From 8e609ac507f894644b771ec75cd71aa1e29d9ada Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:35:43 +0400 Subject: [PATCH 001/289] core: don't subscribe to deleted/left groups, read less data for groups on subscription (#5552) --- src/Simplex/Chat/Controller.hs | 11 +++--- src/Simplex/Chat/Library/Commands.hs | 50 +++++++++++++++------------ src/Simplex/Chat/Store/Groups.hs | 51 ++++++++++++++++++++++++++++ src/Simplex/Chat/Types.hs | 24 +++++++++++++ src/Simplex/Chat/View.hs | 22 ++++++++---- tests/Bots/DirectoryTests.hs | 8 ++--- tests/MobileTests.hs | 1 - 7 files changed, 127 insertions(+), 40 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bf91e5ed23..ac4b50ed9f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -741,7 +741,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} + | CRGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} @@ -757,8 +757,7 @@ data ChatResponse | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} - | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} - | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} + | CRGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} @@ -773,9 +772,9 @@ data ChatResponse | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} - | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} + | CRMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} + | CRGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} @@ -1051,7 +1050,7 @@ data ContactSubStatus = ContactSubStatus deriving (Show) data MemberSubStatus = MemberSubStatus - { member :: GroupMember, + { member :: ShortGroupMember, memberError :: Maybe ChatError } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b8bf879caa..bf568f7946 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3347,17 +3347,17 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce - -- TODO possibly, we could either disable these events or replace with less noisy for API - contactLinkSubsToView rs ucs - groupSubsToView rs gs ms ce - sndFileSubsToView rs sfts - rcvFileSubsToView rs rfts - pendingConnSubsToView rs pcs + unlessM (asks $ coreApi . config) $ do + contactLinkSubsToView rs ucs + groupSubsToView rs gs ms ce + sndFileSubsToView rs sfts + rcvFileSubsToView rs rfts + pendingConnSubsToView rs pcs where addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs') - RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs) + RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs) SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) @@ -3377,6 +3377,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do createdAt, updatedAt = createdAt } + toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} = + ShortGroupMember + { groupMemberId, + groupId, + memberName = localDisplayName, + connId = agentConnId + } getContactConns :: CM ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ (`getUserContacts` vr) @@ -3387,11 +3394,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) - getGroupMemberConns :: CM ([Group], [ConnId], Map ConnId GroupMember) + getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember) getGroupMemberConns = do - gs <- withStore_ (`getUserGroups` vr) - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs + gs <- withStore_ getUserGroupsToSubscribe + let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs pure (gs, map fst mPairs, M.fromList mPairs) + where + shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do sfts <- withStore_ getLiveSndFileTransfers @@ -3435,30 +3444,27 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do -- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs - groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> CM () + groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () groupSubsToView rs gs ms ce = do mapM_ groupSub $ - sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs + sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms - groupSub :: Group -> CM () - groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do + groupSub :: ShortGroup -> CM () + groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors toView groupEvent where - mErrors :: [(GroupMember, ChatError)] + mErrors :: [(ShortGroupMember, ChatError)] mErrors = - sortOn (\(GroupMember {localDisplayName = n}, _) -> n) + sortOn (\(ShortGroupMember {memberName = n}, _) -> n) . filterErrors - $ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs + $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs groupEvent :: ChatResponse groupEvent - | memberStatus membership == GSMemInvited = CRGroupInvitation user g - | all (\GroupMember {activeConn} -> isNothing activeConn) members = - if memberActive membership - then CRGroupEmpty user g - else CRGroupRemoved user g + | membershipStatus == GSMemInvited = CRGroupInvitation user g + | null members = CRGroupEmpty user g | otherwise = CRGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM () sndFileSubsToView rs sfts = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 589e220690..1d1a715b78 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -57,6 +57,7 @@ module Simplex.Chat.Store.Groups deleteGroupItemsAndMembers, deleteGroup, getUserGroups, + getUserGroupsToSubscribe, getUserGroupDetails, getUserGroupsWithSummary, getGroupSummary, @@ -588,6 +589,51 @@ getGroup db vr user groupId = do members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members +getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup +getGroupToSubscribe db User {userId, userContactId} groupId = do + shortInfo <- getGroupInfoToSubscribe + members <- liftIO getGroupMembersToSubscribe + pure $ ShortGroup shortInfo members + where + getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo + getGroupInfoToSubscribe = ExceptT $ do + firstRow toInfo (SEGroupNotFound groupId) $ + DB.query + db + [sql| + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + |] + (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo + toInfo (groupName, membershipStatus) = + ShortGroupInfo groupId groupName membershipStatus + getGroupMembersToSubscribe :: IO [ShortGroupMember] + getGroupMembersToSubscribe = do + map toShortMember + <$> DB.query + db + [sql| + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + 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.member_status NOT IN (?,?,?) + |] + (userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember + toShortMember (groupMemberId, localDisplayName, agentConnId) = + ShortGroupMember groupMemberId groupId localDisplayName agentConnId + deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m) @@ -642,6 +688,11 @@ getUserGroups db vr user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db vr user) groupIds +getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] +getUserGroupsToSubscribe db user@User {userId} = do + groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) + rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds + getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do g_ <- diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 11587694cb..d137e54d23 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -373,6 +373,26 @@ optionalFullName displayName fullName | T.null fullName || displayName == fullName = "" | otherwise = " (" <> fullName <> ")" +data ShortGroup = ShortGroup + { shortInfo :: ShortGroupInfo, + members :: [ShortGroupMember] + } + +data ShortGroupInfo = ShortGroupInfo + { groupId :: GroupId, + groupName :: GroupName, + membershipStatus :: GroupMemberStatus + } + deriving (Eq, Show) + +data ShortGroupMember = ShortGroupMember + { groupMemberId :: GroupMemberId, + groupId :: GroupId, + memberName :: ContactName, + connId :: AgentConnId + } + deriving (Show) + data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Eq, Show) @@ -1812,3 +1832,7 @@ $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) $(JQ.deriveJSON defaultJSON ''ChatTag) + +$(JQ.deriveJSON defaultJSON ''ShortGroupInfo) + +$(JQ.deriveJSON defaultJSON ''ShortGroupMember) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 84cb561396..1d578dea24 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -292,7 +292,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] - CRGroupInvitation u g -> ttyUser u [groupInvitation' g] + CRGroupInvitation u g -> ttyUser u [groupInvitationSub g] 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 @@ -307,8 +307,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] - CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] + CRGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g @@ -323,9 +322,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' - CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] + CRMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g + CRGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g CRPendingSubSummary u _ -> ttyUser u [] CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] @@ -571,8 +570,8 @@ viewUsersList us = <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -viewGroupSubscribed :: GroupInfo -> [StyledString] -viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] +viewGroupSubscribed :: GroupName -> [StyledString] +viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String showSMPServer ProtocolServer {host} = B.unpack $ strEncode host @@ -1216,6 +1215,15 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " +groupInvitationSub :: ShortGroupInfo -> StyledString +groupInvitationSub ShortGroupInfo {groupName = ldn} = + highlight ("#" <> viewName ldn) + <> " - you are invited (" + <> highlight ("/j " <> viewName ldn) + <> " to join, " + <> highlight ("/d #" <> viewName ldn) + <> " to delete invitation)" + viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 6cb11f3f93..7a0ae79ccc 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -923,13 +923,13 @@ testRestoreDirectory tmp = do withTestChat tmp "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" bob - <### [ "#privacy (Privacy): connected to server(s)", - "#security (Security): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#security: connected to server(s)" ] cath <## "2 contacts connected (use /cs for the list)" cath - <### [ "#privacy (Privacy): connected to server(s)", - "#anonymity (Anonymity): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#anonymity: connected to server(s)" ] listGroups superUser bob cath groupFoundN 3 bob "privacy" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 5c4ab29c60..e99b61dd5c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -169,7 +169,6 @@ testChatApi tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` networkStatuses - chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown From 969a7c433d7002822d0f65cc4b82665f72905470 Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 22 Jan 2025 19:32:09 +0000 Subject: [PATCH 002/289] android, desktop: support chat item ttl per chat (#5546) * android, desktop: support chat item ttl per chat * disable all intereactions when reloading ttl * changes matching ios * changes * simplify * changes * divided apiLoadMessages * change * stacktrace * unneeded coroutineScope * only disable ttl * remove delay * look * look * Revert "look" This reverts commit 86a5d1d51191d2808a2a4d3549a199e8d5b02675. --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin --- .../chat/simplex/common/model/ChatModel.kt | 37 ++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 22 +++- .../simplex/common/views/chat/ChatInfoView.kt | 109 +++++++++++++++++- .../common/views/chat/ChatItemsLoader.kt | 13 ++- .../views/chat/group/GroupChatInfoView.kt | 52 ++++++++- .../common/views/chat/group/GroupLinkView.kt | 3 +- .../common/views/database/DatabaseView.kt | 34 +++--- .../commonMain/resources/MR/base/strings.xml | 8 ++ 8 files changed, 242 insertions(+), 36 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 4eb0b350cb..c3bea3be90 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 @@ -1484,6 +1484,7 @@ data class Contact( val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, val chatTags: List, + val chatItemTTL: Long?, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { @@ -1567,7 +1568,8 @@ data class Contact( contactGrpInvSent = false, chatDeleted = false, uiThemes = null, - chatTags = emptyList() + chatTags = emptyList(), + chatItemTTL = null, ) } } @@ -1726,6 +1728,7 @@ data class GroupInfo ( val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, val chatTags: List, + val chatItemTTL: Long?, override val localAlias: String, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group @@ -1774,7 +1777,8 @@ data class GroupInfo ( chatTs = Clock.System.now(), uiThemes = null, chatTags = emptyList(), - localAlias = "" + localAlias = "", + chatItemTTL = null ) } } @@ -4186,32 +4190,49 @@ enum class SwitchPhase { @SerialName("completed") Completed } -sealed class ChatItemTTL: Comparable { +sealed class ChatItemTTL: Comparable { object Day: ChatItemTTL() object Week: ChatItemTTL() object Month: ChatItemTTL() + object Year: ChatItemTTL() data class Seconds(val secs: Long): ChatItemTTL() object None: ChatItemTTL() - override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE) + override fun compareTo(other: ChatItemTTL): Int = + (seconds.takeIf { it != 0L } ?: Long.MAX_VALUE) + .compareTo(other.seconds.takeIf { it != 0L } ?: Long.MAX_VALUE) - val seconds: Long? + val seconds: Long get() = when (this) { - is None -> null + is None -> 0 is Day -> 86400L is Week -> 7 * 86400L is Month -> 30 * 86400L + is Year -> 365 * 86400L is Seconds -> secs } + val text: String + get() = when(this) { + is None -> generalGetString(MR.strings.chat_item_ttl_none) + is Day -> generalGetString(MR.strings.chat_item_ttl_day) + is Week -> generalGetString(MR.strings.chat_item_ttl_week) + is Month -> generalGetString(MR.strings.chat_item_ttl_month) + is Year -> generalGetString(MR.strings.chat_item_ttl_year) + is Seconds -> String.format(generalGetString(MR.strings.chat_item_ttl_seconds), secs) + } + + val neverExpires: Boolean get() = this is None + companion object { - fun fromSeconds(seconds: Long?): ChatItemTTL = + fun fromSeconds(seconds: Long): ChatItemTTL = when (seconds) { - null -> None + 0L -> None 86400L -> Day 7 * 86400L -> Week 30 * 86400L -> Month + 365 * 86400L -> Year else -> Seconds(seconds) } } 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 f891d206a3..bf011c0bb7 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 @@ -1182,7 +1182,13 @@ object ChatController { suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) - if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) + if (r is CR.ChatItemTTL) { + return if (r.chatItemTTL != null) { + ChatItemTTL.fromSeconds(r.chatItemTTL) + } else { + ChatItemTTL.None + } + } throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } @@ -1193,6 +1199,13 @@ object ChatController { throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } + suspend fun setChatTTL(rh: Long?, chatType: ChatType, id: Long, chatItemTTL: ChatItemTTL?) { + val userId = currentUserId("setChatTTL") + val r = sendCmd(rh, CC.APISetChatTTL(userId, chatType, id, chatItemTTL?.seconds)) + if (r is CR.CmdOk) return + throw Exception("failed to set chat TTL: ${r.responseType} ${r.details}") + } + suspend fun apiSetNetworkConfig(cfg: NetCfg, showAlertOnError: Boolean = true, ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl) return when (r) { @@ -3383,8 +3396,9 @@ sealed class CC { class ApiGetUsageConditions(): CC() class ApiSetConditionsNotified(val conditionsId: Long): CC() class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List): CC() - class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() + class APISetChatItemTTL(val userId: Long, val seconds: Long): CC() class APIGetChatItemTTL(val userId: Long): CC() + class APISetChatTTL(val userId: Long, val chatType: ChatType, val id: Long, val seconds: Long?): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC() @@ -3567,6 +3581,7 @@ 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 APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}" @@ -3727,6 +3742,7 @@ sealed class CC { is ApiAcceptConditions -> "apiAcceptConditions" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" + is APISetChatTTL -> "apiSetChatTTL" is APISetNetworkConfig -> "apiSetNetworkConfig" is APIGetNetworkConfig -> "apiGetNetworkConfig" is APISetNetworkInfo -> "apiSetNetworkInfo" @@ -3812,7 +3828,7 @@ sealed class CC { data class ItemRange(val from: Long, val to: Long) fun chatItemTTLStr(seconds: Long?): String { - if (seconds == null) return "none" + if (seconds == null) return "default" return seconds.toString() } 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 2b3cf773cc..7f5ed7b2a5 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 @@ -16,8 +16,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.* 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.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* @@ -36,12 +38,14 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen 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.ProgressIndicator 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.delay @@ -74,6 +78,9 @@ fun ChatInfoView( } val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } + val chatItemTTL = remember(contact.id) { mutableStateOf(if (contact.chatItemTTL != null) ChatItemTTL.fromSeconds(contact.chatItemTTL) else null) } + val deletingItems = rememberSaveable(contact.id) { mutableStateOf(false) } + ChatInfoLayout( chat, contact, @@ -84,6 +91,16 @@ fun ChatInfoView( updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, + chatItemTTL = chatItemTTL, + setChatItemTTL = { + if (it == chatItemTTL.value) { + return@ChatInfoLayout + } + val previousChatTTL = chatItemTTL.value + chatItemTTL.value = it + + setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + }, connStats = connStats, contactNetworkStatus.value, customUserProfile, @@ -173,7 +190,8 @@ fun ChatInfoView( } }, close = close, - onSearchClicked = onSearchClicked + onSearchClicked = onSearchClicked, + deletingItems = deletingItems ) } } @@ -504,6 +522,8 @@ fun ChatInfoLayout( currentUser: User, sendReceipts: State, setSendReceipts: (SendReceipts) -> Unit, + chatItemTTL: MutableState, + setChatItemTTL: (ChatItemTTL?) -> Unit, connStats: MutableState, contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, @@ -520,7 +540,8 @@ fun ChatInfoLayout( syncContactConnectionForce: () -> Unit, verifyClicked: () -> Unit, close: () -> Unit, - onSearchClicked: () -> Unit + onSearchClicked: () -> Unit, + deletingItems: State ) { val cStats = connStats.value val scrollState = rememberScrollState() @@ -597,6 +618,9 @@ fun ChatInfoLayout( } SectionDividerSpaced(maxBottomPadding = false) + ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + val conn = contact.activeConn if (conn != null) { SectionView { @@ -1308,6 +1332,80 @@ fun queueInfoText(info: Pair): String { return generalGetString(MR.strings.message_queue_info_server_info).format(json.encodeToString(qInfo), msgInfo) } +fun setChatTTLAlert( + rhId: Long?, + chatInfo: ChatInfo, + selectedChatTTL: MutableState, + previousChatTTL: ChatItemTTL?, + progressIndicator: MutableState +) { + val defaultTTL = chatModel.chatItemTTL.value + val previouslyUsedTTL = previousChatTTL ?: defaultTTL + val newTTLToUse = selectedChatTTL.value ?: defaultTTL + + AlertManager.shared.showAlertDialog( + title = generalGetString( + if (newTTLToUse.neverExpires) { + MR.strings.disable_automatic_deletion_question + } else if (!previouslyUsedTTL.neverExpires || selectedChatTTL.value == null) { + MR.strings.change_automatic_deletion_question + } else MR.strings.enable_automatic_deletion_question), + text = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion_message else MR.strings.change_automatic_chat_deletion_message), + confirmText = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion else MR.strings.delete_messages), + onConfirm = { setChatTTL(rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) }, + onDismiss = { selectedChatTTL.value = previousChatTTL }, + onDismissRequest = { selectedChatTTL.value = previousChatTTL }, + destructive = true, + ) +} + +private fun setChatTTL( + rhId: Long?, + chatInfo: ChatInfo, + chatTTL: MutableState, + progressIndicator: MutableState, + previousChatTTL: ChatItemTTL? + ) { + progressIndicator.value = true + withBGApi { + try { + chatModel.controller.setChatTTL(rhId, chatInfo.chatType, chatInfo.apiId, chatTTL.value) + afterSetChatTTL(rhId, chatInfo, progressIndicator) + } catch (e: Exception) { + chatTTL.value = previousChatTTL + afterSetChatTTL(rhId, chatInfo, progressIndicator) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_message_deletion), e.stackTraceToString()) + } + } +} + +private suspend fun afterSetChatTTL(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 + if (chat.chatItems.isEmpty()) { + // replacing old chat with the same old chat but without items. Less intrusive way of clearing a preview + withChats { + val oldChat = getChat(chat.id) + if (oldChat != null) { + replaceChat(oldChat.remoteHostId, oldChat.id, oldChat.copy(chatItems = emptyList())) + } + } + } + if (chat.remoteHostId != chatModel.remoteHostId() || chat.id != chatModel.chatId.value) return + processLoadedChat( + chat, + navInfo, + contentTag = null, + pagination = pagination + ) + } catch (e: Exception) { + Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}") + } finally { + progressIndicator.value = false + } +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -1322,6 +1420,8 @@ fun PreviewChatInfoLayout() { User.sampleData, sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, setSendReceipts = {}, + chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, + setChatItemTTL = {}, localAlias = "", connectionCode = "123", developerTools = false, @@ -1338,7 +1438,8 @@ fun PreviewChatInfoLayout() { syncContactConnectionForce = {}, verifyClicked = {}, close = {}, - onSearchClicked = {} + onSearchClicked = {}, + deletingItems = remember { mutableStateOf(false) } ) } } 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 6419aa884d..3613ceaa7c 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 @@ -34,7 +34,16 @@ suspend fun apiLoadMessages( // 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 if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) || !isActive) return@coroutineScope + processLoadedChat(chat, navInfo, contentTag, pagination, visibleItemIndexesNonReversed) +} +suspend fun processLoadedChat( + chat: Chat, + navInfo: NavigationInfo, + contentTag: MsgContentTag?, + pagination: ChatPagination, + visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } +) { val chatState = chatModel.chatStateForContent(contentTag) val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState val oldItems = chatModel.chatItemsForContent(contentTag).value @@ -70,7 +79,7 @@ suspend fun apiLoadMessages( is ChatPagination.Before -> { newItems.addAll(oldItems) val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } - if (indexInCurrentItems == -1) return@coroutineScope + if (indexInCurrentItems == -1) return val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( @@ -87,7 +96,7 @@ suspend fun apiLoadMessages( is ChatPagination.After -> { newItems.addAll(oldItems) val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } - if (indexInCurrentItems == -1) return@coroutineScope + if (indexInCurrentItems == -1) return val mappedItems = mapItemsToIds(chat.chatItems) val newIds = mappedItems.first 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 9b2986ef83..b2963c4d4a 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 @@ -17,6 +17,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString @@ -39,6 +40,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.database.TtlOptions import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -55,7 +57,10 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { val groupInfo = chat.chatInfo.groupInfo val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } + 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() + GroupChatInfoLayout( chat, groupInfo, @@ -66,6 +71,16 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, + chatItemTTL = chatItemTTL, + setChatItemTTL = { + if (it == chatItemTTL.value) { + return@GroupChatInfoLayout + } + val previousChatTTL = chatItemTTL.value + chatItemTTL.value = it + + setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + }, members = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, @@ -125,7 +140,8 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin manageGroupLink = { ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } }, - onSearchClicked = onSearchClicked + onSearchClicked = onSearchClicked, + deletingItems = deletingItems ) } } @@ -285,6 +301,8 @@ fun ModalData.GroupChatInfoLayout( currentUser: User, sendReceipts: State, setSendReceipts: (SendReceipts) -> Unit, + chatItemTTL: MutableState, + setChatItemTTL: (ChatItemTTL?) -> Unit, members: List, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, @@ -300,7 +318,8 @@ fun ModalData.GroupChatInfoLayout( leaveGroup: () -> Unit, manageGroupLink: () -> Unit, close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, - onSearchClicked: () -> Unit + onSearchClicked: () -> Unit, + deletingItems: State ) { val listState = remember { appBarHandler.listState } val scope = rememberCoroutineScope() @@ -394,7 +413,10 @@ fun ModalData.GroupChatInfoLayout( } 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) + 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), members.count() + 1)) { if (groupInfo.canAddMembers) { @@ -463,6 +485,26 @@ fun ModalData.GroupChatInfoLayout( } } +@Composable +fun ChatTTLSection(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)) + } + if (deletingItems.value) { + Box(Modifier.matchParentSize()) { + ProgressIndicator() + } + } + } +} + @Composable private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { Column( @@ -770,12 +812,14 @@ fun PreviewGroupChatInfoLayout() { User.sampleData, sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, setSendReceipts = {}, + chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, + setChatItemTTL = {}, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, onLocalAliasChanged = {}, groupLink = null, scrollToItemId = remember { mutableStateOf(null) }, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, + addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, 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/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 956ee575de..987a80e7c0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -1,9 +1,8 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index d951f1f812..4a3e1cda54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* @@ -108,6 +109,9 @@ fun DatabaseView() { } }, onChatItemTTLSelected = { + if (it == null) { + return@DatabaseLayout + } val oldValue = chatItemTTL.value chatItemTTL.value = it if (it < oldValue) { @@ -158,7 +162,7 @@ fun DatabaseLayout( exportArchive: () -> Unit, deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, - onChatItemTTLSelected: (ChatItemTTL) -> Unit, + onChatItemTTLSelected: (ChatItemTTL?) -> Unit, disconnectAllHosts: () -> Unit, ) { val operationsDisabled = progressIndicator && !chatModel.desktopNoUserNoRemote @@ -300,21 +304,25 @@ private fun setChatItemTTLAlert( } @Composable -private fun TtlOptions(current: State, enabled: State, onSelected: (ChatItemTTL) -> Unit) { +fun TtlOptions( + current: State, + enabled: State, + onSelected: (ChatItemTTL?) -> Unit, + default: State? = null +) { val values = remember { - val all: ArrayList = arrayListOf(ChatItemTTL.None, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day) - if (current.value is ChatItemTTL.Seconds) { - all.add(current.value) + val all: ArrayList = arrayListOf(ChatItemTTL.None, ChatItemTTL.Year, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day) + val currentValue = current.value + if (currentValue is ChatItemTTL.Seconds) { + all.add(currentValue) } - all.map { - when (it) { - is ChatItemTTL.None -> it to generalGetString(MR.strings.chat_item_ttl_none) - is ChatItemTTL.Day -> it to generalGetString(MR.strings.chat_item_ttl_day) - is ChatItemTTL.Week -> it to generalGetString(MR.strings.chat_item_ttl_week) - is ChatItemTTL.Month -> it to generalGetString(MR.strings.chat_item_ttl_month) - is ChatItemTTL.Seconds -> it to String.format(generalGetString(MR.strings.chat_item_ttl_seconds), it.secs) - } + val options: MutableList> = all.map { it to it.text }.toMutableList() + + if (default != null) { + options.add(null to String.format(generalGetString(MR.strings.chat_item_ttl_default), default.value.text)) } + + options } ExposedDropDownSettingRow( generalGetString(MR.strings.delete_messages_after), 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 954c22abee..ded58ac9b3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -540,6 +540,12 @@ Notifications + Disable automatic message deletion? + Change automatic message deletion? + Messages in this chat will never be deleted. + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Disable delete messages + Delete chat messages from your device. connect @@ -1396,7 +1402,9 @@ 1 day 1 week 1 month + 1 year %s second(s) + default (%s) Messages This setting applies to messages in your current chat profile Delete messages after From 9ccea0dc500e9981bfad919e6a8e8f08dea48d60 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Jan 2025 23:33:54 +0400 Subject: [PATCH 003/289] core: get group history faster (#5562) * core: get group history faster * revert join, add index (fix test) * fix postgres compilation * fix postgres schema --- simplex-chat.cabal | 1 + src/Simplex/Chat/Store/Groups.hs | 6 ++- src/Simplex/Chat/Store/Messages.hs | 21 +++++----- .../Postgres/Migrations/M20241220_initial.hs | 15 ++++++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- ...M20250122_chat_items_include_in_history.hs | 39 +++++++++++++++++++ .../Store/SQLite/Migrations/chat_schema.sql | 15 ++++++- 7 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6b0b8bdd82..e481c91687 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -220,6 +220,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20241230_reports Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl + Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 1d1a715b78..eb820eb66d 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -165,9 +165,13 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM - +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else 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)) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index a828a30925..58877210dc 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -415,20 +415,20 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_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, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote 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) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe 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)) :. 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 = case chatDirection of @@ -438,6 +438,10 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing, Nothing) CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) + includeInHistory :: Bool + includeInHistory = + let (_, groupId_, _, _) = idsRow + in isJust groupId_ && isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) forwardedFromRow = case itemForwarded of Nothing -> @@ -3070,12 +3074,11 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do SELECT i.chat_item_id FROM chat_items i LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? - WHERE i.user_id = ? AND i.group_id = ? - AND i.item_content_tag IN (?,?) - AND i.msg_content_tag NOT IN (?) + WHERE s.group_snd_item_status_id IS NULL + AND i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 AND i.item_deleted = 0 - AND s.group_snd_item_status_id IS NULL ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] - (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count) + (groupMemberId' m, userId, groupId, count) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index e8fd77aa0d..60a2ace8f3 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -425,7 +425,8 @@ CREATE TABLE chat_items( fwd_from_group_id BIGINT REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL, via_proxy SMALLINT, - msg_content_tag TEXT + msg_content_tag TEXT, + include_in_history SMALLINT NOT NULL DEFAULT 0 ); ALTER TABLE groups ADD CONSTRAINT fk_groups_chat_items @@ -1012,4 +1013,16 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( item_deleted, item_sent ); +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses( + chat_item_id, + group_member_id +); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f8bdc0d788..c4d4237f98 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -124,6 +124,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl +import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -247,7 +248,8 @@ schemaMigrations = ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), ("20241230_reports", m20241230_reports, Just down_m20241230_reports), ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), - ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl) + ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), + ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs new file mode 100644 index 0000000000..afc8544b4a --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250122_chat_items_include_in_history :: Query +m20250122_chat_items_include_in_history = + [sql| +ALTER TABLE chat_items ADD COLUMN include_in_history INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); + +UPDATE chat_items +SET include_in_history = 1 +WHERE group_id IS NOT NULL + AND item_content_tag IN ('rcvMsgContent', 'sndMsgContent') + AND msg_content_tag NOT IN ('report'); + +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses(chat_item_id, group_member_id); +|] + +down_m20250122_chat_items_include_in_history :: Query +down_m20250122_chat_items_include_in_history = + [sql| +DROP INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id; + +DROP INDEX idx_chat_items_groups_history; + +ALTER TABLE chat_items DROP COLUMN include_in_history; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 923928ad5c..0fef1b43d2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -406,7 +406,8 @@ CREATE TABLE chat_items( fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, via_proxy INTEGER, - msg_content_tag TEXT + msg_content_tag TEXT, + include_in_history INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -978,3 +979,15 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( item_deleted, item_sent ); +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses( + chat_item_id, + group_member_id +); From f3664619ecf26803b2dbd40436bd05d769508551 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 24 Jan 2025 09:44:53 +0000 Subject: [PATCH 004/289] test: track query plans (#5566) * test: track query plans * all query plans * fix postgres build --- cabal.project | 2 +- simplex-chat.cabal | 7 +- src/Simplex/Chat/Mobile.hs | 5 +- src/Simplex/Chat/Options/SQLite.hs | 22 +- .../SQLite/Migrations/chat_query_plans.txt | 5969 +++++++++++++++++ src/Simplex/Chat/Terminal.hs | 2 +- tests/Bots/BroadcastTests.hs | 25 +- tests/Bots/DirectoryTests.hs | 335 +- tests/ChatClient.hs | 118 +- tests/ChatTests.hs | 3 +- tests/ChatTests/ChatList.hs | 17 +- tests/ChatTests/DBUtils.hs | 15 + tests/ChatTests/DBUtils/Postgres.hs | 5 + tests/ChatTests/DBUtils/SQLite.hs | 10 + tests/ChatTests/Direct.hs | 406 +- tests/ChatTests/Files.hs | 83 +- tests/ChatTests/Forward.hs | 49 +- tests/ChatTests/Groups.hs | 428 +- tests/ChatTests/Local.hs | 33 +- tests/ChatTests/Profiles.hs | 168 +- tests/ChatTests/Utils.hs | 57 +- tests/MobileTests.hs | 54 +- tests/RemoteTests.hs | 25 +- tests/SchemaDump.hs | 48 +- tests/Test.hs | 20 +- 25 files changed, 7009 insertions(+), 897 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt create mode 100644 tests/ChatTests/DBUtils.hs create mode 100644 tests/ChatTests/DBUtils/Postgres.hs create mode 100644 tests/ChatTests/DBUtils/SQLite.hs diff --git a/cabal.project b/cabal.project index 9a7346c4d0..fab2f2f9c7 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: 23189753751dc52046865ce2d992335495020e91 + tag: 268a1303acbad1644f81cd3b2def5754e5e2c052 source-repository-package type: git diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e481c91687..59385080d4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -446,6 +446,7 @@ test-suite simplex-chat-test ChatTests ChatTests.ChatList ChatTests.Direct + ChatTests.DBUtils ChatTests.Files ChatTests.Forward ChatTests.Groups @@ -470,8 +471,12 @@ test-suite simplex-chat-test Directory.Service Directory.Store Paths_simplex_chat - if !flag(client_postgres) + if flag(client_postgres) other-modules: + ChatTests.DBUtils.Postgres + else + other-modules: + ChatTests.DBUtils.SQLite MobileTests SchemaDump WebRTCTests diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b30c004b97..3a61e6cdff 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -16,7 +16,6 @@ import Control.Monad.Reader import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) -import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -57,8 +56,10 @@ import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) #if !defined(dbPostgres) +import Data.ByteArray (ScrubbedBytes) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB #endif data DBMigrationResult @@ -237,7 +238,7 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers -- only used in tests chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = do - let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = True} + let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True} chatMigrateInitKey chatDBOpts False confirm False #endif diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 11eaf7e58c..0507076613 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Options.SQLite where @@ -11,11 +12,13 @@ import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import Simplex.Messaging.Agent.Store.SQLite.DB (TrackQueries (..)) import System.FilePath (combine) data ChatDbOpts = ChatDbOpts { dbFilePrefix :: String, dbKey :: ScrubbedBytes, + trackQueries :: TrackQueries, vacuumOnMigration :: Bool } @@ -43,17 +46,24 @@ chatDbOptsP appDir defaultDbName = do ( long "disable-vacuum" <> help "Do not vacuum database after migrations" ) - pure ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = not disableVacuum} + pure + ChatDbOpts + { dbFilePrefix, + dbKey, + trackQueries = TQSlow 5000, -- 5ms + vacuumOnMigration = not disableVacuum + } dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} dbSuffix keepKey = do +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey = do DBOpts { dbFilePath = dbFilePrefix <> dbSuffix, dbKey, keepKey, + track = trackQueries, vacuum = vacuumOnMigration } @@ -71,18 +81,14 @@ mobileDbOpts fp key = do ChatDbOpts { dbFilePrefix, dbKey, + trackQueries = TQSlow 5000, -- 5ms vacuumOnMigration = True } -- used to create new chat controller, -- at that point database is already opened, and the key in options is not used removeDbKey :: ChatDbOpts -> ChatDbOpts -removeDbKey ChatDbOpts {dbFilePrefix, vacuumOnMigration} = - ChatDbOpts - { dbFilePrefix, - dbKey = "", - vacuumOnMigration - } +removeDbKey opts = opts {dbKey = ""} :: ChatDbOpts errorDbStr :: DBOpts -> String errorDbStr DBOpts {dbFilePath} = dbFilePath diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt new file mode 100644 index 0000000000..a58775429d --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -0,0 +1,5969 @@ +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_xcontact_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, 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, + -- 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, + -- 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 + 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 + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_requests + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_requests + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + +Plan: +SEARCH contact_requests 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, + created_at, updated_at, xcontact_id, pq_support) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO display_names + (local_display_name, ldn_base, ldn_suffix, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.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 contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + +Plan: +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT COUNT(1) + FROM chat_items i + JOIN contacts ct USING (contact_id) + WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) AND ct.deleted = 0 + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts (user_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT COUNT(1) + FROM chat_items i + JOIN groups g USING (group_id) + WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups (user_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT conditions_commit, accepted_at, auto_accepted + FROM operator_usage_conditions + WHERE server_operator_id = ? + ORDER BY operator_usage_conditions_id DESC + LIMIT 1 + +Plan: +SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_server_operator_id (server_operator_id=?) + +Query: + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) + +Query: + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + 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.member_status NOT IN (?,?,?) + +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT timed_ttl + FROM chat_items + WHERE user_id = ? AND contact_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: + 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 = ?, + full_name = ?, + image = ?, + contact_link = ?, + updated_at = ? + WHERE contact_profile_id IN ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? + AND contact_request_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE xftp_file_descriptions + SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ? + WHERE file_descr_id = ? + +Plan: +SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO contacts ( + user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + created_at, updated_at, chat_ts + ) VALUES (?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages ( + msg_sent, chat_msg_event, msg_body, connection_id, group_id, + shared_msg_id, shared_msg_id_user, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, + p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + FROM contacts c + JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id + WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 + +Plan: +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? + LIMIT 1 + +Plan: +SEARCH cr USING INDEX idx_contact_requests_xcontact_id (xcontact_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_ts > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_ts = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=? AND item_ts>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=? AND item_ts=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT author_group_member_id, forwarded_by_group_member_id + FROM messages + WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 + +Plan: +SEARCH messages USING INDEX idx_messages_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.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 connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +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 + FROM chat_items + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_id, contact_id, group_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 + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT conn_req_contact, group_id + 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, + created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, + conn_chat_version, peer_chat_min_version, peer_chat_max_version + FROM connections + WHERE user_id = ? AND agent_conn_id = ? + +Plan: +SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) + +Query: + SELECT ct.contact_id + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + +Plan: +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 + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? + WHERE s.group_snd_item_status_id IS NULL + AND i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) +SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) LEFT-JOIN + +Query: + SELECT i.chat_item_id, + -- 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 + 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 + LEFT JOIN chat_items i ON i.user_id = m.user_id + AND i.group_id = m.group_id + AND m.group_member_id = i.group_member_id + AND i.shared_msg_id = ? + WHERE m.user_id = ? AND m.group_id = ? AND m.member_id = ? + +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +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 + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + LIMIT 1 + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ldn_suffix FROM display_names + WHERE user_id = ? AND ldn_base = ? + ORDER BY ldn_suffix DESC + LIMIT 1 + +Plan: +SEARCH display_names USING COVERING INDEX sqlite_autoindex_display_names_2 (user_id=? AND ldn_base=?) + +Query: + SELECT s.contact_id, g.group_id, s.group_member_id + FROM sent_probes s + LEFT JOIN contacts c ON s.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON s.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + JOIN sent_probe_hashes h ON h.sent_probe_id = s.sent_probe_id + WHERE s.user_id = ? AND s.probe = ? + AND (h.contact_id = ? OR h.group_member_id = ?) + +Plan: +SEARCH s USING INDEX sqlite_autoindex_sent_probes_1 (user_id=? AND probe=?) +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 connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +LIST SUBQUERY 1 +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 = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: + DELETE FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: + INSERT INTO chat_item_reactions + (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_reactions + (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at, chat_ts) + SELECT contact_profile_id, group_id, ?, ?, ?, ?, ? + FROM group_members + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_contact_id (contact_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_contact_id (contact_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_contact_id (contact_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) +SEARCH calls USING COVERING INDEX idx_calls_contact_id (contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_contact_id (fwd_from_contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_contact_id (contact_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_id (contact_id=?) +SEARCH connections USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH connections USING COVERING INDEX idx_connections_via_contact (via_contact=?) +SEARCH files USING COVERING INDEX idx_files_contact_id (contact_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by (invited_by=?) +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (local_display_name, user_id, group_profile_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages + (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO server_operators + (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO usage_conditions + (usage_conditions_id, conditions_commit, notified_at, created_at) + VALUES (?,?,?,?) + +Plan: + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- CIMeta forwardedByMember + i.forwarded_by_group_member_id, + -- 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, + -- 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, + -- 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 + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_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 + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) + LEFT JOIN group_members dbm ON dbm.group_member_id = i.item_deleted_by_group_member_id + LEFT JOIN contact_profiles dbp ON dbp.contact_profile_id = COALESCE(dbm.member_profile_id, dbm.contact_profile_id) + WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) 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_shared_msg_id (shared_msg_id=?) LEFT-JOIN +SEARCH rm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH rp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH dbm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH dbp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN +SEARCH ri USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) LEFT-JOIN + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.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 contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + +Plan: +SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, 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, + -- 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, + -- 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, + 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 contacts ct ON ct.contact_id = m.contact_id + 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 AND g.group_id = ct.via_group + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.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 ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu 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 + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, 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, + 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 + FROM groups g + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu USING (group_id) + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT 1 FROM users + WHERE (user_id = ? AND local_display_name = ?) + OR contact_id = ? + LIMIT 1 + +Plan: +MULTI-INDEX OR +INDEX 1 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +INDEX 2 +SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + SELECT DISTINCT f.file_id + FROM files f + JOIN snd_files s USING (file_id) + WHERE f.user_id = ? + AND s.file_status IN (?, ?, ?) + AND s.file_descr_id IS NULL + AND s.file_inline IS NULL + AND s.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH s USING INDEX idx_snd_files_file_descr_id (file_descr_id=? AND file_id=?) + +Query: + SELECT agent_conn_id FROM ( + SELECT + agent_conn_id, + (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord + FROM connections + WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? + ORDER BY conn_ord DESC, created_at DESC + LIMIT 1 + ) c + +Plan: +SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.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 connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? + ORDER BY created_at DESC, chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NULL AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_member_id (group_member_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_id, contact_id, group_id, note_folder_id + FROM chat_items + WHERE chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT chat_item_id, contact_id, group_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 > ?)) + ORDER BY item_ts ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at + FROM chat_item_moderations + WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + LIMIT 1 + +Plan: +SEARCH chat_item_moderations USING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) + +Query: + SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + FROM connections + WHERE agent_conn_id = ? + +Plan: +SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) + +Query: + SELECT contact_id, group_id + FROM files + WHERE user_id = ? AND file_id = ? + LIMIT 1 + +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT count (m.group_member_id) + FROM groups g + JOIN group_members m USING (group_id) + WHERE g.user_id = ? + AND g.group_id = ? + AND m.member_status NOT IN (?,?,?,?) + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) + +Query: + SELECT ct.contact_id + FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL + +Plan: +SEARCH cp USING COVERING INDEX idx_contact_profiles_contact_link (user_id=? AND contact_link=?) +SEARCH ct USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) +SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN + +Query: + SELECT ct.contact_id + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name + FROM contacts ct + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.agent_conn_id IN (SELECT conn_id FROM temp_conn_ids) + AND c.conn_type = ? + AND ct.deleted = 0 + +Plan: +SEARCH c USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) +LIST SUBQUERY 1 +SCAN temp_conn_ids +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT f.file_id + FROM files f + JOIN rcv_files r USING (file_id) + WHERE f.user_id = ? AND r.file_status IN (?, ?) + AND r.rcv_file_inline IS NULL + AND r.file_descr_id IS NULL + AND r.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?,?) + LIMIT 1 + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT group_id + FROM user_contact_links + WHERE user_id = ? AND conn_req_contact IN (?,?) + +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + +Query: + SELECT group_id, group_member_id + FROM group_members + WHERE user_id = ? AND local_display_name = ? + AND member_status IN (?,?) AND member_category != ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + JOIN group_members m ON m.group_id = i.group_id + AND ((i.group_member_id IS NULL AND m.member_category = ?) + OR i.group_member_id = m.group_member_id) + WHERE i.user_id = ? AND i.group_id = ? AND m.member_id = ? AND i.shared_msg_id = ? + ORDER BY i.chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH i USING INDEX idx_chat_items_shared_msg_id (shared_msg_id=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + JOIN group_members m ON m.group_member_id = i.group_member_id + JOIN contacts c ON c.contact_id = m.contact_id + WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + ORDER BY i.chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH i USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT i.chat_item_id, i.contact_id, i.group_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 = ? + LIMIT 1 + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT member_status + FROM group_members + WHERE group_id = ? AND user_id = ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: + SELECT r.file_id + FROM rcv_files r + JOIN files f ON f.file_id = r.file_id + WHERE f.user_id = ? AND r.file_status = ? + AND r.to_receive = 1 AND r.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, + f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, + f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, + c.connection_id, c.agent_conn_id + FROM rcv_files r + JOIN files f USING (file_id) + LEFT JOIN connections c ON r.file_id = c.rcv_file_id + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = r.group_member_id + WHERE f.user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_rcv_file_id (rcv_file_id=?) LEFT-JOIN +SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT r.probe, r.contact_id, g.group_id, r.group_member_id + FROM received_probes r + LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON r.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL + +Plan: +SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + UPDATE contacts + SET local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_profiles + SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, updated_at = ? + WHERE group_profile_id IN ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE user_contact_links + SET auto_accept = ?, business_address = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? + WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL + +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contacts ( + user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + contact_group_member_id, contact_grp_inv_sent, created_at, updated_at, chat_ts + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO msg_deliveries + (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at, delivery_status) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at, auto_accepted) + VALUES (?,?,?,?,?) + +Plan: + +Query: + INSERT INTO remote_hosts + (host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + +Plan: + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.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 contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + AND ct.deleted = ? + AND ( + c.connection_id = ( + SELECT cc_connection_id FROM ( + SELECT + cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, + (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC + LIMIT 1 + ) cc + ) + OR c.connection_id IS NULL + ) + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 2 +CO-ROUTINE cc +SEARCH cc USING INDEX idx_connections_contact_id (contact_id=?) +USE TEMP B-TREE FOR ORDER BY +SCAN cc + +Query: + SELECT + connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' + AND updated_at < ? ORDER BY updated_at DESC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated_at ? ORDER BY updated_at ASC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated_at>?) + +Query: + SELECT + connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' + ORDER BY updated_at DESC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) + +Query: + SELECT + contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts + FROM calls + ORDER BY call_ts ASC + +Plan: +SCAN calls +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' + ) + AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=? AND updated_at ? ORDER BY cr.updated_at ASC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=? AND updated_at>?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY cr.updated_at DESC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.contact_request_id = ? + +Plan: +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + created_at, updated_at, chat_ts, favorite, unread_chat + FROM note_folders + WHERE user_id = ? + AND note_folder_id = ? + +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ( + LOWER(ct.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.local_alias) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.favorite = 1 + OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts 0) + AND ct.chat_ts > ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ct.favorite = 1 + AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ct.favorite = 1 + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? + AND ( + LOWER(g.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (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>?) +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 +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND (g.favorite = 1 + OR 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_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>?) +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=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.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_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>?) +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_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>?) +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=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.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_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>?) +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=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.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_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>?) +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_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>?) +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=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.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_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>?) +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=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.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_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>?) +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_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>?) +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=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.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 msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.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_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>?) +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=?) + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.favorite = 1 + OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT COUNT(1) + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT COUNT(1) + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.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 connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? + +Plan: +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.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, + uc.user_contact_link_id, uc.conn_req_contact, uc.group_id + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? + +Plan: +SEARCH c USING INDEX idx_connections_updated_at (user_id=?) +SEARCH uc USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ch.connection_id, c.conn_req_inv + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT MAX(cc.connection_id) + FROM connections cc + WHERE cc.contact_id = ct.contact_id + ) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_members mh ON mh.group_id = g.group_id + LEFT JOIN connections ch ON ch.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = mh.group_member_id + ) + WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ct 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_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 2 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT ch.connection_id, g.group_id, m.group_member_id, m.member_id, c.conn_req_inv + FROM contacts ct + JOIN group_members m ON m.contact_id = ct.contact_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT MAX(cc.connection_id) + FROM connections cc + WHERE cc.group_member_id = m.group_member_id + ) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_members mh ON mh.group_id = g.group_id + LEFT JOIN connections ch ON ch.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = mh.group_member_id + ) + WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ? + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc +SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 2 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT chat_item_id + FROM chat_item_messages + WHERE message_id IN ( + SELECT message_id + FROM msg_deliveries + WHERE connection_id = ? AND agent_msg_id = ? + ) + +Plan: +SEARCH chat_item_messages USING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) +LIST SUBQUERY 1 +SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) + ORDER BY created_at ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_sent = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + ORDER BY created_at ASC, chat_item_id ASC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING 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_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) + ORDER BY created_at ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + ORDER BY created_at ASC, chat_item_id ASC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +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 + FROM chat_items + WHERE user_id = ? AND timed_delete_at IS NOT NULL AND timed_delete_at <= ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_timed_delete_at (user_id=? AND timed_delete_at>? AND timed_delete_at= ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) + +Query: + SELECT reaction + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: + SELECT reaction + FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? + GROUP BY reaction + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + GROUP BY reaction + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT rf.file_id, f.contact_id, f.group_id, f.note_folder_id + FROM rcv_files rf + JOIN files f ON f.file_id = rf.file_id + WHERE rf.agent_rcv_file_id = ? + +Plan: +SCAN rf +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT s.file_id, s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.group_member_id, c.local_display_name, m.local_display_name + FROM msg_deliveries d + JOIN snd_files s ON s.connection_id = d.connection_id AND s.last_inline_msg_delivery_id = d.msg_delivery_id + JOIN files f ON f.file_id = s.file_id + LEFT JOIN contacts c USING (contact_id) + LEFT JOIN group_members m USING (group_member_id) + WHERE d.connection_id = ? AND d.agent_msg_id = ? AND f.user_id = ? + AND (s.file_descr_id IS NOT NULL OR s.file_inline IS NOT NULL) + +Plan: +SEARCH d USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) +SEARCH s USING INDEX idx_snd_files_last_inline_msg_delivery_id (last_inline_msg_delivery_id=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.connection_id, c.agent_conn_id, s.group_member_id, + cs.local_display_name, m.local_display_name + FROM snd_files s + JOIN files f USING (file_id) + JOIN connections c USING (connection_id) + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = s.group_member_id + WHERE f.user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING PRIMARY KEY (file_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled + FROM protocol_servers + WHERE user_id = ? AND protocol = ? + +Plan: +SEARCH protocol_servers USING INDEX idx_smp_servers_user_id (user_id=?) + +Query: + SELECT to_group_member_id + FROM group_member_intros + WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) + AND intro_chat_protocol_version >= ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) + +Query: + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE conditions_commit = ( + SELECT conditions_commit + FROM operator_usage_conditions + ORDER BY accepted_at DESC + LIMIT 1 + ) + +Plan: +SEARCH usage_conditions USING INDEX sqlite_autoindex_usage_conditions_1 (conditions_commit=?) +SCALAR SUBQUERY 1 +SCAN operator_usage_conditions +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE usage_conditions_id = ? + +Plan: +SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? + RETURNING chat_item_id; + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_member_intros + SET intro_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_intro_id = ? + +Plan: +SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), + updated_at = ? + WHERE contact_id = ? + AND user_id = ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SCALAR SUBQUERY 1 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 2 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM chat_items + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +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 group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +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 note_folder_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +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 connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH c USING COVERING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH c USING COVERING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT contact_profile_id + FROM contacts + WHERE user_id = ? AND contact_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT cr.contact_profile_id + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT cr.contact_profile_id + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? + AND 1 NOT IN ( + SELECT 1 FROM connections + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contacts + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? + AND (member_profile_id = ? OR contact_profile_id = ?) + LIMIT 1 + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH connections USING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +LIST SUBQUERY 2 +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_2 (user_id=? AND contact_profile_id=?) +LIST SUBQUERY 3 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=? AND contact_profile_id=?) +LIST SUBQUERY 4 +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 + AND 1 NOT IN ( + SELECT 1 FROM connections + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? AND member_profile_id = ? LIMIT 1 + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH connections USING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +LIST SUBQUERY 2 +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? + AND local_display_name in ( + SELECT cr.local_display_name + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? + AND local_display_name in ( + SELECT cr.local_display_name + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ( + SELECT local_display_name FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +SCALAR SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND 1 NOT IN ( + SELECT 1 FROM users + WHERE local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contacts + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM groups + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM user_contact_links + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +LIST SUBQUERY 2 +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 3 +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 4 +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +LIST SUBQUERY 5 +SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 6 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 7 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM files + WHERE user_id = ? + AND chat_item_id IN ( + SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_id = ? + ) + +Plan: +SEARCH files USING INDEX idx_files_user_id (user_id=?) +LIST SUBQUERY 1 +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) +SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) + +Query: + DELETE FROM group_profiles + WHERE group_profile_id in ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SEARCH groups USING COVERING INDEX idx_groups_group_profile_id (group_profile_id=?) + +Query: + DELETE FROM messages + WHERE message_id IN ( + SELECT message_id + FROM chat_item_messages + WHERE chat_item_id = ? + ) + +Plan: +SEARCH messages USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_message_id (message_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) +SEARCH chat_items USING COVERING INDEX sqlite_autoindex_chat_items_1 (created_by_msg_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_message_id (message_id=?) + +Query: + INSERT INTO calls + (contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_moderations + (group_id, moderator_member_id, item_member_id, shared_msg_id, created_by_msg_id, moderated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_versions (chat_item_id, msg_content, item_version_ts) + VALUES (?,?,?) + +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, + -- 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, timed_ttl, timed_delete_at, + -- quote + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO commands (connection_id, command_function, command_status, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, + contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, + via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) + +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + +Query: + INSERT INTO files + ( user_id, note_folder_id, + file_name, file_path, file_size, + file_crypto_key, file_crypto_nonce, + chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO msg_deliveries + (message_id, connection_id, agent_msg_id, chat_ts, created_at, updated_at, delivery_status) + VALUES (?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO pending_group_messages + (group_member_id, message_id, group_member_intro_id, created_at, updated_at) VALUES (?,?,?,?,?) + +Plan: + +Query: + INSERT INTO protocol_servers + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO remote_controllers + (ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key) + VALUES + (?, ?, ?, ?, ?, ?, ?) + +Plan: + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) + +Query: + SELECT group_snd_item_status, COUNT(1) + FROM group_snd_item_statuses + WHERE chat_item_id = ? + GROUP BY group_snd_item_status + +Plan: +SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT s.file_id, r.file_id, f.protocol + FROM files f + LEFT JOIN snd_files s ON s.file_id = f.file_id + LEFT JOIN rcv_files r ON r.file_id = f.file_id + WHERE user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING COVERING INDEX idx_snd_files_file_id (file_id=?) LEFT-JOIN +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, updated_at = ? + WHERE user_id = ? AND note_folder_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND chat_item_id = ? + +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 = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + UPDATE commands + SET connection_id = ?, updated_at = ? + WHERE user_id = ? AND command_id = ? + +Plan: +SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET conn_chat_version = ?, peer_chat_min_version = ?, peer_chat_max_version = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET conn_req_inv = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET contact_conn_initiated = 0, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET custom_user_profile_id = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET local_alias = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_rcv_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_snd_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_snd_enabled = ?, pq_rcv_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_support = ?, pq_encryption = ? + WHERE connection_id = ? + +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 = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, contact_link = ?, preferences = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET local_alias = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET contact_group_member_id = NULL, contact_grp_inv_sent = 0, updated_at = ? + WHERE contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET contact_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_member_intros + SET intro_status = ?, updated_at = ? + WHERE group_member_intro_id = ? + +Plan: +SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_restriction = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_status = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET show_messages = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_profiles + SET preferences = ?, updated_at = ? + WHERE group_profile_id IN ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_snd_item_statuses + SET group_snd_item_status = ?, updated_at = ? + WHERE chat_item_id = ? AND group_member_id = ? + +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 group_snd_item_statuses + SET via_proxy = ? + WHERE chat_item_id = ? AND group_member_id = ? + +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 via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 1 +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE msg_deliveries + SET delivery_status = ?, updated_at = ? + WHERE connection_id = ? AND agent_msg_id = ? + +Plan: +SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) + +Query: + UPDATE protocol_servers + SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, + preset = ?, tested = ?, enabled = ?, updated_at = ? + WHERE smp_server_id = ? + +Plan: +SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE rcv_files + SET to_receive = 1, user_approved_relays = ?, updated_at = ? + WHERE file_id = ? + +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE remote_controllers + SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key + WHERE remote_ctrl_id = ? + +Plan: +SEARCH remote_controllers USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE remote_hosts + SET host_device_name = ?, host_dh_pub = ?, bind_addr = ?, bind_iface = ?, bind_port = ? + WHERE remote_host_id = ? + +Plan: +SEARCH remote_hosts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE server_operators + SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? + WHERE server_operator_id = ? + +Plan: +SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE users + SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ? + WHERE user_id = ? + +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE xftp_file_descriptions + SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ?, updated_at = ? + WHERE user_id = ? AND file_descr_id = ? + +Plan: +SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, 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, + -- 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 + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ? +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, 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, + -- 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 + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + 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, + 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.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status IN (?, ?, ?, ?) + AND m.group_member_id NOT IN ( + SELECT DISTINCT group_member_id FROM chat_items + ) + +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +LIST SUBQUERY 2 +SCAN chat_items USING COVERING INDEX idx_chat_items_group_member_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 + 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, + 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.group_id = ? AND m.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +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 + 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, + 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.group_id = ? AND m.member_id = ? +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_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 + 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, + 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.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +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 + 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, + 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 != ?) +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 + 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, + 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 member_role IN (?,?,?) +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 + 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_history (user_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.created_at <= ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at ? ) OR ( user_id = ? AND group_id = ? 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>?) +INDEX 2 +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_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 ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_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 + +Query: DELETE FROM app_settings +Plan: + +Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_moderations WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? +Plan: +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND created_at <= ? +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_reactions WHERE group_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) + +Query: DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ? +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: DELETE 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: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_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 created_at <= ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at 0 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_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=?) + +Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? +Plan: +SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) + +Query: SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1 +Plan: +SEARCH connections USING INDEX idx_connections_conn_req_inv (user_id=? AND conn_req_inv=?) + +Query: SELECT agent_conn_id FROM connections where to_subscribe = 1 +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) + +Query: SELECT app_settings FROM app_settings +Plan: +SCAN app_settings + +Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ? +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 +Plan: +SEARCH chat_items USING COVERING 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: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id IS NULL +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1 +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1 +Plan: +SEARCH settings USING INDEX idx_settings_user_id (user_id=?) + +Query: SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ? +Plan: +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) + +Query: SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ? +Plan: +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_group_id (group_id=?) + +Query: SELECT conn_req_inv FROM connections WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT conn_status FROM connections WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT connection_id FROM connections WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) + +Query: SELECT connection_id FROM connections WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH connections USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Plan: +SCAN contacts + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0 +Plan: +SEARCH contacts USING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) + +Query: SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) + +Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?) + +Query: SELECT count(1) FROM pending_group_messages +Plan: +SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id + +Query: SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ? +Plan: +SEARCH files USING INDEX idx_files_redirect_file_id (redirect_file_id=?) + +Query: SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ? +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_id FROM group_members WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1 +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: SELECT group_id FROM groups WHERE user_id = ? +Plan: +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_2 (user_id=?) + +Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Plan: +SCAN groups + +Query: SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) + +Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL +Plan: +SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Plan: +SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1 +Plan: +SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) + +Query: SELECT last_insert_rowid() +Plan: +SCAN CONSTANT ROW + +Query: SELECT max(active_order) FROM users +Plan: +SEARCH users + +Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? +Plan: +SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) + +Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1 +Plan: +SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) + +Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +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=?) + +Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ? +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_contact_link_id, conn_req_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Plan: +SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) + +Query: SELECT user_id FROM users WHERE local_display_name = ? +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) + +Query: SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1 +Plan: +SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) + +Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +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 chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_user_id_item_status (user_id=? AND item_status=?) + +Query: UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET auth_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3 +Plan: +SCAN connections + +Query: UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? +Plan: +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) + +Query: UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET security_code = ?, security_code_verified_at = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1 +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) + +Query: UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ? +Plan: +SEARCH connections USING INDEX idx_connections_via_contact (via_contact=?) + +Query: UPDATE contact_requests SET contact_id = ? WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH contact_requests USING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) + +Query: UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET chat_item_ttl = ?, updated_at = ? WHERE contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_used = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET send_rcpts = NULL +Plan: +SCAN contacts + +Query: UPDATE contacts SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET agent_snd_file_deleted = 1, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET cancelled = 1, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET file_crypto_key = ?, file_crypto_nonce = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_member_intros SET intro_status='con' +Plan: +SCAN group_member_intros + +Query: UPDATE group_member_intros SET intro_status='fwd' +Plan: +SCAN group_member_intros + +Query: UPDATE group_members SET contact_id = ?, updated_at = ? WHERE contact_profile_id = ? +Plan: +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) + +Query: UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_invited_by (invited_by=?) + +Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET sent_inv_queue_info = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET chat_item_id = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET send_rcpts = NULL +Plan: +SCAN groups + +Query: UPDATE groups SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: UPDATE groups SET user_member_profile_sent_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET chat_ts = ? WHERE user_id = ? AND note_folder_id = ? +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ? +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH note_folders USING INDEX note_folders_user_id (user_id=?) + +Query: UPDATE rcv_files SET agent_rcv_file_deleted = 1, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET agent_rcv_file_id = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET user_approved_relays = ?, rcv_file_inline = ?, file_status = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE settings SET chat_item_ttl = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH settings USING INDEX idx_settings_user_id (user_id=?) + +Query: UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ? +Plan: +SEARCH snd_files USING PRIMARY KEY (file_id=? AND connection_id=?) + +Query: UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_descr_id = ? +Plan: +SEARCH snd_files USING PRIMARY KEY (file_id=? AND connection_id=?) + +Query: UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ? +Plan: +SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ? +Plan: +SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET active_user = 0 +Plan: +SCAN users + +Query: UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET contact_id = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL +Plan: +SCAN users + +Query: UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 829bdc3d31..958cd9d75f 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -16,7 +16,6 @@ import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators import Simplex.Chat.Options -import Simplex.Chat.Options.DB import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) @@ -29,6 +28,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB +import Simplex.Chat.Options.DB import System.IO (hFlush, hSetEcho, stdin, stdout) #endif diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 7d10f6a34a..751a7d1e05 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -8,6 +8,7 @@ module Bots.BroadcastTests where import Broadcast.Bot import Broadcast.Options import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (bracket) @@ -21,7 +22,7 @@ import Test.Hspec hiding (it) import System.FilePath (()) #endif -broadcastBotTests :: SpecWith FilePath +broadcastBotTests :: SpecWith TestParams broadcastBotTests = do it "should broadcast message" testBroadcastMessages @@ -34,8 +35,8 @@ withBroadcastBot opts test = broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkBotOpts :: FilePath -> [KnownContact] -> BroadcastBotOpts -mkBotOpts tmp publishers = +mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts +mkBotOpts ps publishers = BroadcastBotOpts { coreOptions = testCoreOpts @@ -44,7 +45,7 @@ mkBotOpts tmp publishers = #if defined(dbPostgres) {dbSchemaPrefix = "client_" <> botDbPrefix} #else - {dbFilePrefix = tmp botDbPrefix} + {dbFilePrefix = tmpPath ps botDbPrefix} #endif }, @@ -56,19 +57,19 @@ mkBotOpts tmp publishers = botDbPrefix :: FilePath botDbPrefix = "broadcast_bot" -testBroadcastMessages :: HasCallStack => FilePath -> IO () -testBroadcastMessages tmp = do +testBroadcastMessages :: HasCallStack => TestParams -> IO () +testBroadcastMessages ps = do botLink <- - withNewTestChat tmp botDbPrefix broadcastBotProfile $ \bc_bot -> - withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat ps botDbPrefix broadcastBotProfile $ \bc_bot -> + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers bc_bot alice bc_bot ##> "/ad" getContactLink bc_bot True - let botOpts = mkBotOpts tmp [KnownContact 2 "alice"] + let botOpts = mkBotOpts ps [KnownContact 2 "alice"] withBroadcastBot botOpts $ - withTestChat tmp "alice" $ \alice -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withTestChat ps "alice" $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" bob `connectVia` botLink bob #> "@broadcast_bot hello" diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 7a0ae79ccc..12de4cf742 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -7,6 +7,7 @@ module Bots.DirectoryTests where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) @@ -27,7 +28,7 @@ import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import System.FilePath (()) import Test.Hspec hiding (it) -directoryServiceTests :: SpecWith FilePath +directoryServiceTests :: SpecWith TestParams directoryServiceTests = do it "should register group" testDirectoryService it "should suspend and resume group, send message to owner" testSuspendResume @@ -68,8 +69,8 @@ directoryServiceTests = do directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkDirectoryOpts :: FilePath -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts -mkDirectoryOpts tmp superUsers ownersGroup = +mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts +mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = DirectoryOpts { coreOptions = testCoreOpts @@ -78,14 +79,14 @@ mkDirectoryOpts tmp superUsers ownersGroup = #if defined(dbPostgres) {dbSchemaPrefix = "client_" <> serviceDbPrefix} #else - {dbFilePrefix = tmp serviceDbPrefix} + {dbFilePrefix = ps serviceDbPrefix} #endif }, adminUsers = [], superUsers, ownersGroup, - directoryLog = Just $ tmp "directory_service.log", + directoryLog = Just $ ps "directory_service.log", serviceName = "SimpleX-Directory", runCLI = False, searchResults = 3, @@ -98,11 +99,11 @@ serviceDbPrefix = "directory_service" viewName :: String -> String viewName = T.unpack . DE.viewName . T.pack -testDirectoryService :: HasCallStack => FilePath -> IO () -testDirectoryService tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDirectoryService :: HasCallStack => TestParams -> IO () +testDirectoryService ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink bob #> "@SimpleX-Directory privacy" bob <# "SimpleX-Directory> > privacy" @@ -211,10 +212,10 @@ testDirectoryService tmp = su <## "To approve send:" su <# ("SimpleX-Directory> /approve 1:PSA " <> show grId) -testSuspendResume :: HasCallStack => FilePath -> IO () -testSuspendResume tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSuspendResume :: HasCallStack => TestParams -> IO () +testSuspendResume ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -240,10 +241,10 @@ testSuspendResume tmp = superUser <## " Forwarded to @bob, the owner of the group ID 1 (privacy)" bob <# "SimpleX-Directory> hello there" -testDeleteGroup :: HasCallStack => FilePath -> IO () -testDeleteGroup tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testDeleteGroup :: HasCallStack => TestParams -> IO () +testDeleteGroup ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -252,11 +253,11 @@ testDeleteGroup tmp = bob <## " Your group privacy is deleted from the directory" groupNotFound bob "privacy" -testSetRole :: HasCallStack => FilePath -> IO () -testSetRole tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testSetRole :: HasCallStack => TestParams -> IO () +testSetRole ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -281,12 +282,12 @@ testSetRole tmp = cath ##> "#privacy hello" cath <## "#privacy: you don't have permission to send messages" -testJoinGroup :: HasCallStack => FilePath -> IO () -testJoinGroup tmp = - withDirectoryServiceCfg tmp testCfgGroupLinkViaContact $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> - withNewTestChatCfg tmp testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do +testJoinGroup :: HasCallStack => TestParams -> IO () +testJoinGroup ps = + withDirectoryServiceCfg ps testCfgGroupLinkViaContact $ \superUser dsLink -> + withNewTestChatCfg ps testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> + withNewTestChatCfg ps testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -331,10 +332,10 @@ testJoinGroup tmp = cath <## "#privacy: new member dan is connected" ] -testGroupNameWithSpaces :: HasCallStack => FilePath -> IO () -testGroupNameWithSpaces tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupNameWithSpaces :: HasCallStack => TestParams -> IO () +testGroupNameWithSpaces ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "Privacy & Security" "" groupFound bob "Privacy & Security" @@ -349,11 +350,11 @@ testGroupNameWithSpaces tmp = bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is listed in the directory again!" groupFound bob "Privacy & Security" -testSearchGroups :: HasCallStack => FilePath -> IO () -testSearchGroups tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testSearchGroups :: HasCallStack => TestParams -> IO () +testSearchGroups ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink forM_ [1..8 :: Int] $ \i -> registerGroupId superUser bob (groups !! (i - 1)) "" i i @@ -435,10 +436,10 @@ testSearchGroups tmp = u <##. "Link to join the group " u <## (show count <> " members") -testInviteToOwnersGroup :: HasCallStack => FilePath -> IO () -testInviteToOwnersGroup tmp = - withDirectoryServiceCfgOwnersGroup tmp testCfg True $ \superUser dsLink -> - withNewTestChatCfg tmp testCfg "bob" bobProfile $ \bob -> do +testInviteToOwnersGroup :: HasCallStack => TestParams -> IO () +testInviteToOwnersGroup ps = + withDirectoryServiceCfgOwnersGroup ps testCfg True $ \superUser dsLink -> + withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroupId superUser bob "privacy" "Privacy" 2 1 bob <## "#owners: SimpleX-Directory invites you to join the group as member" @@ -453,11 +454,11 @@ testInviteToOwnersGroup tmp = registerGroupId superUser bob "security" "Security" 3 2 superUser <## "Owner is already a member of owners' group" -testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () -testDelistedOwnerLeaves tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedOwnerLeaves :: HasCallStack => TestParams -> IO () +testDelistedOwnerLeaves ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -469,11 +470,11 @@ testDelistedOwnerLeaves tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)." groupNotFound cath "privacy" -testDelistedOwnerRemoved :: HasCallStack => FilePath -> IO () -testDelistedOwnerRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () +testDelistedOwnerRemoved ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -484,11 +485,11 @@ testDelistedOwnerRemoved tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)." groupNotFound cath "privacy" -testNotDelistedMemberLeaves :: HasCallStack => FilePath -> IO () -testNotDelistedMemberLeaves tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () +testNotDelistedMemberLeaves ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -497,11 +498,11 @@ testNotDelistedMemberLeaves tmp = (superUser FilePath -> IO () -testNotDelistedMemberRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberRemoved :: HasCallStack => TestParams -> IO () +testNotDelistedMemberRemoved ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -509,11 +510,11 @@ testNotDelistedMemberRemoved tmp = (superUser FilePath -> IO () -testDelistedServiceRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedServiceRemoved :: HasCallStack => TestParams -> IO () +testDelistedServiceRemoved ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -526,11 +527,11 @@ testDelistedServiceRemoved tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." groupNotFound cath "privacy" -testDelistedGroupDeleted :: HasCallStack => FilePath -> IO () -testDelistedGroupDeleted tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDelistedGroupDeleted :: HasCallStack => TestParams -> IO () +testDelistedGroupDeleted ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -550,11 +551,11 @@ testDelistedGroupDeleted tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group is deleted)." groupNotFound cath "privacy" -testDelistedRoleChanges :: HasCallStack => FilePath -> IO () -testDelistedRoleChanges tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedRoleChanges :: HasCallStack => TestParams -> IO () +testDelistedRoleChanges ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -596,11 +597,11 @@ testDelistedRoleChanges tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (user role is set to owner)." groupFoundN 3 cath "privacy" -testNotDelistedMemberRoleChanged :: HasCallStack => FilePath -> IO () -testNotDelistedMemberRoleChanged tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberRoleChanged :: HasCallStack => TestParams -> IO () +testNotDelistedMemberRoleChanged ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -610,11 +611,11 @@ testNotDelistedMemberRoleChanged tmp = cath <## "#privacy: bob changed your role from owner to member" groupFoundN 3 cath "privacy" -testNotSentApprovalBadRoles :: HasCallStack => FilePath -> IO () -testNotSentApprovalBadRoles tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotSentApprovalBadRoles :: HasCallStack => TestParams -> IO () +testNotSentApprovalBadRoles ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" @@ -633,11 +634,11 @@ testNotSentApprovalBadRoles tmp = approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -testNotApprovedBadRoles :: HasCallStack => FilePath -> IO () -testNotApprovedBadRoles tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotApprovedBadRoles :: HasCallStack => TestParams -> IO () +testNotApprovedBadRoles ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" @@ -660,11 +661,11 @@ testNotApprovedBadRoles tmp = approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -testRegOwnerChangedProfile :: HasCallStack => FilePath -> IO () -testRegOwnerChangedProfile tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testRegOwnerChangedProfile ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -679,11 +680,11 @@ testRegOwnerChangedProfile tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testAnotherOwnerChangedProfile :: HasCallStack => FilePath -> IO () -testAnotherOwnerChangedProfile tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testAnotherOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testAnotherOwnerChangedProfile ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -698,11 +699,11 @@ testAnotherOwnerChangedProfile tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testRegOwnerRemovedLink :: HasCallStack => FilePath -> IO () -testRegOwnerRemovedLink tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () +testRegOwnerRemovedLink ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -731,11 +732,11 @@ testRegOwnerRemovedLink tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testAnotherOwnerRemovedLink :: HasCallStack => FilePath -> IO () -testAnotherOwnerRemovedLink tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () +testAnotherOwnerRemovedLink ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -773,11 +774,11 @@ testAnotherOwnerRemovedLink tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testDuplicateAskConfirmation :: HasCallStack => FilePath -> IO () -testDuplicateAskConfirmation tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () +testDuplicateAskConfirmation ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" _ <- groupAccepted bob "privacy" @@ -792,11 +793,11 @@ testDuplicateAskConfirmation tmp = completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2 groupFound bob "privacy" -testDuplicateProhibitRegistration :: HasCallStack => FilePath -> IO () -testDuplicateProhibitRegistration tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () +testDuplicateProhibitRegistration ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -804,11 +805,11 @@ testDuplicateProhibitRegistration tmp = _ <- submitGroup cath "privacy" "Privacy" cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." -testDuplicateProhibitConfirmation :: HasCallStack => FilePath -> IO () -testDuplicateProhibitConfirmation tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitConfirmation :: HasCallStack => TestParams -> IO () +testDuplicateProhibitConfirmation ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -823,11 +824,11 @@ testDuplicateProhibitConfirmation tmp = cath #> "@SimpleX-Directory /confirm 1:privacy" cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." -testDuplicateProhibitWhenUpdated :: HasCallStack => FilePath -> IO () -testDuplicateProhibitWhenUpdated tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitWhenUpdated :: HasCallStack => TestParams -> IO () +testDuplicateProhibitWhenUpdated ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -854,11 +855,11 @@ testDuplicateProhibitWhenUpdated tmp = groupFound bob "security" groupFound cath "security" -testDuplicateProhibitApproval :: HasCallStack => FilePath -> IO () -testDuplicateProhibitApproval tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitApproval :: HasCallStack => TestParams -> IO () +testDuplicateProhibitApproval ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -880,11 +881,11 @@ testDuplicateProhibitApproval tmp = superUser <# ("SimpleX-Directory> > " <> approve) superUser <## " The group ID 2 (privacy) is already listed in the directory." -testListUserGroups :: HasCallStack => FilePath -> IO () -testListUserGroups tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testListUserGroups :: HasCallStack => TestParams -> IO () +testListUserGroups ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -915,12 +916,12 @@ testListUserGroups tmp = groupNotFound cath "anonymity" listGroups superUser bob cath -testRestoreDirectory :: HasCallStack => FilePath -> IO () -testRestoreDirectory tmp = do - testListUserGroups tmp - restoreDirectoryService tmp 3 3 $ \superUser _dsLink -> - withTestChat tmp "bob" $ \bob -> - withTestChat tmp "cath" $ \cath -> do +testRestoreDirectory :: HasCallStack => TestParams -> IO () +testRestoreDirectory ps = do + testListUserGroups ps + restoreDirectoryService ps 3 3 $ \superUser _dsLink -> + withTestChat ps "bob" $ \bob -> + withTestChat ps "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" bob <### [ "#privacy: connected to server(s)", @@ -1021,17 +1022,17 @@ addCathAsOwner bob cath = do joinGroup "privacy" cath bob cath <## "#privacy: member SimpleX-Directory is connected" -withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) -> IO () -withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg +withDirectoryService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO () +withDirectoryService ps = withDirectoryServiceCfg ps testCfg -withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg tmp cfg = withDirectoryServiceCfgOwnersGroup tmp cfg False +withDirectoryServiceCfg :: HasCallStack => TestParams -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfg ps cfg = withDirectoryServiceCfgOwnersGroup ps cfg False -withDirectoryServiceCfgOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do +withDirectoryServiceCfgOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup test = do dsLink <- - withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> - withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do + withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds -> + withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser when createOwnersGroup $ do superUser ##> "/g owners" @@ -1046,12 +1047,12 @@ withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do superUser <## "#owners: SimpleX-Directory joined the group" ds ##> "/ad" getContactLink ds True - withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test + withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test -restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () -restoreDirectoryService tmp ctCount grCount test = do +restoreDirectoryService :: HasCallStack => TestParams -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () +restoreDirectoryService ps ctCount grCount test = do dsLink <- - withTestChat tmp serviceDbPrefix $ \ds -> do + withTestChat ps serviceDbPrefix $ \ds -> do ds <## (show ctCount <> " contacts connected (use /cs for the list)") ds <## "Your address is active! To show: /sa" ds <## (show grCount <> " group links active") @@ -1060,16 +1061,16 @@ restoreDirectoryService tmp ctCount grCount test = do dsLink <- getContactLink ds False ds <## "auto_accept on" pure dsLink - withDirectory tmp testCfg dsLink test + withDirectory ps testCfg dsLink test -withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp cfg dsLink = withDirectoryOwnersGroup tmp cfg dsLink False +withDirectory :: HasCallStack => TestParams -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () +withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False -withDirectoryOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test = do - let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing +withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test = do + let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing runDirectory cfg opts $ - withTestChatCfg tmp cfg "super_user" $ \superUser -> do + withTestChatCfg ps cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" when createOwnersGroup $ superUser <## "#owners: connected to server(s)" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 32af1d5c95..d330510d62 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -12,6 +12,7 @@ module ChatClient where +import ChatTests.DBUtils import Control.Concurrent (forkIOWithUnmask, killThread, threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM @@ -68,6 +69,8 @@ import Test.Hspec (Expectation, HasCallStack, shouldReturn) import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) #else import Data.ByteArray (ScrubbedBytes) +import qualified Data.Map.Strict as M +import Simplex.Messaging.Agent.Store.Common (withConnection) import System.FilePath (()) #endif @@ -118,6 +121,7 @@ testCoreOpts = #else { dbFilePrefix = "./simplex_v1", -- dbFilePrefix is not used in tests (except bot tests where it's redefined) dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", + trackQueries = DB.TQAll, vacuumOnMigration = True #endif }, @@ -273,29 +277,29 @@ mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} groupLinkViaContactVRange :: VersionRangeChat groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) -createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix profile = do - Right db@ChatDatabase {chatStore, agentStore} <- createDatabase tmp coreOptions dbPrefix +createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC +createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do + Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user -startTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> IO TestCC -startTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix = do - Right db@ChatDatabase {chatStore} <- createDatabase tmp coreOptions dbPrefix +startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC +startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do + Right db@ChatDatabase {chatStore} <- createDatabase ps coreOptions dbPrefix Just user <- find activeUser <$> withTransaction chatStore getUsers startTestChat_ db cfg opts user -createDatabase :: FilePath -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) +createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) #if defined(dbPostgres) -createDatabase _tmp CoreChatOpts {dbOptions} dbPrefix = do +createDatabase _params CoreChatOpts {dbOptions} dbPrefix = do createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} MCError insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") #else -createDatabase tmp CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbFilePrefix = tmp dbPrefix} MCError +createDatabase TestParams {tmpPath} CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbFilePrefix = tmpPath dbPrefix} MCError insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") @@ -313,48 +317,64 @@ startTestChat_ db cfg opts user = do termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput = False} -stopTestChat :: TestCC -> IO () -stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do +stopTestChat :: TestParams -> TestCC -> IO () +stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do stopChatController cc uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent +#if !defined(dbPostgres) + stats <- withConnection chatStore $ readTVarIO . DB.slow + atomically $ modifyTVar' (queryStats ps) $ M.unionWith combineStats stats +#endif closeDBStore chatStore threadDelay 200000 +#if !defined(dbPostgres) + where + combineStats + DB.SlowQueryStats {count, timeMax, timeAvg, errs} + DB.SlowQueryStats {count = count', timeMax = timeMax', timeAvg = timeAvg', errs = errs'} = + DB.SlowQueryStats + { count = count + count', + timeMax = max timeMax timeMax', + timeAvg = (timeAvg * count + timeAvg' * count') `div` (count + count'), + errs = M.unionWith (+) errs errs' + } +#endif -withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChat tmp = withNewTestChatCfgOpts tmp testCfg testOpts +withNewTestChat :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChat ps = withNewTestChatCfgOpts ps testCfg testOpts -withNewTestChatV1 :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatV1 tmp = withNewTestChatCfg tmp testCfgV1 +withNewTestChatV1 :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatV1 ps = withNewTestChatCfg ps testCfgV1 -withNewTestChatCfg :: HasCallStack => FilePath -> ChatConfig -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatCfg tmp cfg = withNewTestChatCfgOpts tmp cfg testOpts +withNewTestChatCfg :: HasCallStack => TestParams -> ChatConfig -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatCfg ps cfg = withNewTestChatCfgOpts ps cfg testOpts -withNewTestChatOpts :: HasCallStack => FilePath -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatOpts tmp = withNewTestChatCfgOpts tmp testCfg +withNewTestChatOpts :: HasCallStack => TestParams -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatOpts ps = withNewTestChatCfgOpts ps testCfg -withNewTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatCfgOpts tmp cfg opts dbPrefix profile runTest = +withNewTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatCfgOpts ps cfg opts dbPrefix profile runTest = bracket - (createTestChat tmp cfg opts dbPrefix profile) - stopTestChat + (createTestChat ps cfg opts dbPrefix profile) + (stopTestChat ps) (\cc -> runTest cc >>= ((cc )) -withTestChatV1 :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatV1 tmp = withTestChatCfg tmp testCfgV1 +withTestChatV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatV1 ps = withTestChatCfg ps testCfgV1 -withTestChat :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChat tmp = withTestChatCfgOpts tmp testCfg testOpts +withTestChat :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChat ps = withTestChatCfgOpts ps testCfg testOpts -withTestChatCfg :: HasCallStack => FilePath -> ChatConfig -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatCfg tmp cfg = withTestChatCfgOpts tmp cfg testOpts +withTestChatCfg :: HasCallStack => TestParams -> ChatConfig -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatCfg ps cfg = withTestChatCfgOpts ps cfg testOpts -withTestChatOpts :: HasCallStack => FilePath -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg +withTestChatOpts :: HasCallStack => TestParams -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatOpts ps = withTestChatCfgOpts ps testCfg -withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +withTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatCfgOpts ps cfg opts dbPrefix = bracket (startTestChat ps cfg opts dbPrefix) (\cc -> cc > stopTestChat ps cc) -- enable output for specific chat controller, use like this: -- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... @@ -390,16 +410,16 @@ withTmpFiles = (createDirectoryIfMissing False "tests/tmp") (removeDirectoryRecursive "tests/tmp") -testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> FilePath -> IO () -testChatN cfg opts ps test tmp = do +testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> TestParams -> IO () +testChatN cfg opts ps test params = do tcs <- getTestCCs (zip ps [1 ..]) [] test tcs concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat tmp cfg opts (show db) p <*> getTestCCs envs' tcs + getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' tcs ( TestCC -> Int -> Expectation ( IO [Char] userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser -testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> TestParams -> IO () testChat = testChatCfgOpts testCfg testOpts -testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts cfg opts p test = testChatN cfg opts [p] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc] = test tc test_ _ = error "expected 1 chat client" -testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts -testChatCfg2 :: HasCallStack => ChatConfig -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg2 :: HasCallStack => ChatConfig -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg2 cfg = testChatCfgOpts2 cfg testOpts -testChatOpts2 :: HasCallStack => ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatOpts2 :: HasCallStack => ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatOpts2 = testChatCfgOpts2 testCfg -testChatCfgOpts2 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts2 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts2 cfg opts p1 p2 test = testChatN cfg opts [p1, p2] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2] = test tc1 tc2 test_ _ = error "expected 2 chat clients" -testChat3 :: HasCallStack => Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat3 :: HasCallStack => Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat3 = testChatCfgOpts3 testCfg testOpts -testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg3 cfg = testChatCfgOpts3 cfg testOpts -testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3] = test tc1 tc2 tc3 test_ _ = error "expected 3 chat clients" -testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat4 = testChatCfg4 testCfg -testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +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_ where test_ :: HasCallStack => [TestCC] -> IO () diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index e8f3838eb6..20fccf6c64 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,6 +1,7 @@ module ChatTests where import ChatTests.ChatList +import ChatTests.DBUtils import ChatTests.Direct import ChatTests.Files import ChatTests.Forward @@ -9,7 +10,7 @@ import ChatTests.Local import ChatTests.Profiles import Test.Hspec hiding (it) -chatTests :: SpecWith FilePath +chatTests :: SpecWith TestParams chatTests = do describe "direct tests" chatDirectTests describe "forward tests" chatForwardTests diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index dd88aac54e..dcd18a9818 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -1,12 +1,13 @@ module ChatTests.ChatList where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Data.Time.Clock (getCurrentTime) import Data.Time.Format.ISO8601 (iso8601Show) import Test.Hspec hiding (it) -chatListTests :: SpecWith FilePath +chatListTests :: SpecWith TestParams chatListTests = do it "get last chats" testPaginationLast it "get chats before/after timestamp" testPaginationTs @@ -16,7 +17,7 @@ chatListTests = do it "filter favorite or unread" testFilterFavoriteOrUnread it "sort and filter chats of all types" testPaginationAllChatTypes -testPaginationLast :: HasCallStack => FilePath -> IO () +testPaginationLast :: HasCallStack => TestParams -> IO () testPaginationLast = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -32,7 +33,7 @@ testPaginationLast = alice <# "bob> hey" alice <# "@cath hey" -testPaginationTs :: HasCallStack => FilePath -> IO () +testPaginationTs :: HasCallStack => TestParams -> IO () testPaginationTs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -59,7 +60,7 @@ getChats_ :: HasCallStack => TestCC -> String -> [(String, String)] -> Expectati getChats_ cc query expected = do cc #$> ("/_get chats 1 pcc=on " <> query, chats, expected) -testFilterSearch :: HasCallStack => FilePath -> IO () +testFilterSearch :: HasCallStack => TestParams -> IO () testFilterSearch = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -75,7 +76,7 @@ testFilterSearch = getChats_ alice (query "bob") [("@bob", "hey")] getChats_ alice (query "Bob") [("@bob", "hey")] -testFilterFavorite :: HasCallStack => FilePath -> IO () +testFilterFavorite :: HasCallStack => TestParams -> IO () testFilterFavorite = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -99,7 +100,7 @@ testFilterFavorite = alice <## "ok" getChats_ alice query [("@bob", "hey")] -testFilterUnread :: HasCallStack => FilePath -> IO () +testFilterUnread :: HasCallStack => TestParams -> IO () testFilterUnread = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -123,7 +124,7 @@ testFilterUnread = alice <## "ok" getChats_ alice query [("@bob", "hey")] -testFilterFavoriteOrUnread :: HasCallStack => FilePath -> IO () +testFilterFavoriteOrUnread :: HasCallStack => TestParams -> IO () testFilterFavoriteOrUnread = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -154,7 +155,7 @@ testFilterFavoriteOrUnread = alice <## "ok" getChats_ alice query [("@cath", "hey"), ("@bob", "hey")] -testPaginationAllChatTypes :: HasCallStack => FilePath -> IO () +testPaginationAllChatTypes :: HasCallStack => TestParams -> IO () testPaginationAllChatTypes = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do diff --git a/tests/ChatTests/DBUtils.hs b/tests/ChatTests/DBUtils.hs new file mode 100644 index 0000000000..f6ac2284da --- /dev/null +++ b/tests/ChatTests/DBUtils.hs @@ -0,0 +1,15 @@ +{-# LANGUAGE CPP #-} + +module ChatTests.DBUtils + +#if defined(dbPostgres) + ( module ChatTests.DBUtils.Postgres, + ) + where +import ChatTests.DBUtils.Postgres +#else + ( module ChatTests.DBUtils.SQLite, + ) + where +import ChatTests.DBUtils.SQLite +#endif diff --git a/tests/ChatTests/DBUtils/Postgres.hs b/tests/ChatTests/DBUtils/Postgres.hs new file mode 100644 index 0000000000..840c3d9a38 --- /dev/null +++ b/tests/ChatTests/DBUtils/Postgres.hs @@ -0,0 +1,5 @@ +module ChatTests.DBUtils.Postgres where + +data TestParams = TestParams + { tmpPath :: FilePath + } diff --git a/tests/ChatTests/DBUtils/SQLite.hs b/tests/ChatTests/DBUtils/SQLite.hs new file mode 100644 index 0000000000..cdde43b015 --- /dev/null +++ b/tests/ChatTests/DBUtils/SQLite.hs @@ -0,0 +1,10 @@ +module ChatTests.DBUtils.SQLite where + +import Database.SQLite.Simple (Query) +import Simplex.Messaging.Agent.Store.SQLite.DB +import Simplex.Messaging.TMap (TMap) + +data TestParams = TestParams + { tmpPath :: FilePath, + queryStats :: TMap Query SlowQueryStats + } diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index bc857132eb..a930246106 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -12,6 +12,7 @@ module ChatTests.Direct where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -28,7 +29,6 @@ import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite @@ -45,10 +45,11 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Options.DB import System.FilePath (()) #endif -chatDirectTests :: SpecWith FilePath +chatDirectTests :: SpecWith TestParams chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive messages" testAddContact @@ -180,7 +181,7 @@ chatDirectTests = do testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 -testAddContact :: HasCallStack => SpecWith FilePath +testAddContact :: HasCallStack => SpecWith TestParams testAddContact = versionTestMatrix2 runTestAddContact where runTestAddContact pqExpected alice bob = do @@ -237,9 +238,10 @@ testAddContact = versionTestMatrix2 runTestAddContact then chatFeatures else (0, e2eeInfoNoPQStr) : tail chatFeatures -testRetryConnecting :: HasCallStack => FilePath -> IO () -testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test tmp +testRetryConnecting :: HasCallStack => TestParams -> IO () +testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps where + tmp = tmpPath ps test alice bob = do inv <- withSmpServer' serverCfg' $ do alice ##> "/_connect 1" @@ -289,14 +291,14 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te } } -testRetryConnectingClientTimeout :: HasCallStack => FilePath -> IO () -testRetryConnectingClientTimeout tmp = do +testRetryConnectingClientTimeout :: HasCallStack => TestParams -> IO () +testRetryConnectingClientTimeout ps = do inv <- withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp cfg' opts' "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> do alice ##> "/_connect 1" inv <- getInvitation alice - withNewTestChatCfgOpts tmp cfgZeroTimeout opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfgZeroTimeout opts' "bob" bobProfile $ \bob -> do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -308,8 +310,8 @@ testRetryConnectingClientTimeout tmp = do logFile `shouldContain` "SECURE" withSmpServer' serverCfg' $ do - withTestChatCfgOpts tmp cfg' opts' "alice" $ \alice -> do - withTestChatCfgOpts tmp cfg' opts' "bob" $ \bob -> do + withTestChatCfgOpts ps cfg' opts' "alice" $ \alice -> do + withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -323,6 +325,7 @@ testRetryConnectingClientTimeout tmp = do bob #> "@alice message 2" alice <# "bob> message 2" where + tmp = tmpPath ps serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], @@ -358,7 +361,7 @@ testRetryConnectingClientTimeout tmp = do } } -testMarkReadDirect :: HasCallStack => FilePath -> IO () +testMarkReadDirect :: HasCallStack => TestParams -> IO () testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice #> "@bob 1" @@ -374,7 +377,7 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do let itemIds = intercalate "," $ map show [i - 3 .. i] bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") -testChatPaginationInitial :: HasCallStack => FilePath -> IO () +testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob -- Wait, otherwise ids are going to be wrong. @@ -404,7 +407,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice { markRead = False } -testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsSeparate :: HasCallStack => TestParams -> IO () testDuplicateContactsSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -430,7 +433,7 @@ testDuplicateContactsSeparate = bob @@@ [("@alice", "hey"), ("@alice_1", "2")] bob `hasContactProfiles` ["bob", "alice", "alice"] -testDuplicateContactsMultipleSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsMultipleSeparate :: HasCallStack => TestParams -> IO () testDuplicateContactsMultipleSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -470,7 +473,7 @@ testDuplicateContactsMultipleSeparate = alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] -testPlanInvitationLinkOk :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOk :: HasCallStack => TestParams -> IO () testPlanInvitationLinkOk = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -489,9 +492,9 @@ testPlanInvitationLinkOk = bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection alice <##> bob -testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () -testPlanInvitationLinkOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanInvitationLinkOwn :: HasCallStack => TestParams -> IO () +testPlanInvitationLinkOwn ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/c" inv <- getInvitation alice alice ##> ("/_connect plan 1 " <> inv) @@ -524,12 +527,12 @@ testPlanInvitationLinkOwn tmp = ] alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] -testPlanInvitationLinkConnecting :: HasCallStack => FilePath -> IO () -testPlanInvitationLinkConnecting tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanInvitationLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanInvitationLinkConnecting ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -540,7 +543,7 @@ testPlanInvitationLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> invSchema2) bob <## "invitation link: connecting" -testContactClear :: HasCallStack => FilePath -> IO () +testContactClear :: HasCallStack => TestParams -> IO () testContactClear = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -552,7 +555,7 @@ testContactClear = bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") bob #$> ("/_get chat @2 count=100", chat, []) -testDeleteContactDeletesProfile :: HasCallStack => FilePath -> IO () +testDeleteContactDeletesProfile :: HasCallStack => TestParams -> IO () testDeleteContactDeletesProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -572,7 +575,7 @@ testDeleteContactDeletesProfile = (bob FilePath -> IO () +testDeleteContactKeepConversation :: HasCallStack => TestParams -> IO () testDeleteContactKeepConversation = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -590,7 +593,7 @@ testDeleteContactKeepConversation = bob ##> "@alice hey" bob <## "alice: not ready" -testDeleteConversationKeepContact :: HasCallStack => FilePath -> IO () +testDeleteConversationKeepContact :: HasCallStack => TestParams -> IO () testDeleteConversationKeepContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -609,7 +612,7 @@ testDeleteConversationKeepContact = alice @@@ [("@bob", "hi")] alice <##> bob -testDeleteUnusedContactSilent :: HasCallStack => FilePath -> IO () +testDeleteUnusedContactSilent :: HasCallStack => TestParams -> IO () testDeleteUnusedContactSilent = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -645,7 +648,7 @@ testDeleteUnusedContactSilent = bob <# "#team cath> hey" ] -testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO () +testDirectMessageQuotedReply :: HasCallStack => TestParams -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -670,7 +673,7 @@ testDirectMessageQuotedReply = bob #$> ("/_get chat @2 count=1", chat', [((1, "will tell more"), Just (1, "all good - you?"))]) alice #$> ("/_get chat @2 count=1", chat', [((0, "will tell more"), Just (0, "all good - you?"))]) -testDirectMessageUpdate :: HasCallStack => FilePath -> IO () +testDirectMessageUpdate :: HasCallStack => TestParams -> IO () testDirectMessageUpdate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -734,7 +737,7 @@ testDirectMessageUpdate = alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((1, "greetings 🤝"), Nothing), ((0, "hey Alice"), Just (1, "hello 🙂")), ((0, "greetings Alice"), Just (1, "hey 👋"))]) bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "greetings 🤝"), Nothing), ((1, "hey Alice"), Just (0, "hello 🙂")), ((1, "greetings Alice"), Just (0, "hey 👋"))]) -testDirectMessageEditHistory :: HasCallStack => FilePath -> IO () +testDirectMessageEditHistory :: HasCallStack => TestParams -> IO () testDirectMessageEditHistory = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -805,7 +808,7 @@ testDirectMessageEditHistory = bob <## "message history:" bob .<## ": hey there" -testDirectMessageDelete :: HasCallStack => FilePath -> IO () +testDirectMessageDelete :: HasCallStack => TestParams -> IO () testDirectMessageDelete = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -886,7 +889,7 @@ testDirectMessageDelete = bob #$> ("/_delete item @2 " <> itemId 4 <> " internal", id, "message deleted") bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "hello 🙂"), Nothing), ((1, "do you receive my messages?"), Just (0, "hello 🙂"))]) -testDirectMessageDeleteMultiple :: HasCallStack => FilePath -> IO () +testDirectMessageDeleteMultiple :: HasCallStack => TestParams -> IO () testDirectMessageDeleteMultiple = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -908,7 +911,7 @@ testDirectMessageDeleteMultiple = alice #$> ("/_get chat @2 count=2", chat, [(1, "hello [marked deleted]"), (1, "hey [marked deleted]")]) bob #$> ("/_get chat @2 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) -testDirectMessageDeleteMultipleManyBatches :: HasCallStack => FilePath -> IO () +testDirectMessageDeleteMultipleManyBatches :: HasCallStack => TestParams -> IO () testDirectMessageDeleteMultipleManyBatches = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -937,7 +940,7 @@ testDirectMessageDeleteMultipleManyBatches = forM_ [(1 :: Int) .. 300] $ \i -> do bob <# ("alice> [marked deleted] message " <> show i) -testDirectLiveMessage :: HasCallStack => FilePath -> IO () +testDirectLiveMessage :: HasCallStack => TestParams -> IO () testDirectLiveMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -966,7 +969,7 @@ testDirectLiveMessage = bob .<## ": hello 2" bob .<## ":" -testDirectTimedMessage :: HasCallStack => FilePath -> IO () +testDirectTimedMessage :: HasCallStack => TestParams -> IO () testDirectTimedMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -992,7 +995,7 @@ testDirectTimedMessage = alice <# "@bob hi" bob <# "alice> hi" -testRepeatAuthErrorsDisableContact :: HasCallStack => FilePath -> IO () +testRepeatAuthErrorsDisableContact :: HasCallStack => TestParams -> IO () testRepeatAuthErrorsDisableContact = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1012,7 +1015,7 @@ testRepeatAuthErrorsDisableContact = alice #> "@bob hey" alice <## "[bob, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" -testMultilineMessage :: HasCallStack => FilePath -> IO () +testMultilineMessage :: HasCallStack => TestParams -> IO () testMultilineMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers alice cath @@ -1029,7 +1032,7 @@ testMultilineMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bo cath <# "alice> hello" cath <## "there" -testLargeMessage :: HasCallStack => FilePath -> IO () +testLargeMessage :: HasCallStack => TestParams -> IO () testLargeMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1043,7 +1046,7 @@ testLargeMessage = bob <## "contact alice changed to alice2" bob <## "use @alice2 to send messages" -testSendMulti :: HasCallStack => FilePath -> IO () +testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1055,7 +1058,7 @@ testSendMulti = bob <# "alice> test 1" bob <# "alice> test 2" -testSendMultiTimed :: HasCallStack => FilePath -> IO () +testSendMultiTimed :: HasCallStack => TestParams -> IO () testSendMultiTimed = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1076,7 +1079,7 @@ testSendMultiTimed = "timed message deleted: test 2" ] -testSendMultiWithQuote :: HasCallStack => FilePath -> IO () +testSendMultiWithQuote :: HasCallStack => TestParams -> IO () testSendMultiWithQuote = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1119,7 +1122,7 @@ testSendMultiWithQuote = bob <## " message 3" bob <# "alice> message 1" -testSendMultiManyBatches :: HasCallStack => FilePath -> IO () +testSendMultiManyBatches :: HasCallStack => TestParams -> IO () testSendMultiManyBatches = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1149,7 +1152,7 @@ testSendMultiManyBatches = DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdBob) :: IO [[Int]] bobItemsCount `shouldBe` [[300]] -testGetSetSMPServers :: HasCallStack => FilePath -> IO () +testGetSetSMPServers :: HasCallStack => TestParams -> IO () testGetSetSMPServers = testChat aliceProfile $ \alice -> do @@ -1177,7 +1180,7 @@ testGetSetSMPServers = alice <## " smp://2345-w==@smp2.example.im" alice <## " smp://3456-w==@smp3.example.im:5224" -testTestSMPServerConnection :: HasCallStack => FilePath -> IO () +testTestSMPServerConnection :: HasCallStack => TestParams -> IO () testTestSMPServerConnection = testChat aliceProfile $ \alice -> do @@ -1192,7 +1195,7 @@ testTestSMPServerConnection = alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in SMP server address is incorrect" -testGetSetXFTPServers :: HasCallStack => FilePath -> IO () +testGetSetXFTPServers :: HasCallStack => TestParams -> IO () testGetSetXFTPServers = testChat aliceProfile $ \alice -> withXFTPServer $ do @@ -1219,7 +1222,7 @@ testGetSetXFTPServers = alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" -testTestXFTPServer :: HasCallStack => FilePath -> IO () +testTestXFTPServer :: HasCallStack => TestParams -> IO () testTestXFTPServer = testChat aliceProfile $ \alice -> withXFTPServer $ do @@ -1234,7 +1237,7 @@ testTestXFTPServer = alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect" -testOperators :: HasCallStack => FilePath -> IO () +testOperators :: HasCallStack => TestParams -> IO () testOperators = testChatCfgOpts testCfg opts' aliceProfile $ \alice -> do @@ -1264,59 +1267,59 @@ testOperators = where opts' = testOpts {coreOptions = testCoreOpts {smpServers = [], xftpServers = []}} -testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testAsyncInitiatingOffline aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testAsyncInitiatingOffline aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp aliceCfg "alice" $ \alice -> do + withTestChatCfg ps aliceCfg "alice" $ \alice -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testAsyncAcceptingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testAsyncAcceptingOffline aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testAsyncAcceptingOffline :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testAsyncAcceptingOffline aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp aliceCfg "alice" $ \alice -> do - withTestChatCfg tmp bobCfg "bob" $ \bob -> do + withTestChatCfg ps aliceCfg "alice" $ \alice -> do + withTestChatCfg ps bobCfg "bob" $ \bob -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testFullAsyncFast :: HasCallStack => FilePath -> IO () -testFullAsyncFast tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testFullAsyncFast :: HasCallStack => TestParams -> IO () +testFullAsyncFast ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" threadDelay 250000 - withTestChat tmp "alice" $ \alice -> + withTestChat ps "alice" $ \alice -> alice <## "bob (Bob): contact is connected" - withTestChat tmp "bob" $ \bob -> + withTestChat ps "bob" $ \bob -> bob <## "alice (Alice): contact is connected" -testFullAsyncSlow :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testFullAsyncSlow aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testFullAsyncSlow :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testFullAsyncSlow aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -1329,8 +1332,8 @@ testFullAsyncSlow aliceCfg bobCfg tmp = do bob <## "1 contacts connected (use /cs for the list)" bob <## "alice (Alice): contact is connected" where - withAlice = withTestChatCfg tmp aliceCfg "alice" - withBob = withTestChatCfg tmp aliceCfg "bob" + withAlice = withTestChatCfg ps aliceCfg "alice" + withBob = withTestChatCfg ps aliceCfg "bob" testCallType :: CallType testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encryption = True}} @@ -1355,7 +1358,7 @@ serialize = B.unpack . LB.toStrict . J.encode repeatM_ :: Int -> IO a -> IO () repeatM_ n a = forM_ [1 .. n] $ const a -testNegotiateCall :: HasCallStack => FilePath -> IO () +testNegotiateCall :: HasCallStack => TestParams -> IO () testNegotiateCall = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1402,10 +1405,10 @@ testNegotiateCall = alice <## "call with bob ended" alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: ended (00:00)")]) -testMaintenanceMode :: HasCallStack => FilePath -> IO () -testMaintenanceMode tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testMaintenanceMode :: HasCallStack => TestParams -> IO () +testMaintenanceMode ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/c" alice <## "error: chat not started" alice ##> "/_start" @@ -1437,7 +1440,7 @@ testMaintenanceMode tmp = do alice ##> "/_start" alice <## "error: chat store changed, please restart chat" -- works after full restart - withTestChat tmp "alice" $ \alice -> testChatWorking alice bob + withTestChat ps "alice" $ \alice -> testChatWorking alice bob testChatWorking :: HasCallStack => TestCC -> TestCC -> IO () testChatWorking alice bob = do @@ -1447,10 +1450,10 @@ testChatWorking alice bob = do bob #> "@alice hello too" alice <# "bob> hello too" -testMaintenanceModeWithFiles :: HasCallStack => FilePath -> IO () -testMaintenanceModeWithFiles tmp = withXFTPServer $ do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testMaintenanceModeWithFiles :: HasCallStack => TestParams -> IO () +testMaintenanceModeWithFiles ps = withXFTPServer $ do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" alice <## "chat started" alice ##> "/_files_folder ./tests/tmp/alice_files" @@ -1490,13 +1493,13 @@ testMaintenanceModeWithFiles tmp = withXFTPServer $ do alice <## "ok" B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src -- works after full restart - withTestChat tmp "alice" $ \alice -> testChatWorking alice bob + withTestChat ps "alice" $ \alice -> testChatWorking alice bob #if !defined(dbPostgres) -testDatabaseEncryption :: HasCallStack => FilePath -> IO () -testDatabaseEncryption tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testDatabaseEncryption :: HasCallStack => TestParams -> IO () +testDatabaseEncryption ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" alice <## "chat started" connectUsers alice bob @@ -1514,7 +1517,7 @@ testDatabaseEncryption tmp = do alice <## "ok" alice ##> "/_start" alice <## "error: chat store changed, please restart chat" - withTestChatOpts tmp (getTestOpts True "mykey") "alice" $ \alice -> do + withTestChatOpts ps (getTestOpts True "mykey") "alice" $ \alice -> do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob @@ -1530,7 +1533,7 @@ testDatabaseEncryption tmp = do alice <## "ok" alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}" alice <## "ok" - withTestChatOpts tmp (getTestOpts True "anotherkey") "alice" $ \alice -> do + withTestChatOpts ps (getTestOpts True "anotherkey") "alice" $ \alice -> do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob @@ -1538,15 +1541,15 @@ testDatabaseEncryption tmp = do alice <## "chat stopped" alice ##> "/db decrypt anotherkey" alice <## "ok" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do testChatWorking alice bob #endif -testSubscribeAppNSE :: HasCallStack => FilePath -> IO () -testSubscribeAppNSE tmp = - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withTestChatOpts tmp testOpts {maintenance = True} "alice" $ \nseAlice -> do +testSubscribeAppNSE :: HasCallStack => TestParams -> IO () +testSubscribeAppNSE ps = + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withTestChatOpts ps testOpts {maintenance = True} "alice" $ \nseAlice -> do alice ##> "/_app suspend 1" alice <## "ok" alice <## "chat suspended" @@ -1572,7 +1575,7 @@ testSubscribeAppNSE tmp = threadDelay 100000 alice <##> bob -testMuteContact :: HasCallStack => FilePath -> IO () +testMuteContact :: HasCallStack => TestParams -> IO () testMuteContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1592,7 +1595,7 @@ testMuteContact = alice #> "@bob hi again" bob <# "alice> hi again" -testMuteGroup :: HasCallStack => FilePath -> IO () +testMuteGroup :: HasCallStack => TestParams -> IO () testMuteGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1706,7 +1709,7 @@ testMuteGroup = bob ##> "/gs" bob <## "#team (3 members)" -testCreateSecondUser :: HasCallStack => FilePath -> IO () +testCreateSecondUser :: HasCallStack => TestParams -> IO () testCreateSecondUser = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1757,10 +1760,10 @@ testCreateSecondUser = alice ##> "/_user 2" showActiveUser alice "alisa" -testUsersSubscribeAfterRestart :: HasCallStack => FilePath -> IO () -testUsersSubscribeAfterRestart tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUsersSubscribeAfterRestart :: HasCallStack => TestParams -> IO () +testUsersSubscribeAfterRestart ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers alice bob alice <##> bob @@ -1769,7 +1772,7 @@ testUsersSubscribeAfterRestart tmp = do connectUsers alice bob alice <##> bob - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do -- second user is active alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -1781,7 +1784,7 @@ testUsersSubscribeAfterRestart tmp = do bob #> "@alice hey alice" (alice, "alice") $<# "bob> hey alice" -testMultipleUserAddresses :: HasCallStack => FilePath -> IO () +testMultipleUserAddresses :: HasCallStack => TestParams -> IO () testMultipleUserAddresses = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1853,7 +1856,7 @@ testMultipleUserAddresses = showActiveUser alice "alice (Alice)" alice @@@ [("@bob", "hey alice")] -testCreateUserSameServers :: HasCallStack => FilePath -> IO () +testCreateUserSameServers :: HasCallStack => TestParams -> IO () testCreateUserSameServers = testChat2 aliceProfile bobProfile $ \alice _ -> do @@ -1878,7 +1881,7 @@ testCreateUserSameServers = alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" -testDeleteUser :: HasCallStack => FilePath -> IO () +testDeleteUser :: HasCallStack => TestParams -> IO () testDeleteUser = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -1965,10 +1968,10 @@ testDeleteUser = alice ##> "/users" alice <## "no users" -testUsersDifferentCIExpirationTTL :: HasCallStack => FilePath -> IO () -testUsersDifferentCIExpirationTTL tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () +testUsersDifferentCIExpirationTTL ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- first user messages connectUsers alice bob @@ -2040,10 +2043,10 @@ testUsersDifferentCIExpirationTTL tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testUsersRestartCIExpiration :: HasCallStack => FilePath -> IO () -testUsersRestartCIExpiration tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testUsersRestartCIExpiration :: HasCallStack => TestParams -> IO () +testUsersRestartCIExpiration ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- set ttl for first user alice #$> ("/_ttl 1 2", id, "ok") connectUsers alice bob @@ -2076,7 +2079,7 @@ testUsersRestartCIExpiration tmp = do alice ##> "/user alice" showActiveUser alice "alice (Alice)" - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alisa] 1 contacts connected (use /cs for the list)" @@ -2123,10 +2126,10 @@ testUsersRestartCIExpiration tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testEnableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () -testEnableCIExpirationOnlyForOneUser tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testEnableCIExpirationOnlyForOneUser :: HasCallStack => TestParams -> IO () +testEnableCIExpirationOnlyForOneUser ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- first user messages connectUsers alice bob @@ -2175,7 +2178,7 @@ testEnableCIExpirationOnlyForOneUser tmp = do showActiveUser alice "alisa" alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2194,10 +2197,10 @@ testEnableCIExpirationOnlyForOneUser tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testDisableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () -testDisableCIExpirationOnlyForOneUser tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testDisableCIExpirationOnlyForOneUser :: HasCallStack => TestParams -> IO () +testDisableCIExpirationOnlyForOneUser ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- set ttl for first user alice #$> ("/_ttl 1 1", id, "ok") connectUsers alice bob @@ -2231,7 +2234,7 @@ testDisableCIExpirationOnlyForOneUser tmp = do -- second user messages are deleted alice #$> ("/_get chat @6 count=100", chat, []) - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2252,10 +2255,10 @@ testDisableCIExpirationOnlyForOneUser tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testUsersTimedMessages :: HasCallStack => FilePath -> IO () -testUsersTimedMessages tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUsersTimedMessages :: HasCallStack => TestParams -> IO () +testUsersTimedMessages ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers alice bob configureTimedMessages alice bob "2" "2" @@ -2338,7 +2341,7 @@ testUsersTimedMessages tmp = do bob #> "@alisa alisa 4" alice <# "bob> alisa 4" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2387,7 +2390,7 @@ testUsersTimedMessages tmp = do bob <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))") alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") -- to remove feature items -testUserPrivacy :: HasCallStack => FilePath -> IO () +testUserPrivacy :: HasCallStack => TestParams -> IO () testUserPrivacy = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2528,7 +2531,7 @@ testUserPrivacy = "bob> this won't show" ] -testSetChatItemTTL :: HasCallStack => FilePath -> IO () +testSetChatItemTTL :: HasCallStack => TestParams -> IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2562,7 +2565,7 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") -testSetDirectChatTTL :: HasCallStack => FilePath -> IO () +testSetDirectChatTTL :: HasCallStack => TestParams -> IO () testSetDirectChatTTL = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2638,9 +2641,9 @@ testSetDirectChatTTL = alice #$> ("/ttl @cath year", id, "ok") alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one year") -testAppSettings :: HasCallStack => FilePath -> IO () -testAppSettings tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testAppSettings :: HasCallStack => TestParams -> IO () +testAppSettings ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]} -- app-provided defaults @@ -2656,7 +2659,7 @@ testAppSettings tmp = alice ##> "/_get app settings" alice <## ("app settings: " <> settingsApp) -testSwitchContact :: HasCallStack => FilePath -> IO () +testSwitchContact :: HasCallStack => TestParams -> IO () testSwitchContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2671,10 +2674,10 @@ testSwitchContact = bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob -testAbortSwitchContact :: HasCallStack => FilePath -> IO () -testAbortSwitchContact tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testAbortSwitchContact :: HasCallStack => TestParams -> IO () +testAbortSwitchContact ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #$> ("/switch bob", id, "switch started") alice <## "bob: you started changing address" @@ -2686,7 +2689,7 @@ testAbortSwitchContact tmp = do -- repeat switch stop is prohibited alice ##> "/abort switch bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected tmp "bob" $ \bob -> do + withTestChatContactConnected ps "bob" $ \bob -> do bob <## "alice started changing address for you" -- alice changes address again alice #$> ("/switch bob", id, "switch started") @@ -2699,7 +2702,7 @@ testAbortSwitchContact tmp = do bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob -testSwitchGroupMember :: HasCallStack => FilePath -> IO () +testSwitchGroupMember :: HasCallStack => TestParams -> IO () testSwitchGroupMember = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2717,10 +2720,10 @@ testSwitchGroupMember = bob #> "#team hi" alice <# "#team bob> hi" -testAbortSwitchGroupMember :: HasCallStack => FilePath -> IO () -testAbortSwitchGroupMember tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testAbortSwitchGroupMember :: HasCallStack => TestParams -> IO () +testAbortSwitchGroupMember ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #$> ("/switch #team bob", id, "switch started") alice <## "#team: you started changing address for bob" @@ -2732,7 +2735,7 @@ testAbortSwitchGroupMember tmp = do -- repeat switch stop is prohibited alice ##> "/abort switch #team bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected tmp "bob" $ \bob -> do + withTestChatContactConnected ps "bob" $ \bob -> do bob <## "#team: connected to server(s)" bob <## "#team: alice started changing address for you" -- alice changes address again @@ -2749,7 +2752,7 @@ testAbortSwitchGroupMember tmp = do bob #> "#team hi" alice <# "#team bob> hi" -testMarkContactVerified :: HasCallStack => FilePath -> IO () +testMarkContactVerified :: HasCallStack => TestParams -> IO () testMarkContactVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -2785,7 +2788,7 @@ testMarkContactVerified = | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" -testMarkGroupMemberVerified :: HasCallStack => FilePath -> IO () +testMarkGroupMemberVerified :: HasCallStack => TestParams -> IO () testMarkGroupMemberVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob @@ -2821,17 +2824,17 @@ testMarkGroupMemberVerified = | otherwise = "connection not verified, use /code command to see security code" #if !defined(dbPostgres) -testMsgDecryptError :: HasCallStack => FilePath -> IO () -testMsgDecryptError tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testMsgDecryptError :: HasCallStack => TestParams -> IO () +testMsgDecryptError ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" bob #> "@alice hey" alice <# "bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob hello again" bob <# "alice> skipped message ID 9..11" @@ -2839,10 +2842,10 @@ testMsgDecryptError tmp = bob #> "@alice received!" alice <# "bob> received!" -setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> IO () -setupDesynchronizedRatchet tmp alice = do +setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () +setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob 1" bob <# "alice> 1" @@ -2853,7 +2856,7 @@ setupDesynchronizedRatchet tmp alice = do bob #> "@alice 4" alice <# "bob> 4" threadDelay 500000 - withTestChat tmp "bob_old" $ \bob -> do + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "error: command is prohibited, synchronizeRatchet: not allowed" @@ -2869,21 +2872,22 @@ setupDesynchronizedRatchet tmp alice = do bob <## "error: command is prohibited, sendMessagesB: send prohibited" (alice (from <> chatSuffix)) (tmp (to <> chatSuffix)) copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) -testSyncRatchet :: HasCallStack => FilePath -> IO () -testSyncRatchet tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSyncRatchet :: HasCallStack => TestParams -> IO () +testSyncRatchet ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" bob #> "@alice hey" alice <# "bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "connection synchronization started" @@ -2901,10 +2905,10 @@ testSyncRatchet tmp = bob #> "@alice received!" alice <# "bob> received!" -testSyncRatchetCodeReset :: HasCallStack => FilePath -> IO () -testSyncRatchetCodeReset tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSyncRatchetCodeReset :: HasCallStack => TestParams -> IO () +testSyncRatchetCodeReset ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" @@ -2921,8 +2925,8 @@ testSyncRatchetCodeReset tmp = -- connection verified bob ##> "/i alice" aliceInfo bob True - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "connection synchronization started" @@ -2960,7 +2964,7 @@ testSyncRatchetCodeReset tmp = | otherwise = "connection not verified, use /code command to see security code" #endif -testSetMessageReactions :: HasCallStack => FilePath -> IO () +testSetMessageReactions :: HasCallStack => TestParams -> IO () testSetMessageReactions = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3008,10 +3012,10 @@ testSetMessageReactions = bob <# "alice> hi" bob <## " 👍 1" -testSendDeliveryReceipts :: HasCallStack => FilePath -> IO () -testSendDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testSendDeliveryReceipts :: HasCallStack => TestParams -> IO () +testSendDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" @@ -3024,11 +3028,11 @@ testSendDeliveryReceipts tmp = where cfg = testCfg {showReceipts = True} -testConfigureDeliveryReceipts :: HasCallStack => FilePath -> IO () -testConfigureDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testConfigureDeliveryReceipts :: HasCallStack => TestParams -> IO () +testConfigureDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice bob connectUsers alice cath @@ -3102,10 +3106,10 @@ testConfigureDeliveryReceipts tmp = cc2 <# (name1 <> "> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> FilePath -> IO () -testConnInvChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do +testConnInvChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> TestParams -> IO () +testConnInvChatVRange ct1VRange ct2VRange ps = + withNewTestChatCfg ps testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -3114,10 +3118,10 @@ testConnInvChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () -testConnReqChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do +testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> TestParams -> IO () +testConnReqChatVRange ct1VRange ct2VRange ps = + withNewTestChatCfg ps testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do alice ##> "/ad" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) @@ -3134,10 +3138,10 @@ testConnReqChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testUpdatePeerChatVRange :: HasCallStack => FilePath -> IO () -testUpdatePeerChatVRange tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg11 "bob" bobProfile $ \bob -> do +testUpdatePeerChatVRange :: HasCallStack => TestParams -> IO () +testUpdatePeerChatVRange ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg11 "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -3146,7 +3150,7 @@ testUpdatePeerChatVRange tmp = bob ##> "/i alice" contactInfoChatVRange bob supportedChatVRange - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob #> "@alice hello 1" @@ -3158,7 +3162,7 @@ testUpdatePeerChatVRange tmp = bob ##> "/i alice" contactInfoChatVRange bob supportedChatVRange - withTestChatCfg tmp cfg11 "bob" $ \bob -> do + withTestChatCfg ps cfg11 "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob #> "@alice hello 2" @@ -3172,15 +3176,15 @@ testUpdatePeerChatVRange tmp = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig -testGetNetworkStatuses :: HasCallStack => FilePath -> IO () -testGetNetworkStatuses tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGetNetworkStatuses :: HasCallStack => TestParams -> IO () +testGetNetworkStatuses ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/_network_statuses" alice <## "1 connections connected" - withTestChatCfg tmp cfg "alice" $ \alice -> - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "alice" $ \alice -> + withTestChatCfg ps cfg "bob" $ \bob -> do alice <## "1 connections connected" bob <## "1 connections connected" where diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 98b995e4ec..cceb91c77c 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -6,6 +6,7 @@ module ChatTests.Files where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -24,7 +25,7 @@ import Simplex.Messaging.Encoding.String import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import Test.Hspec hiding (it) -chatFileTests :: SpecWith FilePath +chatFileTests :: SpecWith TestParams chatFileTests = do describe "messages with files" $ do it "send and receive message with file" runTestMessageWithFile @@ -63,7 +64,7 @@ chatFileTests = do xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests it "removes received temporary files" testXFTPStandaloneCancelRcv -runTestMessageWithFile :: HasCallStack => FilePath -> IO () +runTestMessageWithFile :: HasCallStack => TestParams -> IO () runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -89,7 +90,7 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg")]) bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")]) -testSendImage :: HasCallStack => FilePath -> IO () +testSendImage :: HasCallStack => TestParams -> IO () testSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -120,7 +121,7 @@ testSendImage = fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True -testSenderMarkItemDeleted :: HasCallStack => FilePath -> IO () +testSenderMarkItemDeleted :: HasCallStack => TestParams -> IO () testSenderMarkItemDeleted = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -143,7 +144,7 @@ testSenderMarkItemDeleted = bob ##> "/fs 1" bob <## "receiving file 1 (test_1MB.pdf) cancelled" -testFilesFoldersSendImage :: HasCallStack => FilePath -> IO () +testFilesFoldersSendImage :: HasCallStack => TestParams -> IO () testFilesFoldersSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -175,7 +176,7 @@ testFilesFoldersSendImage = bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" -testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () +testFilesFoldersImageSndDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageSndDelete = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -208,7 +209,7 @@ testFilesFoldersImageSndDelete = bob ##> "/d alice" bob <## "alice: contact is deleted" -testFilesFoldersImageRcvDelete :: HasCallStack => FilePath -> IO () +testFilesFoldersImageRcvDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageRcvDelete = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -235,7 +236,7 @@ testFilesFoldersImageRcvDelete = bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" -testSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () +testSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testSendImageWithTextAndQuote = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -310,7 +311,7 @@ testSendImageWithTextAndQuote = B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src -testGroupSendImage :: HasCallStack => FilePath -> IO () +testGroupSendImage :: HasCallStack => TestParams -> IO () testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -352,7 +353,7 @@ testGroupSendImage = bob #$> ("/_get chat #1 count=1", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) cath #$> ("/_get chat #1 count=1", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg")]) -testGroupSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () +testGroupSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testGroupSendImageWithTextAndQuote = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -409,7 +410,7 @@ testGroupSendImageWithTextAndQuote = cath #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) cath @@@ [("#team", "hey bob"), ("@alice", "received invitation to join group team as admin")] -testSendMultiFilesDirect :: HasCallStack => FilePath -> IO () +testSendMultiFilesDirect :: HasCallStack => TestParams -> IO () testSendMultiFilesDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -473,7 +474,7 @@ testSendMultiFilesDirect = alice #$> ("/_get chat @2 count=3", chatF, [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")]) bob #$> ("/_get chat @2 count=3", chatF, [((0, "message without file"), Nothing), ((0, "sending file 1"), Just "test.jpg"), ((0, "sending file 2"), Just "test.pdf")]) -testSendMultiFilesGroup :: HasCallStack => FilePath -> IO () +testSendMultiFilesGroup :: HasCallStack => TestParams -> IO () testSendMultiFilesGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -582,7 +583,7 @@ testXFTPRoundFDCount = do roundedFDCount 128 `shouldBe` 128 roundedFDCount 500 `shouldBe` 512 -testXFTPFileTransfer :: HasCallStack => FilePath -> IO () +testXFTPFileTransfer :: HasCallStack => TestParams -> IO () testXFTPFileTransfer = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -611,7 +612,7 @@ testXFTPFileTransfer = dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testXFTPFileTransferEncrypted :: HasCallStack => TestParams -> IO () testXFTPFileTransferEncrypted = testChat2 aliceProfile bobProfile $ \alice bob -> do src <- B.readFile "./tests/fixtures/test.pdf" @@ -638,7 +639,7 @@ testXFTPFileTransferEncrypted = LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src -testXFTPAcceptAfterUpload :: HasCallStack => FilePath -> IO () +testXFTPAcceptAfterUpload :: HasCallStack => TestParams -> IO () testXFTPAcceptAfterUpload = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -663,7 +664,7 @@ testXFTPAcceptAfterUpload = dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPGroupFileTransfer :: HasCallStack => FilePath -> IO () +testXFTPGroupFileTransfer :: HasCallStack => TestParams -> IO () testXFTPGroupFileTransfer = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -701,7 +702,7 @@ testXFTPGroupFileTransfer = dest1 `shouldBe` src dest2 `shouldBe` src -testXFTPDeleteUploadedFile :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFile :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFile = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -722,7 +723,7 @@ testXFTPDeleteUploadedFile = bob ##> "/fr 1 ./tests/tmp" bob <## "file cancelled: test.pdf" -testXFTPDeleteUploadedFileGroup :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFileGroup :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFileGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -774,7 +775,7 @@ testXFTPDeleteUploadedFileGroup = cath ##> "/fr 1 ./tests/tmp" cath <## "file cancelled: test.pdf" -testXFTPWithRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPWithRelativePaths :: HasCallStack => TestParams -> IO () testXFTPWithRelativePaths = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -802,11 +803,11 @@ testXFTPWithRelativePaths = dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src -testXFTPContinueRcv :: HasCallStack => FilePath -> IO () -testXFTPContinueRcv tmp = do +testXFTPContinueRcv :: HasCallStack => TestParams -> IO () +testXFTPContinueRcv ps = do withXFTPServer $ do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -816,7 +817,7 @@ testXFTPContinueRcv tmp = do alice <## "completed uploading file 1 (test.pdf) for bob" -- server is down - file is not received - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -831,14 +832,14 @@ testXFTPContinueRcv tmp = do withXFTPServer $ do -- server is up - file reception is continued - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPMarkToReceive :: HasCallStack => FilePath -> IO () +testXFTPMarkToReceive :: HasCallStack => TestParams -> IO () testXFTPMarkToReceive = do testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -875,11 +876,11 @@ testXFTPMarkToReceive = do dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src -testXFTPRcvError :: HasCallStack => FilePath -> IO () -testXFTPRcvError tmp = do +testXFTPRcvError :: HasCallStack => TestParams -> IO () +testXFTPRcvError ps = do withXFTPServer $ do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -890,7 +891,7 @@ testXFTPRcvError tmp = do -- server is up w/t store log - file reception should fail withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -903,7 +904,7 @@ testXFTPRcvError tmp = do bob ##> "/fs 1" bob <## "receiving file 1 (test.pdf) error: FileErrAuth" -testXFTPCancelRcvRepeat :: HasCallStack => FilePath -> IO () +testXFTPCancelRcvRepeat :: HasCallStack => TestParams -> IO () testXFTPCancelRcvRepeat = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -952,7 +953,7 @@ testXFTPCancelRcvRepeat = where cfg = testCfg {xftpDescrPartSize = 200} -testAutoAcceptFile :: HasCallStack => FilePath -> IO () +testAutoAcceptFile :: HasCallStack => TestParams -> IO () testAutoAcceptFile = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -977,7 +978,7 @@ testAutoAcceptFile = where opts = (testOpts :: ChatOpts) {autoAcceptFileSize = 200000} -testProhibitFiles :: HasCallStack => FilePath -> IO () +testProhibitFiles :: HasCallStack => TestParams -> IO () testProhibitFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath @@ -999,7 +1000,7 @@ testProhibitFiles = (bob FilePath -> IO () +testXFTPStandaloneSmall :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1024,7 +1025,7 @@ testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst srcBody <- B.readFile "./tests/fixtures/logo.jpg" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneSmallInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneSmallInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1054,7 +1055,7 @@ testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src srcBody <- B.readFile "./tests/fixtures/logo.jpg" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneLarge :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLarge :: HasCallStack => TestParams -> IO () testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1081,7 +1082,7 @@ testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst srcBody <- B.readFile "./tests/tmp/testfile.in" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneLargeInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLargeInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1114,7 +1115,7 @@ testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src srcBody <- B.readFile "./tests/tmp/testfile.in" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneCancelSnd :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelSnd :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1144,7 +1145,7 @@ testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst <## "error receiving file 1 (should.not.extist)" dst <## "INTERNAL {internalErr = \"XFTP {xftpErr = AUTH}\"}" -testXFTPStandaloneRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPStandaloneRelativePaths :: HasCallStack => TestParams -> IO () testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1175,7 +1176,7 @@ testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \ srcBody <- B.readFile "./tests/tmp/src_files/testfile.in" B.readFile "./tests/tmp/dst_files/testfile.out" `shouldReturn` srcBody -testXFTPStandaloneCancelRcv :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelRcv :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 6a1edfe3d4..483c2269b1 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -4,6 +4,7 @@ module ChatTests.Forward where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import qualified Data.ByteString.Char8 as B @@ -14,7 +15,7 @@ import Simplex.Chat.Types (ImageData (..)) import System.Directory (copyFile, doesFileExist, removeFile) import Test.Hspec hiding (it) -chatForwardTests :: SpecWith FilePath +chatForwardTests :: SpecWith TestParams chatForwardTests = do describe "forward messages" $ do it "from contact to contact" testForwardContactToContact @@ -42,7 +43,7 @@ chatForwardTests = do it "from group to group" testForwardGroupToGroupMulti it "with relative paths: multiple files from contact to contact" testMultiForwardFiles -testForwardContactToContact :: HasCallStack => FilePath -> IO () +testForwardContactToContact :: HasCallStack => TestParams -> IO () testForwardContactToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -88,7 +89,7 @@ testForwardContactToContact = alice .<## ": hey" alice <##. "forwarded from: @bob, chat item id:" -testForwardContactToGroup :: HasCallStack => FilePath -> IO () +testForwardContactToGroup :: HasCallStack => TestParams -> IO () testForwardContactToGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -112,7 +113,7 @@ testForwardContactToGroup = cath <# "#team alice> -> forwarded" cath <## " hey" -testForwardContactToNotes :: HasCallStack => FilePath -> IO () +testForwardContactToNotes :: HasCallStack => TestParams -> IO () testForwardContactToNotes = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -132,7 +133,7 @@ testForwardContactToNotes = alice <# "* <- @bob" alice <## " hey" -testForwardGroupToContact :: HasCallStack => FilePath -> IO () +testForwardGroupToContact :: HasCallStack => TestParams -> IO () testForwardGroupToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -156,7 +157,7 @@ testForwardGroupToContact = cath <# "alice> -> forwarded" cath <## " hey" -testForwardGroupToGroup :: HasCallStack => FilePath -> IO () +testForwardGroupToGroup :: HasCallStack => TestParams -> IO () testForwardGroupToGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -197,7 +198,7 @@ testForwardGroupToGroup = cath <# "#club alice> -> forwarded" cath <## " hey" -testForwardGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardGroupToNotes :: HasCallStack => TestParams -> IO () testForwardGroupToNotes = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -217,7 +218,7 @@ testForwardGroupToNotes = alice <# "* <- #team" alice <## " hey" -testForwardNotesToContact :: HasCallStack => FilePath -> IO () +testForwardNotesToContact :: HasCallStack => TestParams -> IO () testForwardNotesToContact = testChat2 aliceProfile cathProfile $ \alice cath -> do @@ -230,7 +231,7 @@ testForwardNotesToContact = alice <# "@cath hi" cath <# "alice> hi" -testForwardNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardNotesToGroup :: HasCallStack => TestParams -> IO () testForwardNotesToGroup = testChat2 aliceProfile cathProfile $ \alice cath -> do @@ -243,9 +244,9 @@ testForwardNotesToGroup = alice <# "#team hi" cath <# "#team alice> hi" -testForwardNotesToNotes :: HasCallStack => FilePath -> IO () -testForwardNotesToNotes tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testForwardNotesToNotes :: HasCallStack => TestParams -> IO () +testForwardNotesToNotes ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "hi" @@ -257,7 +258,7 @@ testForwardNotesToNotes tmp = alice <# "* hi" alice <# "* hi" -testForwardPreserveInfo :: HasCallStack => FilePath -> IO () +testForwardPreserveInfo :: HasCallStack => TestParams -> IO () testForwardPreserveInfo = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -285,7 +286,7 @@ testForwardPreserveInfo = dan <# "#team alice> -> forwarded" dan <## " hey" -testForwardRcvMsgNewInfo :: HasCallStack => FilePath -> IO () +testForwardRcvMsgNewInfo :: HasCallStack => TestParams -> IO () testForwardRcvMsgNewInfo = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -313,7 +314,7 @@ testForwardRcvMsgNewInfo = cath <# "alice> -> forwarded" cath <## " hey" -testForwardQuotedMsg :: HasCallStack => FilePath -> IO () +testForwardQuotedMsg :: HasCallStack => TestParams -> IO () testForwardQuotedMsg = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -343,7 +344,7 @@ testForwardQuotedMsg = cath <# "alice> -> forwarded" cath <## " hey" -testForwardEditProhibited :: HasCallStack => FilePath -> IO () +testForwardEditProhibited :: HasCallStack => TestParams -> IO () testForwardEditProhibited = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -363,7 +364,7 @@ testForwardEditProhibited = alice ##> ("/_update item @3 " <> msgId <> " text hey edited") alice <## "cannot update this item" -testForwardDeleteForOther :: HasCallStack => FilePath -> IO () +testForwardDeleteForOther :: HasCallStack => TestParams -> IO () testForwardDeleteForOther = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -384,7 +385,7 @@ testForwardDeleteForOther = alice <## "message marked deleted" cath <# "alice> [marked deleted] hey" -testForwardFileNoFilesFolder :: HasCallStack => FilePath -> IO () +testForwardFileNoFilesFolder :: HasCallStack => TestParams -> IO () testForwardFileNoFilesFolder = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -438,7 +439,7 @@ testForwardFileNoFilesFolder = dest2 <- B.readFile "./tests/tmp/test_1.pdf" dest2 `shouldBe` src -testForwardFileContactToContact :: HasCallStack => FilePath -> IO () +testForwardFileContactToContact :: HasCallStack => TestParams -> IO () testForwardFileContactToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -504,7 +505,7 @@ testForwardFileContactToContact = fwdFileExists <- doesFileExist "./tests/tmp/bob_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardFileGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardFileGroupToNotes :: HasCallStack => TestParams -> IO () testForwardFileGroupToNotes = testChat2 aliceProfile cathProfile $ \alice cath -> withXFTPServer $ do @@ -552,7 +553,7 @@ testForwardFileGroupToNotes = fwdFileExists <- doesFileExist "./tests/tmp/cath_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardFileNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardFileNotesToGroup :: HasCallStack => TestParams -> IO () testForwardFileNotesToGroup = testChat2 aliceProfile cathProfile $ \alice cath -> withXFTPServer $ do @@ -599,7 +600,7 @@ testForwardFileNotesToGroup = fwdFileExists <- doesFileExist "./tests/tmp/alice_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardContactToContactMulti :: HasCallStack => FilePath -> IO () +testForwardContactToContactMulti :: HasCallStack => TestParams -> IO () testForwardContactToContactMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -629,7 +630,7 @@ testForwardContactToContactMulti = cath <# "alice> -> forwarded" cath <## " hey" -testForwardGroupToGroupMulti :: HasCallStack => FilePath -> IO () +testForwardGroupToGroupMulti :: HasCallStack => TestParams -> IO () testForwardGroupToGroupMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -673,7 +674,7 @@ testForwardGroupToGroupMulti = cath <# "#club alice> -> forwarded" cath <## " hey" -testMultiForwardFiles :: HasCallStack => FilePath -> IO () +testMultiForwardFiles :: HasCallStack => TestParams -> IO () testMultiForwardFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index b9fee913d8..6042d19195 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -10,6 +10,7 @@ module ChatTests.Groups where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -20,7 +21,6 @@ import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -34,11 +34,12 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Options.DB import System.Directory (copyFile) import System.FilePath (()) #endif -chatGroupTests :: SpecWith FilePath +chatGroupTests :: SpecWith TestParams chatGroupTests = do describe "chat groups" $ do describe "add contacts, create group and send/receive messages" testGroupMatrix @@ -200,17 +201,17 @@ chatGroupTests = do ) $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns -testGroup :: HasCallStack => FilePath -> IO () +testGroup :: HasCallStack => TestParams -> IO () testGroup = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> testGroupShared alice bob cath False True -testGroupCheckMessages :: HasCallStack => FilePath -> IO () +testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> testGroupShared alice bob cath True True -testGroupMatrix :: SpecWith FilePath +testGroupMatrix :: SpecWith TestParams testGroupMatrix = versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False False @@ -369,7 +370,7 @@ testGroupShared alice bob cath checkMessages directConnections = do alice #$> ("/_unread chat #1 on", id, "ok") alice #$> ("/_unread chat #1 off", id, "ok") -testMarkReadGroup :: HasCallStack => FilePath -> IO () +testMarkReadGroup :: HasCallStack => TestParams -> IO () testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob alice #> "#team 1" @@ -385,7 +386,7 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do let itemIds = intercalate "," $ map show [i - 3 .. i] bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") -testChatPaginationInitial :: HasCallStack => FilePath -> IO () +testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob -- Wait, otherwise ids are going to be wrong. @@ -417,7 +418,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice { markRead = False } -testGroupLargeMessage :: HasCallStack => FilePath -> IO () +testGroupLargeMessage :: HasCallStack => TestParams -> IO () testGroupLargeMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -431,7 +432,7 @@ testGroupLargeMessage = bob <## "alice updated group #team:" bob <## "profile image updated" -testNewGroupIncognito :: HasCallStack => FilePath -> IO () +testNewGroupIncognito :: HasCallStack => TestParams -> IO () testNewGroupIncognito = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -481,7 +482,7 @@ testNewGroupIncognito = bob ##> "/gs" bob <## "#team (2 members)" -testGroup2 :: HasCallStack => FilePath -> IO () +testGroup2 :: HasCallStack => TestParams -> IO () testGroup2 = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -681,7 +682,7 @@ testGroup2 = bob <##> cath bob <##> alice -testGroupDelete :: HasCallStack => FilePath -> IO () +testGroupDelete :: HasCallStack => TestParams -> IO () testGroupDelete = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -717,7 +718,7 @@ testGroupDelete = where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} -testGroupSameName :: HasCallStack => FilePath -> IO () +testGroupSameName :: HasCallStack => TestParams -> IO () testGroupSameName = testChat2 aliceProfile bobProfile $ \alice _ -> do @@ -729,7 +730,7 @@ testGroupSameName = alice <## "group #team_1 is created" alice <## "to add members use /a team_1 or /create link #team_1" -testGroupDeleteWhenInvited :: HasCallStack => FilePath -> IO () +testGroupDeleteWhenInvited :: HasCallStack => TestParams -> IO () testGroupDeleteWhenInvited = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -756,7 +757,7 @@ testGroupDeleteWhenInvited = bob <## "use /j team to accept" ] -testGroupReAddInvited :: HasCallStack => FilePath -> IO () +testGroupReAddInvited :: HasCallStack => TestParams -> IO () testGroupReAddInvited = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -791,7 +792,7 @@ testGroupReAddInvited = bob <## "use /j team_1 to accept" ] -testGroupReAddInvitedChangeRole :: HasCallStack => FilePath -> IO () +testGroupReAddInvitedChangeRole :: HasCallStack => TestParams -> IO () testGroupReAddInvitedChangeRole = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -831,7 +832,7 @@ testGroupReAddInvitedChangeRole = alice ##> "/d #team" alice <## "#team: you deleted the group" -testGroupDeleteInvitedContact :: HasCallStack => FilePath -> IO () +testGroupDeleteInvitedContact :: HasCallStack => TestParams -> IO () testGroupDeleteInvitedContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -876,7 +877,7 @@ testGroupDeleteInvitedContact = (bob <## "alice (Alice): contact is connected") alice <##> bob -testDeleteGroupMemberProfileKept :: HasCallStack => FilePath -> IO () +testDeleteGroupMemberProfileKept :: HasCallStack => TestParams -> IO () testDeleteGroupMemberProfileKept = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -946,7 +947,7 @@ testDeleteGroupMemberProfileKept = bob #> "#club received" alice <# "#club bob> received" -testGroupRemoveAdd :: HasCallStack => FilePath -> IO () +testGroupRemoveAdd :: HasCallStack => TestParams -> IO () testGroupRemoveAdd = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -997,7 +998,7 @@ testGroupRemoveAdd = (alice <# "#team cath> hello") (bob <# "#team_1 cath> hello") -testGroupList :: HasCallStack => FilePath -> IO () +testGroupList :: HasCallStack => TestParams -> IO () testGroupList = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1027,7 +1028,7 @@ testGroupList = bob ##> "/gs" bob <## "#team (2 members)" -testGroupMessageQuotedReply :: HasCallStack => FilePath -> IO () +testGroupMessageQuotedReply :: HasCallStack => TestParams -> IO () testGroupMessageQuotedReply = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1097,7 +1098,7 @@ testGroupMessageQuotedReply = cath <## " go on" ) -testGroupMessageUpdate :: HasCallStack => FilePath -> IO () +testGroupMessageUpdate :: HasCallStack => TestParams -> IO () testGroupMessageUpdate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1169,7 +1170,7 @@ testGroupMessageUpdate = bob #$> ("/_get chat #1 count=3", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hey 👋")), ((0, "greetings!"), Just (0, "greetings 🤝"))]) cath #$> ("/_get chat #1 count=3", chat', [((0, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (0, "hey 👋")), ((1, "greetings!"), Just (0, "greetings 🤝"))]) -testGroupMessageEditHistory :: HasCallStack => FilePath -> IO () +testGroupMessageEditHistory :: HasCallStack => TestParams -> IO () testGroupMessageEditHistory = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1243,7 +1244,7 @@ testGroupMessageEditHistory = bob <## "message history:" bob .<## ": hey there" -testGroupMessageDelete :: HasCallStack => FilePath -> IO () +testGroupMessageDelete :: HasCallStack => TestParams -> IO () testGroupMessageDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1327,7 +1328,7 @@ testGroupMessageDelete = bob #$> ("/_get chat #1 count=3", chat', [((0, "hello!"), Nothing), ((1, "hi alice"), Just (0, "hello!")), ((0, "how are you? [marked deleted]"), Nothing)]) cath #$> ("/_get chat #1 count=3", chat', [((0, "hello!"), Nothing), ((0, "hi alice"), Just (0, "hello!")), ((1, "how are you? [marked deleted]"), Nothing)]) -testGroupMessageDeleteMultiple :: HasCallStack => FilePath -> IO () +testGroupMessageDeleteMultiple :: HasCallStack => TestParams -> IO () testGroupMessageDeleteMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1363,7 +1364,7 @@ testGroupMessageDeleteMultiple = bob #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) -testGroupMessageDeleteMultipleManyBatches :: HasCallStack => FilePath -> IO () +testGroupMessageDeleteMultipleManyBatches :: HasCallStack => TestParams -> IO () testGroupMessageDeleteMultipleManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1401,7 +1402,7 @@ testGroupMessageDeleteMultipleManyBatches = (bob <# ("#team alice> [marked deleted] message " <> show i)) (cath <# ("#team alice> [marked deleted] message " <> show i)) -testGroupLiveMessage :: HasCallStack => FilePath -> IO () +testGroupLiveMessage :: HasCallStack => TestParams -> IO () testGroupLiveMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -1439,7 +1440,7 @@ testGroupLiveMessage = bob .<## ": hello 2" bob .<## ":" -testUpdateGroupProfile :: HasCallStack => FilePath -> IO () +testUpdateGroupProfile :: HasCallStack => TestParams -> IO () testUpdateGroupProfile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1466,7 +1467,7 @@ testUpdateGroupProfile = (alice <# "#my_team bob> hi") (cath <# "#my_team bob> hi") -testUpdateMemberRole :: HasCallStack => FilePath -> IO () +testUpdateMemberRole :: HasCallStack => TestParams -> IO () testUpdateMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1512,7 +1513,7 @@ testUpdateMemberRole = alice ##> "/d #team" alice <## "#team: you have insufficient permissions for this action, the required role is owner" -testGroupDeleteUnusedContacts :: HasCallStack => FilePath -> IO () +testGroupDeleteUnusedContacts :: HasCallStack => TestParams -> IO () testGroupDeleteUnusedContacts = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1599,7 +1600,7 @@ testGroupDeleteUnusedContacts = cath ##> ("/d #" <> group) cath <## ("#" <> group <> ": you deleted the group") -testGroupDescription :: HasCallStack => FilePath -> IO () +testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob alice ##> "/g team" @@ -1670,7 +1671,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile cc <## "#team: bob added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testGroupModerate :: HasCallStack => FilePath -> IO () +testGroupModerate :: HasCallStack => TestParams -> IO () testGroupModerate = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1701,7 +1702,7 @@ testGroupModerate = bob #$> ("/_get chat #1 count=1", chat, [(0, "hi [marked deleted by you]")]) cath #$> ("/_get chat #1 count=1", chat, [(1, "hi [marked deleted by bob]")]) -testGroupModerateOwn :: HasCallStack => FilePath -> IO () +testGroupModerateOwn :: HasCallStack => TestParams -> IO () testGroupModerateOwn = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1715,7 +1716,7 @@ testGroupModerateOwn = alice #$> ("/_get chat #1 count=1", chat, [(1, "hello [marked deleted by you]")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "hello [marked deleted by alice]")]) -testGroupModerateMultiple :: HasCallStack => FilePath -> IO () +testGroupModerateMultiple :: HasCallStack => TestParams -> IO () testGroupModerateMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1750,7 +1751,7 @@ testGroupModerateMultiple = bob #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (1, "hey [marked deleted by alice]")]) cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "hey [marked deleted by alice]")]) -testGroupModerateFullDelete :: HasCallStack => FilePath -> IO () +testGroupModerateFullDelete :: HasCallStack => TestParams -> IO () testGroupModerateFullDelete = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1788,12 +1789,12 @@ testGroupModerateFullDelete = bob #$> ("/_get chat #1 count=1", chat, [(0, "moderated [deleted by you]")]) cath #$> ("/_get chat #1 count=1", chat, [(1, "moderated [deleted by bob]")]) -testGroupDelayedModeration :: HasCallStack => FilePath -> IO () -testGroupDelayedModeration tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGroupDelayedModeration :: HasCallStack => TestParams -> IO () +testGroupDelayedModeration ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1814,11 +1815,11 @@ testGroupDelayedModeration tmp = do alice ##> "\\\\ #team @cath hi" alice <## "message marked deleted by you" cath <# "#team cath> [marked deleted by alice] hi" - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - withTestChatCfg tmp cfg "cath" $ \cath -> do + withTestChatCfg ps cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1834,12 +1835,12 @@ testGroupDelayedModeration tmp = do where cfg = testCfgCreateGroupDirect -testGroupDelayedModerationFullDelete :: HasCallStack => FilePath -> IO () -testGroupDelayedModerationFullDelete tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGroupDelayedModerationFullDelete :: HasCallStack => TestParams -> IO () +testGroupDelayedModerationFullDelete ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1868,14 +1869,14 @@ testGroupDelayedModerationFullDelete tmp = do cath <## "alice updated group #team:" cath <## "updated group preferences:" cath <## "Full deletion: on" - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" - withTestChatCfg tmp cfg "cath" $ \cath -> do + withTestChatCfg ps cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1891,7 +1892,7 @@ testGroupDelayedModerationFullDelete tmp = do where cfg = testCfgCreateGroupDirect -testSendMulti :: HasCallStack => FilePath -> IO () +testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1905,7 +1906,7 @@ testSendMulti = cath <# "#team alice> test 1" cath <# "#team alice> test 2" -testSendMultiTimed :: HasCallStack => FilePath -> IO () +testSendMultiTimed :: HasCallStack => TestParams -> IO () testSendMultiTimed = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1942,7 +1943,7 @@ testSendMultiTimed = "timed message deleted: test 2" ] -testSendMultiManyBatches :: HasCallStack => FilePath -> IO () +testSendMultiManyBatches :: HasCallStack => TestParams -> IO () testSendMultiManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1977,10 +1978,10 @@ testSendMultiManyBatches = DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdCath) :: IO [[Int]] cathItemsCount `shouldBe` [[300]] -testGroupAsync :: HasCallStack => FilePath -> IO () -testGroupAsync tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupAsync :: HasCallStack => TestParams -> IO () +testGroupAsync ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/g team" alice <## "group #team is created" @@ -1998,8 +1999,8 @@ testGroupAsync tmp = do (bob <## "#team: you joined the group") alice #> "#team hello bob" bob <# "#team alice> hello bob" - withTestChat tmp "alice" $ \alice -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withTestChat ps "alice" $ \alice -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "#team: connected to server(s)" connectUsers alice cath @@ -2017,8 +2018,8 @@ testGroupAsync tmp = do ] alice #> "#team hello cath" cath <# "#team alice> hello cath" - withTestChat tmp "bob" $ \bob -> do - withTestChat tmp "cath" $ \cath -> do + withTestChat ps "bob" $ \bob -> do + withTestChat ps "cath" $ \cath -> do concurrentlyN_ [ do bob <## "1 contacts connected (use /cs for the list)" @@ -2032,8 +2033,8 @@ testGroupAsync tmp = do cath <## "#team: member bob (Bob) is connected" ] threadDelay 500000 - withTestChat tmp "bob" $ \bob -> do - withNewTestChat tmp "dan" danProfile $ \dan -> do + withTestChat ps "bob" $ \bob -> do + withNewTestChat ps "dan" danProfile $ \dan -> do bob <## "2 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" connectUsers bob dan @@ -2051,9 +2052,9 @@ testGroupAsync tmp = do ] threadDelay 1000000 threadDelay 1000000 - withTestChat tmp "alice" $ \alice -> do - withTestChat tmp "cath" $ \cath -> do - withTestChat tmp "dan" $ \dan -> do + withTestChat ps "alice" $ \alice -> do + withTestChat ps "cath" $ \cath -> do + withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do alice <## "2 contacts connected (use /cs for the list)" @@ -2072,10 +2073,10 @@ testGroupAsync tmp = do dan <## "#team: member cath (Catherine) is connected" ] threadDelay 1000000 - withTestChat tmp "alice" $ \alice -> do - withTestChat tmp "bob" $ \bob -> do - withTestChat tmp "cath" $ \cath -> do - withTestChat tmp "dan" $ \dan -> do + withTestChat ps "alice" $ \alice -> do + withTestChat ps "bob" $ \bob -> do + withTestChat ps "cath" $ \cath -> do + withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do alice <## "3 contacts connected (use /cs for the list)" @@ -2118,7 +2119,7 @@ testGroupAsync tmp = do dan <##> cath dan <##> alice -testGroupLink :: HasCallStack => FilePath -> IO () +testGroupLink :: HasCallStack => TestParams -> IO () testGroupLink = testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2222,7 +2223,7 @@ testGroupLink = alice ##> "/show link #team" alice <## "no group link, to create: /create link #team" -testGroupLinkDeleteGroupRejoin :: HasCallStack => FilePath -> IO () +testGroupLinkDeleteGroupRejoin :: HasCallStack => TestParams -> IO () testGroupLinkDeleteGroupRejoin = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2279,7 +2280,7 @@ testGroupLinkDeleteGroupRejoin = bob #> "#team hi there" alice <# "#team bob> hi there" -testGroupLinkContactUsed :: HasCallStack => FilePath -> IO () +testGroupLinkContactUsed :: HasCallStack => TestParams -> IO () testGroupLinkContactUsed = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2313,7 +2314,7 @@ testGroupLinkContactUsed = bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") bob @@@ [("@alice", ""), ("#team", "connected")] -testGroupLinkIncognitoMembership :: HasCallStack => FilePath -> IO () +testGroupLinkIncognitoMembership :: HasCallStack => TestParams -> IO () testGroupLinkIncognitoMembership = testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -2427,7 +2428,7 @@ testGroupLinkIncognitoMembership = cath <# ("#team " <> danIncognito <> "> how is it going?") ] -testGroupLinkUnusedHostContactDeleted :: HasCallStack => FilePath -> IO () +testGroupLinkUnusedHostContactDeleted :: HasCallStack => TestParams -> IO () testGroupLinkUnusedHostContactDeleted = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -2502,7 +2503,7 @@ testGroupLinkUnusedHostContactDeleted = bob ##> ("/d #" <> group) bob <## ("#" <> group <> ": you deleted the group") -testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => FilePath -> IO () +testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => TestParams -> IO () testGroupLinkIncognitoUnusedHostContactsDeleted = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -2563,7 +2564,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = bob ##> ("/d #" <> group) bob <## ("#" <> group <> ": you deleted the group") -testGroupLinkMemberRole :: HasCallStack => FilePath -> IO () +testGroupLinkMemberRole :: HasCallStack => TestParams -> IO () testGroupLinkMemberRole = testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2630,7 +2631,7 @@ testGroupLinkMemberRole = (alice <# "#team bob> hey now") (cath <# "#team bob> hey now") -testGroupLinkLeaveDelete :: HasCallStack => FilePath -> IO () +testGroupLinkLeaveDelete :: HasCallStack => TestParams -> IO () testGroupLinkLeaveDelete = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2699,7 +2700,7 @@ testGroupLinkLeaveDelete = bob <## "alice (Alice)" bob <## "cath (Catherine)" -testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOkKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkOkKnown = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2743,7 +2744,7 @@ testPlanGroupLinkOkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () +testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => TestParams -> IO () testPlanHostContactDeletedGroupLinkKnown = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2790,9 +2791,9 @@ testPlanHostContactDeletedGroupLinkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () -testPlanGroupLinkOwn tmp = - withNewTestChatCfg tmp (mkCfgGroupLinkViaContact testCfgSlow) "alice" aliceProfile $ \alice -> do +testPlanGroupLinkOwn :: HasCallStack => TestParams -> IO () +testPlanGroupLinkOwn ps = + withNewTestChatCfg ps (mkCfgGroupLinkViaContact testCfgSlow) "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" @@ -2851,18 +2852,18 @@ testPlanGroupLinkOwn tmp = WithTime "#team alice_1> 4" ] -testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () -testPlanGroupLinkConnecting tmp = do - -- gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnecting ps = do + -- gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - -- withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + -- withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -2876,15 +2877,15 @@ testPlanGroupLinkConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - -- withTestChatCfg tmp cfg "alice" $ \alice -> do - withTestChatCfg tmp cfg "alice" $ \alice -> do + -- withTestChatCfg ps cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - -- withTestChatCfg tmp cfg "bob" $ \bob -> do - withTestChatCfg tmp cfg "bob" $ \bob -> do + -- withTestChatCfg ps cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do threadDelay 500000 bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting" @@ -2898,7 +2899,7 @@ testPlanGroupLinkConnecting tmp = do where cfg = mkCfgGroupLinkViaContact testCfgSlow -testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () +testPlanGroupLinkLeaveRejoin :: HasCallStack => TestParams -> IO () testPlanGroupLinkLeaveRejoin = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2987,7 +2988,7 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" -testGroupLinkNoContact :: HasCallStack => FilePath -> IO () +testGroupLinkNoContact :: HasCallStack => TestParams -> IO () testGroupLinkNoContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3048,7 +3049,7 @@ testGroupLinkNoContact = alice <# "#team bob> hi cath" cath <# "#team bob> hi cath" -testGroupLinkNoContactInviteesWereConnected :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactInviteesWereConnected :: HasCallStack => TestParams -> IO () testGroupLinkNoContactInviteesWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3120,7 +3121,7 @@ testGroupLinkNoContactInviteesWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactAllMembersWereConnected :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactAllMembersWereConnected :: HasCallStack => TestParams -> IO () testGroupLinkNoContactAllMembersWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3211,7 +3212,7 @@ testGroupLinkNoContactAllMembersWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactMemberRole :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactMemberRole :: HasCallStack => TestParams -> IO () testGroupLinkNoContactMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3292,7 +3293,7 @@ testGroupLinkNoContactMemberRole = bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" -testGroupLinkNoContactHostIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostIncognito :: HasCallStack => TestParams -> IO () testGroupLinkNoContactHostIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3325,7 +3326,7 @@ testGroupLinkNoContactHostIncognito = bob #> "#team hi there" alice ?<# "#team bob> hi there" -testGroupLinkNoContactInviteeIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactInviteeIncognito :: HasCallStack => TestParams -> IO () testGroupLinkNoContactInviteeIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3359,7 +3360,7 @@ testGroupLinkNoContactInviteeIncognito = bob ?#> "#team hi there" alice <# ("#team " <> bobIncognito <> "> hi there") -testGroupLinkNoContactHostProfileReceived :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostProfileReceived :: HasCallStack => TestParams -> IO () testGroupLinkNoContactHostProfileReceived = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3387,7 +3388,7 @@ testGroupLinkNoContactHostProfileReceived = aliceImage <- getProfilePictureByName bob "alice" aliceImage `shouldBe` Just profileImage -testGroupLinkNoContactExistingContactMerged :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactExistingContactMerged :: HasCallStack => TestParams -> IO () testGroupLinkNoContactExistingContactMerged = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3430,7 +3431,7 @@ testGroupLinkNoContactExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" -testPlanGroupLinkNoContactKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkNoContactKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3467,16 +3468,16 @@ testPlanGroupLinkNoContactKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnecting :: HasCallStack => FilePath -> IO () -testPlanGroupLinkNoContactConnecting tmp = do - gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanGroupLinkNoContactConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupLinkNoContactConnecting ps = do + gLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -3490,13 +3491,13 @@ testPlanGroupLinkNoContactConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do threadDelay 500000 bob <## "#team: joining the group..." bob <## "#team: you joined the group" @@ -3514,16 +3515,16 @@ testPlanGroupLinkNoContactConnecting tmp = do bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnectingSlow :: HasCallStack => FilePath -> IO () -testPlanGroupLinkNoContactConnectingSlow tmp = do - gLink <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testPlanGroupLinkNoContactConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanGroupLinkNoContactConnectingSlow ps = do + gLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -3537,13 +3538,13 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 bob <## "#team: joining the group..." @@ -3558,17 +3559,17 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob <## "group link: connecting to group #team" #if !defined(dbPostgres) -testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () -testGroupMsgDecryptError tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupMsgDecryptError :: HasCallStack => TestParams -> IO () +testGroupMsgDecryptError ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" bob #> "#team hey" alice <# "#team bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" alice #> "#team hello again" @@ -3577,10 +3578,10 @@ testGroupMsgDecryptError tmp = bob #> "#team received!" alice <# "#team bob> received!" -setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> IO () -setupDesynchronizedRatchet tmp alice = do +setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () +setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" alice #> "#team 1" @@ -3591,7 +3592,7 @@ setupDesynchronizedRatchet tmp alice = do bob <# "#team alice> 3" bob #> "#team 4" alice <# "#team bob> 4" - withTestChat tmp "bob_old" $ \bob -> do + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob ##> "/sync #team alice" @@ -3605,21 +3606,22 @@ setupDesynchronizedRatchet tmp alice = do bob ##> "/tail #team 1" bob <# "#team alice> decryption error, possibly due to the device change (header, 3 messages)" where + tmp = tmpPath ps copyDb from to = do copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) -testGroupSyncRatchet :: HasCallStack => FilePath -> IO () -testGroupSyncRatchet tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupSyncRatchet :: HasCallStack => TestParams -> IO () +testGroupSyncRatchet ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" bob #> "#team hey" alice <# "#team bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob `send` "#team 1" @@ -3644,10 +3646,10 @@ testGroupSyncRatchet tmp = bob #> "#team received!" alice <# "#team bob> received!" -testGroupSyncRatchetCodeReset :: HasCallStack => FilePath -> IO () -testGroupSyncRatchetCodeReset tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupSyncRatchetCodeReset :: HasCallStack => TestParams -> IO () +testGroupSyncRatchetCodeReset ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" @@ -3664,8 +3666,8 @@ testGroupSyncRatchetCodeReset tmp = -- connection verified bob ##> "/i #team alice" aliceInfo bob True - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob ##> "/sync #team alice" @@ -3703,7 +3705,7 @@ testGroupSyncRatchetCodeReset tmp = | otherwise = "connection not verified, use /code command to see security code" #endif -testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () +testSetGroupMessageReactions :: HasCallStack => TestParams -> IO () testSetGroupMessageReactions = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3775,11 +3777,11 @@ testSetGroupMessageReactions = cath <# "#team alice> hi" cath <## " 👍 1" -testSendGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () -testSendGroupDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testSendGroupDeliveryReceipts :: HasCallStack => TestParams -> IO () +testSendGroupDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do -- turn off contacts receipts for tests alice ##> "/_set receipts contacts 1 off" alice <## "ok" @@ -3805,11 +3807,11 @@ testSendGroupDeliveryReceipts tmp = where cfg = testCfg {showReceipts = True} -testConfigureGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () -testConfigureGroupDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testConfigureGroupDeliveryReceipts :: HasCallStack => TestParams -> IO () +testConfigureGroupDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do -- turn off contacts receipts for tests alice ##> "/_set receipts contacts 1 off" alice <## "ok" @@ -3954,11 +3956,11 @@ testConfigureGroupDeliveryReceipts tmp = cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> FilePath -> IO () -testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = - withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do +testNoGroupDirectConns :: HasCallStack => VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> TestParams -> IO () +testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns ps = + withNewTestChatCfg ps testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath if noDirectConns then contactsDontExist bob cath @@ -3982,7 +3984,7 @@ testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = ] bob <##> cath -testNoDirectDifferentLDNs :: HasCallStack => FilePath -> IO () +testNoDirectDifferentLDNs :: HasCallStack => TestParams -> IO () testNoDirectDifferentLDNs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4052,7 +4054,7 @@ testNoDirectDifferentLDNs = bob <# ("#" <> gName <> " " <> cathLDN <> "> hey") ] -testMergeMemberExistingContact :: HasCallStack => FilePath -> IO () +testMergeMemberExistingContact :: HasCallStack => TestParams -> IO () testMergeMemberExistingContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4099,7 +4101,7 @@ testMergeMemberExistingContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactExistingMember :: HasCallStack => FilePath -> IO () +testMergeContactExistingMember :: HasCallStack => TestParams -> IO () testMergeContactExistingMember = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4130,7 +4132,7 @@ testMergeContactExistingMember = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () +testMergeContactMultipleMembers :: HasCallStack => TestParams -> IO () testMergeContactMultipleMembers = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4168,7 +4170,7 @@ testMergeContactMultipleMembers = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeGroupLinkHostMultipleContacts :: HasCallStack => TestParams -> IO () testMergeGroupLinkHostMultipleContacts = testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ \bob cath -> do @@ -4217,7 +4219,7 @@ testMergeGroupLinkHostMultipleContacts = bob `hasContactProfiles` ["bob", "cath", "cath"] cath `hasContactProfiles` ["cath", "bob", "bob"] -testMemberContactMessage :: HasCallStack => FilePath -> IO () +testMemberContactMessage :: HasCallStack => TestParams -> IO () testMemberContactMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4284,7 +4286,7 @@ testMemberContactMessage = cath #$> ("/_get chat #1 count=1", chat, [(0, "started direct connection with you")]) bob <##> cath -testMemberContactNoMessage :: HasCallStack => FilePath -> IO () +testMemberContactNoMessage :: HasCallStack => TestParams -> IO () testMemberContactNoMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4305,7 +4307,7 @@ testMemberContactNoMessage = cath #$> ("/_get chat #1 count=1", chat, [(0, "started direct connection with you")]) bob <##> cath -testMemberContactProhibitedContactExists :: HasCallStack => FilePath -> IO () +testMemberContactProhibitedContactExists :: HasCallStack => TestParams -> IO () testMemberContactProhibitedContactExists = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4318,7 +4320,7 @@ testMemberContactProhibitedContactExists = alice <# "@bob hi" bob <# "alice> hi" -testMemberContactProhibitedRepeatInv :: HasCallStack => FilePath -> IO () +testMemberContactProhibitedRepeatInv :: HasCallStack => TestParams -> IO () testMemberContactProhibitedRepeatInv = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4345,11 +4347,11 @@ testMemberContactProhibitedRepeatInv = bob <##> cath -testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () -testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testMemberContactInvitedConnectionReplaced :: HasCallStack => TestParams -> IO () +testMemberContactInvitedConnectionReplaced ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -4377,20 +4379,20 @@ testMemberContactInvitedConnectionReplaced tmp = do items <- chat <$> getTermLine bob items `shouldContain` [(0, "security code changed")] - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do subscriptions bob 1 checkConnectionsWork alice bob - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do subscriptions alice 2 - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do subscriptions bob 1 checkConnectionsWork alice bob - withTestChat tmp "cath" $ \cath -> do + withTestChat ps "cath" $ \cath -> do subscriptions cath 1 -- group messages work @@ -4416,7 +4418,7 @@ testMemberContactInvitedConnectionReplaced tmp = do alice @@@ [("@bob", "hey"), ("@cath", "sent invitation to join group team as admin"), ("#team", "connected")] bob @@@ [("@alice", "hey"), ("#team", "started direct connection with you")] -testMemberContactIncognito :: HasCallStack => FilePath -> IO () +testMemberContactIncognito :: HasCallStack => TestParams -> IO () testMemberContactIncognito = testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4516,7 +4518,7 @@ testMemberContactIncognito = bob ?<# ("#team " <> cathIncognito <> "> hey") ] -testMemberContactProfileUpdate :: HasCallStack => FilePath -> IO () +testMemberContactProfileUpdate :: HasCallStack => TestParams -> IO () testMemberContactProfileUpdate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4584,7 +4586,7 @@ testMemberContactProfileUpdate = alice <# "#team kate> hello there" bob <# "#team kate> hello there" -- updated profile -testRecreateMemberContactManyGroups :: HasCallStack => FilePath -> IO () +testRecreateMemberContactManyGroups :: HasCallStack => TestParams -> IO () testRecreateMemberContactManyGroups = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -4651,7 +4653,7 @@ testRecreateMemberContactManyGroups = bob <# "@alice 4" alice <# "bob> 4" -testGroupMsgForward :: HasCallStack => FilePath -> IO () +testGroupMsgForward :: HasCallStack => TestParams -> IO () testGroupMsgForward = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4691,7 +4693,7 @@ setupGroupForwarding3 gName alice bob cath = do void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" -testGroupMsgForwardDeduplicate :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeduplicate :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeduplicate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4728,7 +4730,7 @@ testGroupMsgForwardDeduplicate = cath <#. "#team bob> hi there" cath <# "#team hey team" -testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () +testGroupMsgForwardEdit :: HasCallStack => TestParams -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4751,7 +4753,7 @@ testGroupMsgForwardEdit = cath ##> "/tail #team 1" cath <# "#team bob> hello there [>>]" -testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO () +testGroupMsgForwardReaction :: HasCallStack => TestParams -> IO () testGroupMsgForwardReaction = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4768,7 +4770,7 @@ testGroupMsgForwardReaction = bob <# "#team cath> > bob hi there" bob <## " + 👍" -testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeletion :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeletion = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4783,7 +4785,7 @@ testGroupMsgForwardDeletion = alice <# "#team bob> [marked deleted] hi there" cath <# "#team bob> [marked deleted] hi there" -- TODO show as forwarded -testGroupMsgForwardFile :: HasCallStack => FilePath -> IO () +testGroupMsgForwardFile :: HasCallStack => TestParams -> IO () testGroupMsgForwardFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -4808,7 +4810,7 @@ testGroupMsgForwardFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () +testGroupMsgForwardChangeRole :: HasCallStack => TestParams -> IO () testGroupMsgForwardChangeRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4819,7 +4821,7 @@ testGroupMsgForwardChangeRole = alice <## "#team: cath changed the role of bob from admin to member" bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded -testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO () +testGroupMsgForwardNewMember :: HasCallStack => TestParams -> IO () testGroupMsgForwardNewMember = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -4860,7 +4862,7 @@ testGroupMsgForwardNewMember = "dan (Daniel): member" ] -testGroupMsgForwardLeave :: HasCallStack => FilePath -> IO () +testGroupMsgForwardLeave :: HasCallStack => TestParams -> IO () testGroupMsgForwardLeave = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4872,7 +4874,7 @@ testGroupMsgForwardLeave = alice <## "#team: bob left the group" cath <## "#team: bob left the group" -testGroupHistory :: HasCallStack => FilePath -> IO () +testGroupHistory :: HasCallStack => TestParams -> IO () testGroupHistory = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4916,7 +4918,7 @@ testGroupHistory = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryGroupLink :: HasCallStack => FilePath -> IO () +testGroupHistoryGroupLink :: HasCallStack => TestParams -> IO () testGroupHistoryGroupLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4964,7 +4966,7 @@ testGroupHistoryGroupLink = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryPreferenceOff :: HasCallStack => FilePath -> IO () +testGroupHistoryPreferenceOff :: HasCallStack => TestParams -> IO () testGroupHistoryPreferenceOff = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -5049,7 +5051,7 @@ testGroupHistoryPreferenceOff = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testGroupHistoryHostFile :: HasCallStack => FilePath -> IO () +testGroupHistoryHostFile :: HasCallStack => TestParams -> IO () testGroupHistoryHostFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5088,7 +5090,7 @@ testGroupHistoryHostFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupHistoryMemberFile :: HasCallStack => FilePath -> IO () +testGroupHistoryMemberFile :: HasCallStack => TestParams -> IO () testGroupHistoryMemberFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5127,7 +5129,7 @@ testGroupHistoryMemberFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupHistoryLargeFile :: HasCallStack => FilePath -> IO () +testGroupHistoryLargeFile :: HasCallStack => TestParams -> IO () testGroupHistoryLargeFile = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5185,7 +5187,7 @@ testGroupHistoryLargeFile = where cfg = testCfg {xftpDescrPartSize = 200} -testGroupHistoryMultipleFiles :: HasCallStack => FilePath -> IO () +testGroupHistoryMultipleFiles :: HasCallStack => TestParams -> IO () testGroupHistoryMultipleFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5265,7 +5267,7 @@ testGroupHistoryMultipleFiles = ((0, "hey bob"), Just "./tests/tmp/testfile_alice_1") ] -testGroupHistoryFileCancel :: HasCallStack => FilePath -> IO () +testGroupHistoryFileCancel :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancel = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5320,7 +5322,7 @@ testGroupHistoryFileCancel = bob <## "#team: new member cath is connected" ] -testGroupHistoryFileCancelNoText :: HasCallStack => FilePath -> IO () +testGroupHistoryFileCancelNoText :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancelNoText = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5379,7 +5381,7 @@ testGroupHistoryFileCancelNoText = bob <## "#team: new member cath is connected" ] -testGroupHistoryQuotes :: HasCallStack => FilePath -> IO () +testGroupHistoryQuotes :: HasCallStack => TestParams -> IO () testGroupHistoryQuotes = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5485,7 +5487,7 @@ testGroupHistoryQuotes = ((0, "4"), Just (0, "BOB")) ] -testGroupHistoryDeletedMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryDeletedMessage :: HasCallStack => TestParams -> IO () testGroupHistoryDeletedMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5523,7 +5525,7 @@ testGroupHistoryDeletedMessage = r `shouldContain` [(0, "hello")] r `shouldNotContain` [(0, "hey!")] -testGroupHistoryDisappearingMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryDisappearingMessage :: HasCallStack => TestParams -> IO () testGroupHistoryDisappearingMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5607,7 +5609,7 @@ testGroupHistoryDisappearingMessage = r2 `shouldNotContain` [(0, "2")] r2 `shouldNotContain` [(0, "3")] -testGroupHistoryWelcomeMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryWelcomeMessage :: HasCallStack => TestParams -> IO () testGroupHistoryWelcomeMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5664,7 +5666,7 @@ testGroupHistoryWelcomeMessage = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryUnknownMember :: HasCallStack => FilePath -> IO () +testGroupHistoryUnknownMember :: HasCallStack => TestParams -> IO () testGroupHistoryUnknownMember = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -5732,7 +5734,7 @@ testGroupHistoryUnknownMember = dan #> "#team 3" [alice, cath] *<# "#team dan> 3" -testMembershipProfileUpdateNextGroupMessage :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNextGroupMessage :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateNextGroupMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5830,7 +5832,7 @@ testMembershipProfileUpdateNextGroupMessage = rc <- chat <$> getTermLine cath rc `shouldContain` [(0, "updated profile")] -testMembershipProfileUpdateSameMember :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateSameMember :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateSameMember = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -5888,7 +5890,7 @@ testMembershipProfileUpdateSameMember = rClub <- chat <$> getTermLine bob rClub `shouldNotContain` [(0, "updated profile")] -testMembershipProfileUpdateContactActive :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactActive :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactActive = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -5959,7 +5961,7 @@ testMembershipProfileUpdateContactActive = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testMembershipProfileUpdateContactDeleted :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDeleted :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactDeleted = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6038,7 +6040,7 @@ testMembershipProfileUpdateContactDeleted = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testMembershipProfileUpdateContactDisabled :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDisabled :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactDisabled = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6093,7 +6095,7 @@ testMembershipProfileUpdateContactDisabled = rGrp <- chat <$> getTermLine bob rGrp `shouldContain` [(0, "updated profile")] -testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateNoChangeIgnored = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6132,7 +6134,7 @@ testMembershipProfileUpdateNoChangeIgnored = rGrp <- chat <$> getTermLine bob rGrp `shouldNotContain` [(0, "updated profile")] -testMembershipProfileUpdateContactLinkIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactLinkIgnored :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactLinkIgnored = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6174,7 +6176,7 @@ testMembershipProfileUpdateContactLinkIgnored = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testBlockForAllMarkedBlocked :: HasCallStack => FilePath -> IO () +testBlockForAllMarkedBlocked :: HasCallStack => TestParams -> IO () testBlockForAllMarkedBlocked = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6261,7 +6263,7 @@ testBlockForAllMarkedBlocked = ) bob #$> ("/_get chat #1 count=4", chat, [(1, "1"), (1, "2"), (1, "3"), (1, "4")]) -testBlockForAllFullDelete :: HasCallStack => FilePath -> IO () +testBlockForAllFullDelete :: HasCallStack => TestParams -> IO () testBlockForAllFullDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6341,7 +6343,7 @@ testBlockForAllFullDelete = ) bob #$> ("/_get chat #1 count=4", chat, [(1, "1"), (1, "2"), (1, "3"), (1, "4")]) -testBlockForAllAnotherAdminUnblocks :: HasCallStack => FilePath -> IO () +testBlockForAllAnotherAdminUnblocks :: HasCallStack => TestParams -> IO () testBlockForAllAnotherAdminUnblocks = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6369,7 +6371,7 @@ testBlockForAllAnotherAdminUnblocks = bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) -testBlockForAllBeforeJoining :: HasCallStack => FilePath -> IO () +testBlockForAllBeforeJoining :: HasCallStack => TestParams -> IO () testBlockForAllBeforeJoining = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -6437,7 +6439,7 @@ testBlockForAllBeforeJoining = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testBlockForAllCantRepeat :: HasCallStack => FilePath -> IO () +testBlockForAllCantRepeat :: HasCallStack => TestParams -> IO () testBlockForAllCantRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6483,11 +6485,11 @@ testBlockForAllCantRepeat = bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) -testGroupMemberInactive :: HasCallStack => FilePath -> IO () -testGroupMemberInactive tmp = do +testGroupMemberInactive :: HasCallStack => TestParams -> IO () +testGroupMemberInactive ps = do withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp cfg' opts' "alice" aliceProfile $ \alice -> do - withNewTestChatCfgOpts tmp cfg' opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" @@ -6510,7 +6512,7 @@ testGroupMemberInactive tmp = do threadDelay 1500000 - withTestChatCfgOpts tmp cfg' opts' "bob" $ \bob -> do + withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <# "#team alice> 1" @@ -6553,7 +6555,7 @@ testGroupMemberInactive tmp = do } } -testGroupMemberReports :: HasCallStack => FilePath -> IO () +testGroupMemberReports :: HasCallStack => TestParams -> IO () testGroupMemberReports = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index 594e6e47b0..a5fb31ad77 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -5,6 +5,7 @@ module ChatTests.Local where import ChatClient import ChatTests.ChatList (getChats_) +import ChatTests.DBUtils import ChatTests.Utils import Data.Time (getCurrentTime) import Data.Time.Format.ISO8601 (iso8601Show) @@ -13,7 +14,7 @@ import System.Directory (copyFile, doesFileExist) import System.FilePath (()) import Test.Hspec hiding (it) -chatLocalChatsTests :: SpecWith FilePath +chatLocalChatsTests :: SpecWith TestParams chatLocalChatsTests = do describe "note folders" $ do it "create folders, add notes, read, search" testNotes @@ -26,8 +27,8 @@ chatLocalChatsTests = do it "create multiple messages api" testCreateMulti it "create multiple messages with files" testCreateMultiFiles -testNotes :: FilePath -> IO () -testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testNotes :: TestParams -> IO () +testNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice ##> "/contacts" @@ -55,8 +56,8 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail *" alice <# "* Greetings." -testUserNotes :: FilePath -> IO () -testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUserNotes :: TestParams -> IO () +testUserNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "keep in mind" @@ -73,8 +74,8 @@ testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/_delete item *1 1 internal" alice <## "chat db error: SENoteFolderNotFound {noteFolderId = 1}" -testPreviewsPagination :: FilePath -> IO () -testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPreviewsPagination :: TestParams -> IO () +testPreviewsPagination ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice tsS <- iso8601Show <$> getCurrentTime @@ -91,8 +92,8 @@ testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice - getChats_ alice ("before=" <> tsE <> " count=10") [("*", "last")] getChats_ alice ("before=" <> tsS <> " count=10") [] -testChatPagination :: FilePath -> IO () -testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testChatPagination :: TestParams -> IO () +testChatPagination ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "hello world" @@ -115,8 +116,8 @@ testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice #$> ("/_get chat *1 count=10 search=k-k", chat, [(1, "knock-knock")]) -testFiles :: FilePath -> IO () -testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testFiles :: TestParams -> IO () +testFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- setup createCCNoteFolder alice let files = "./tests/tmp/app_files" @@ -163,7 +164,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail" doesFileExist stored `shouldReturn` False -testOtherFiles :: FilePath -> IO () +testOtherFiles :: TestParams -> IO () testOtherFiles = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -196,16 +197,16 @@ testOtherFiles = where cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} -testCreateMulti :: FilePath -> IO () -testCreateMulti tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testCreateMulti :: TestParams -> IO () +testCreateMulti ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice ##> "/_create *1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}]" alice <# "* test 1" alice <# "* test 2" -testCreateMultiFiles :: FilePath -> IO () -testCreateMultiFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testCreateMultiFiles :: TestParams -> IO () +testCreateMultiFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 36fe576dcb..f12f7276fd 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -8,6 +8,7 @@ module ChatTests.Profiles where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -32,7 +33,7 @@ import Simplex.Messaging.Util (encodeJSON) import System.Directory (copyFile, createDirectoryIfMissing) import Test.Hspec hiding (it) -chatProfileTests :: SpecWith FilePath +chatProfileTests :: SpecWith TestParams chatProfileTests = do describe "user profiles" $ do it "update user profile and notify contacts" testUpdateProfile @@ -101,7 +102,7 @@ chatProfileTests = do it "SimpleX links" testGroupPrefsSimplexLinksForRole it "set user, contact and group UI theme" testSetUITheme -testUpdateProfile :: HasCallStack => FilePath -> IO () +testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -143,7 +144,7 @@ testUpdateProfile = bob <## "use @cat to send messages" ] -testUpdateProfileImage :: HasCallStack => FilePath -> IO () +testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -163,7 +164,7 @@ testUpdateProfileImage = bob <## "use @alice2 to send messages" (bob FilePath -> IO () +testMultiWordProfileNames :: HasCallStack => TestParams -> IO () testMultiWordProfileNames = testChat3 aliceProfile' bobProfile' cathProfile' $ \alice bob cath -> do @@ -236,7 +237,7 @@ testMultiWordProfileNames = cathProfile' = baseProfile {displayName = "Cath Johnson"} baseProfile = Profile {displayName = "", fullName = "", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} -testUserContactLink :: HasCallStack => FilePath -> IO () +testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -266,9 +267,10 @@ testUserContactLink = alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testRetryAcceptingViaContactLink :: HasCallStack => FilePath -> IO () -testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test tmp +testRetryAcceptingViaContactLink :: HasCallStack => TestParams -> IO () +testRetryAcceptingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps where + tmp = tmpPath ps test alice bob = do cLink <- withSmpServer' serverCfg' $ do alice ##> "/ad" @@ -327,7 +329,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile } } -testProfileLink :: HasCallStack => FilePath -> IO () +testProfileLink :: HasCallStack => TestParams -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -408,7 +410,7 @@ testProfileLink = cc <## "quantum resistant end-to-end encryption" cc <## currentChatVRangeInfo -testUserContactLinkAutoAccept :: HasCallStack => FilePath -> IO () +testUserContactLinkAutoAccept :: HasCallStack => TestParams -> IO () testUserContactLinkAutoAccept = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -456,7 +458,7 @@ testUserContactLinkAutoAccept = alice @@@ [("@dan", lastChatFeature), ("@cath", "hey"), ("@bob", "hey")] alice <##> dan -testDeduplicateContactRequests :: HasCallStack => FilePath -> IO () +testDeduplicateContactRequests :: HasCallStack => TestParams -> IO () testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -515,7 +517,7 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testDeduplicateContactRequestsProfileChange :: HasCallStack => FilePath -> IO () +testDeduplicateContactRequestsProfileChange :: HasCallStack => TestParams -> IO () testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -592,7 +594,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile alice @@@ [("@cath", lastChatFeature), ("@robert", "hey")] alice <##> cath -testRejectContactAndDeleteUserContact :: HasCallStack => FilePath -> IO () +testRejectContactAndDeleteUserContact :: HasCallStack => TestParams -> IO () testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/_address 1" @@ -615,7 +617,7 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr cath ##> ("/c " <> cLink) cath <## "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" -testDeleteConnectionRequests :: HasCallStack => FilePath -> IO () +testDeleteConnectionRequests :: HasCallStack => TestParams -> IO () testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -637,7 +639,7 @@ testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ cath ##> ("/c " <> cLink') alice <#? cath -testAutoReplyMessage :: HasCallStack => FilePath -> IO () +testAutoReplyMessage :: HasCallStack => TestParams -> IO () testAutoReplyMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -659,7 +661,7 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $ alice <## "bob (Bob): contact is connected" ] -testAutoReplyMessageInIncognito :: HasCallStack => FilePath -> IO () +testAutoReplyMessageInIncognito :: HasCallStack => TestParams -> IO () testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -684,7 +686,7 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ alice <## "use /i bob to print out this incognito profile again" ] -testBusinessAddress :: HasCallStack => FilePath -> IO () +testBusinessAddress :: HasCallStack => TestParams -> IO () testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice @ Biz"} bobProfile $ \biz alice bob -> do biz ##> "/ad" @@ -737,7 +739,7 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice (alice <# "#bob bob_1> hey there") (biz <# "#bob bob_1> hey there") -testBusinessUpdateProfiles :: HasCallStack => FilePath -> IO () +testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO () testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $ \biz alice bob cath -> do biz ##> "/ad" @@ -866,7 +868,7 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c bob #$> ("/_get chat #1 count=1", chat, [(0, "Voice messages: on")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "Voice messages: on")]) -testPlanAddressOkKnown :: HasCallStack => FilePath -> IO () +testPlanAddressOkKnown :: HasCallStack => TestParams -> IO () testPlanAddressOkKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -899,9 +901,9 @@ testPlanAddressOkKnown = bob <## "contact address: known contact alice" bob <## "use @alice to send messages" -testPlanAddressOwn :: HasCallStack => FilePath -> IO () -testPlanAddressOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanAddressOwn :: HasCallStack => TestParams -> IO () +testPlanAddressOwn ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/ad" cLink <- getContactLink alice True @@ -945,12 +947,12 @@ testPlanAddressOwn tmp = alice ##> ("/c " <> cLink) alice <## "alice_2 (Alice): contact already exists" -testPlanAddressConnecting :: HasCallStack => FilePath -> IO () -testPlanAddressConnecting tmp = do - cLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanAddressConnecting :: HasCallStack => TestParams -> IO () +testPlanAddressConnecting ps = do + cLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/ad" getContactLink alice True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> cLink) @@ -964,14 +966,14 @@ testPlanAddressConnecting tmp = do bob <## "contact address: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do threadDelay 500000 bob <## "alice (Alice): contact is connected" bob @@@ [("@alice", "Audio/video calls: enabled")] @@ -988,12 +990,12 @@ testPlanAddressConnecting tmp = do bob <## "contact address: known contact alice" bob <## "use @alice to send messages" -testPlanAddressConnectingSlow :: HasCallStack => FilePath -> IO () -testPlanAddressConnectingSlow tmp = do - cLink <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testPlanAddressConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanAddressConnectingSlow ps = do + cLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do alice ##> "/ad" getContactLink alice True - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> cLink) @@ -1007,14 +1009,14 @@ testPlanAddressConnectingSlow tmp = do bob <## "contact address: connecting, allowed to reconnect" threadDelay 100000 - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1027,7 +1029,7 @@ testPlanAddressConnectingSlow tmp = do bob ##> ("/c " <> cLink) bob <## "contact address: connecting to contact alice" -testPlanAddressContactDeletedReconnected :: HasCallStack => FilePath -> IO () +testPlanAddressContactDeletedReconnected :: HasCallStack => TestParams -> IO () testPlanAddressContactDeletedReconnected = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1090,7 +1092,7 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" -testPlanAddressContactViaAddress :: HasCallStack => FilePath -> IO () +testPlanAddressContactViaAddress :: HasCallStack => TestParams -> IO () testPlanAddressContactViaAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1154,7 +1156,7 @@ testPlanAddressContactViaAddress = alice <##> bob bob @@@ [("@alice", "hey")] -testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () +testConnectIncognitoInvitationLink :: HasCallStack => TestParams -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/connect incognito" @@ -1228,7 +1230,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi (bob FilePath -> IO () +testConnectIncognitoContactAddress :: HasCallStack => TestParams -> IO () testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -1266,7 +1268,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ (bob FilePath -> IO () +testAcceptContactRequestIncognito :: HasCallStack => TestParams -> IO () testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -1314,7 +1316,7 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil alice `hasContactProfiles` ["alice", "cath", T.pack aliceIncognitoCath] cath `hasContactProfiles` ["cath", T.pack aliceIncognitoCath] -testSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetConnectionIncognito :: HasCallStack => TestParams -> IO () testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/connect" @@ -1337,7 +1339,7 @@ testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] bob `hasContactProfiles` ["bob", T.pack aliceIncognito] -testResetConnectionIncognito :: HasCallStack => FilePath -> IO () +testResetConnectionIncognito :: HasCallStack => TestParams -> IO () testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/_connect 1 incognito=on" @@ -1353,42 +1355,42 @@ testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => FilePath -> IO () -testSetConnectionIncognitoProhibitedDuringNegotiation tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => TestParams -> IO () +testSetConnectionIncognitoProhibitedDuringNegotiation ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/connect" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do threadDelay 250000 alice <## "bob (Bob): contact is connected" alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "alice (Alice): contact is connected" alice <##> bob alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetConnectionIncognitoProhibitedDuringNegotiationSlow :: HasCallStack => FilePath -> IO () -testSetConnectionIncognitoProhibitedDuringNegotiationSlow tmp = do - inv <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testSetConnectionIncognitoProhibitedDuringNegotiationSlow :: HasCallStack => TestParams -> IO () +testSetConnectionIncognitoProhibitedDuringNegotiationSlow ps = do + inv <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/connect" getInvitation alice - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do threadDelay 250000 alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1396,7 +1398,7 @@ testSetConnectionIncognitoProhibitedDuringNegotiationSlow tmp = do alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testConnectionIncognitoUnchangedErrors :: HasCallStack => FilePath -> IO () +testConnectionIncognitoUnchangedErrors :: HasCallStack => TestParams -> IO () testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/connect" @@ -1420,7 +1422,7 @@ testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetResetSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetResetSetConnectionIncognito :: HasCallStack => TestParams -> IO () testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/_connect 1 incognito=off" @@ -1447,7 +1449,7 @@ testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] bob `hasContactProfiles` ["bob", T.pack aliceIncognito] -testJoinGroupIncognito :: HasCallStack => FilePath -> IO () +testJoinGroupIncognito :: HasCallStack => TestParams -> IO () testJoinGroupIncognito = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -1636,7 +1638,7 @@ testJoinGroupIncognito = cath ?#> "@bob_1 ok" bob <# (cathIncognito <> "> ok") -testCantInviteContactIncognito :: HasCallStack => FilePath -> IO () +testCantInviteContactIncognito :: HasCallStack => TestParams -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- alice connected incognito to bob @@ -1660,7 +1662,7 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ -- bob doesn't receive invitation (bob FilePath -> IO () +testCantSeeGlobalPrefsUpdateIncognito :: HasCallStack => TestParams -> IO () testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/c i" @@ -1711,7 +1713,7 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr cath <## "alice updated preferences for you:" cath <## "Full deletion: off (you allow: default (no), contact allows: yes)" -testDeleteContactThenGroupDeletesIncognitoProfile :: HasCallStack => FilePath -> IO () +testDeleteContactThenGroupDeletesIncognitoProfile :: HasCallStack => TestParams -> IO () testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do -- bob connects incognito to alice @@ -1763,7 +1765,7 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr bob <## "#team: you deleted the group" bob `hasContactProfiles` ["bob"] -testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => FilePath -> IO () +testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => TestParams -> IO () testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do -- bob connects incognito to alice @@ -1815,7 +1817,7 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr (bob FilePath -> IO () +testSetAlias :: HasCallStack => TestParams -> IO () testSetAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1826,7 +1828,7 @@ testSetAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob)" -testChangePCCUser :: HasCallStack => FilePath -> IO () +testChangePCCUser :: HasCallStack => TestParams -> IO () testChangePCCUser = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite @@ -1856,7 +1858,7 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ (alice <## "bob (Bob): contact is connected") (bob <## "alisa2: contact is connected") -testChangePCCUserFromIncognito :: HasCallStack => FilePath -> IO () +testChangePCCUserFromIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito @@ -1887,7 +1889,7 @@ testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") -testChangePCCUserAndThenIncognito :: HasCallStack => FilePath -> IO () +testChangePCCUserAndThenIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito @@ -1916,11 +1918,11 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ alice <## ("use /i bob to print out this incognito profile again") ] -testChangePCCUserDiffSrv :: HasCallStack => FilePath -> IO () -testChangePCCUserDiffSrv tmp = do +testChangePCCUserDiffSrv :: HasCallStack => TestParams -> IO () +testChangePCCUserDiffSrv ps = do withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp testCfg testOpts "alice" aliceProfile $ \alice -> do - withNewTestChatCfgOpts tmp testCfg testOpts "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps testCfg testOpts "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps testCfg testOpts "bob" bobProfile $ \bob -> do -- Create a new invite alice ##> "/connect" _ <- getInvitation alice @@ -1962,7 +1964,7 @@ testChangePCCUserDiffSrv tmp = do msgQueueQuota = 2 } -testSetConnectionAlias :: HasCallStack => FilePath -> IO () +testSetConnectionAlias :: HasCallStack => TestParams -> IO () testSetConnectionAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/c" @@ -1980,7 +1982,7 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob) (alias: friend)" -testSetGroupAlias :: HasCallStack => FilePath -> IO () +testSetGroupAlias :: HasCallStack => TestParams -> IO () testSetGroupAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob @@ -1994,7 +1996,7 @@ testSetGroupAlias = testChat2 aliceProfile bobProfile $ alice ##> "/groups" alice <## "#team (2 members)" -testSetContactPrefs :: HasCallStack => FilePath -> IO () +testSetContactPrefs :: HasCallStack => TestParams -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") @@ -2087,7 +2089,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ bob <## "Voice messages: off (you allow: default (yes), contact allows: no)" bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off"), (1, "Voice messages: enabled"), (0, "Voice messages: off")]) -testFeatureOffers :: HasCallStack => FilePath -> IO () +testFeatureOffers :: HasCallStack => TestParams -> IO () testFeatureOffers = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -2106,7 +2108,7 @@ testFeatureOffers = testChat2 aliceProfile bobProfile $ bob <## "Full deletion: off (you allow: default (no), contact allows: no)" bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "offered Full deletion"), (0, "cancelled Full deletion")]) -testUpdateGroupPrefs :: HasCallStack => FilePath -> IO () +testUpdateGroupPrefs :: HasCallStack => TestParams -> IO () testUpdateGroupPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2157,7 +2159,7 @@ testUpdateGroupPrefs = alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on"), (0, "hey"), (1, "hi")]) -testAllowFullDeletionContact :: HasCallStack => FilePath -> IO () +testAllowFullDeletionContact :: HasCallStack => TestParams -> IO () testAllowFullDeletionContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2175,7 +2177,7 @@ testAllowFullDeletionContact = alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (1, "Full deletion: enabled for contact")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hi"), (0, "Full deletion: enabled for you")]) -testAllowFullDeletionGroup :: HasCallStack => FilePath -> IO () +testAllowFullDeletionGroup :: HasCallStack => TestParams -> IO () testAllowFullDeletionGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2201,7 +2203,7 @@ testAllowFullDeletionGroup = alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "hi"), (1, "Full deletion: on")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (0, "Full deletion: on")]) -testProhibitDirectMessages :: HasCallStack => FilePath -> IO () +testProhibitDirectMessages :: HasCallStack => TestParams -> IO () testProhibitDirectMessages = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -2259,7 +2261,7 @@ testProhibitDirectMessages = cc <## "updated group preferences:" cc <## "Direct messages: off" -testEnableTimedMessagesContact :: HasCallStack => FilePath -> IO () +testEnableTimedMessagesContact :: HasCallStack => TestParams -> IO () testEnableTimedMessagesContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2304,7 +2306,7 @@ testEnableTimedMessagesContact = alice <## "bob updated preferences for you:" alice <## "Disappearing messages: enabled (you allow: yes (1 week), contact allows: yes (1 week))" -testEnableTimedMessagesGroup :: HasCallStack => FilePath -> IO () +testEnableTimedMessagesGroup :: HasCallStack => TestParams -> IO () testEnableTimedMessagesGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2354,7 +2356,7 @@ testEnableTimedMessagesGroup = bob <## "updated group preferences:" bob <## "Disappearing messages: on (1 week)" -testTimedMessagesEnabledGlobally :: HasCallStack => FilePath -> IO () +testTimedMessagesEnabledGlobally :: HasCallStack => TestParams -> IO () testTimedMessagesEnabledGlobally = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2378,7 +2380,7 @@ testTimedMessagesEnabledGlobally = alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)")]) -testUpdateMultipleUserPrefs :: HasCallStack => FilePath -> IO () +testUpdateMultipleUserPrefs :: HasCallStack => TestParams -> IO () testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -2405,7 +2407,7 @@ testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi bob"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "hi cath"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) -testGroupPrefsDirectForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsDirectForRole :: HasCallStack => TestParams -> IO () testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath @@ -2471,7 +2473,7 @@ testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danP cc <## "updated group preferences:" cc <## "Direct messages: on for owners" -testGroupPrefsFilesForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsFilesForRole :: HasCallStack => TestParams -> IO () testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") @@ -2506,7 +2508,7 @@ testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ cc <## "updated group preferences:" cc <## "Files and media: on for owners" -testGroupPrefsSimplexLinksForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsSimplexLinksForRole :: HasCallStack => TestParams -> IO () testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath @@ -2542,7 +2544,7 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil cc <## "updated group preferences:" cc <## "SimpleX links: on for owners" -testSetUITheme :: HasCallStack => FilePath -> IO () +testSetUITheme :: HasCallStack => TestParams -> IO () testSetUITheme = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 24178125ee..9dc7a10026 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -9,6 +9,7 @@ module ChatTests.Utils where import ChatClient +import ChatTests.DBUtils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM @@ -72,13 +73,13 @@ danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing, businessProfile :: Profile businessProfile = Profile {displayName = "biz", fullName = "Biz Inc", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} -it :: HasCallStack => String -> (FilePath -> Expectation) -> SpecWith (Arg (FilePath -> Expectation)) +it :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) it name test = Hspec.it name $ \tmp -> timeout t (test tmp) >>= maybe (error "test timed out") pure where t = 90 * 1000000 -xit' :: HasCallStack => String -> (FilePath -> Expectation) -> SpecWith (Arg (FilePath -> Expectation)) +xit' :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) xit' = if os == "linux" then xit else it xit'' :: (HasCallStack, Example a) => String -> a -> SpecWith (Arg a) @@ -96,7 +97,7 @@ skip :: String -> SpecWith a -> SpecWith a skip = before_ . pendingWith -- Bool is pqExpected - see testAddContact -versionTestMatrix2 :: (HasCallStack => Bool -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +versionTestMatrix2 :: (HasCallStack => Bool -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile (runTest True) it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile (runTest False) @@ -106,7 +107,7 @@ versionTestMatrix2 runTest = do it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest False) it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest False) -versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix3 runTest = do it "current" $ testChat3 aliceProfile bobProfile cathProfile runTest it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest @@ -115,46 +116,46 @@ versionTestMatrix3 runTest = do it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest -runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () -runTestCfg2 aliceCfg bobCfg runTest tmp = - withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> +runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () +runTestCfg2 aliceCfg bobCfg runTest ps = + withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> runTest alice bob -runTestCfg3 :: ChatConfig -> ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () -runTestCfg3 aliceCfg bobCfg cathCfg runTest tmp = - withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp cathCfg "cath" cathProfile $ \cath -> +runTestCfg3 :: ChatConfig -> ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +runTestCfg3 aliceCfg bobCfg cathCfg runTest ps = + withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath -withTestChatGroup3Connected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatGroup3Connected tmp dbPrefix action = do - withTestChat tmp dbPrefix $ \cc -> do +withTestChatGroup3Connected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatGroup3Connected ps dbPrefix action = do + withTestChat ps dbPrefix $ \cc -> do cc <## "2 contacts connected (use /cs for the list)" cc <## "#team: connected to server(s)" action cc -withTestChatGroup3Connected' :: HasCallStack => FilePath -> String -> IO () -withTestChatGroup3Connected' tmp dbPrefix = withTestChatGroup3Connected tmp dbPrefix $ \_ -> pure () +withTestChatGroup3Connected' :: HasCallStack => TestParams -> String -> IO () +withTestChatGroup3Connected' ps dbPrefix = withTestChatGroup3Connected ps dbPrefix $ \_ -> pure () -withTestChatContactConnected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnected tmp dbPrefix action = - withTestChat tmp dbPrefix $ \cc -> do +withTestChatContactConnected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatContactConnected ps dbPrefix action = + withTestChat ps dbPrefix $ \cc -> do cc <## "1 contacts connected (use /cs for the list)" action cc -withTestChatContactConnected' :: HasCallStack => FilePath -> String -> IO () -withTestChatContactConnected' tmp dbPrefix = withTestChatContactConnected tmp dbPrefix $ \_ -> pure () +withTestChatContactConnected' :: HasCallStack => TestParams -> String -> IO () +withTestChatContactConnected' ps dbPrefix = withTestChatContactConnected ps dbPrefix $ \_ -> pure () -withTestChatContactConnectedV1 :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnectedV1 tmp dbPrefix action = - withTestChatV1 tmp dbPrefix $ \cc -> do +withTestChatContactConnectedV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatContactConnectedV1 ps dbPrefix action = + withTestChatV1 ps dbPrefix $ \cc -> do cc <## "1 contacts connected (use /cs for the list)" action cc -withTestChatContactConnectedV1' :: HasCallStack => FilePath -> String -> IO () -withTestChatContactConnectedV1' tmp dbPrefix = withTestChatContactConnectedV1 tmp dbPrefix $ \_ -> pure () +withTestChatContactConnectedV1' :: HasCallStack => TestParams -> String -> IO () +withTestChatContactConnectedV1' ps dbPrefix = withTestChatContactConnectedV1 ps dbPrefix $ \_ -> pure () -- | test sending direct messages (<##>) :: HasCallStack => TestCC -> TestCC -> IO () diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e99b61dd5c..41d7280121 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -7,6 +7,7 @@ module MobileTests where +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent.STM import Control.Monad.Except @@ -38,6 +39,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -48,7 +50,7 @@ import System.FilePath (()) import System.IO (utf8) import Test.Hspec hiding (it) -mobileTests :: HasCallStack => SpecWith FilePath +mobileTests :: HasCallStack => SpecWith TestParams mobileTests = do describe "mobile API" $ do runIO $ do @@ -146,9 +148,10 @@ parsedMarkdown = parsedMarkdownTagged #endif -testChatApiNoUser :: FilePath -> IO () -testChatApiNoUser tmp = do - let dbPrefix = tmp "1" +testChatApiNoUser :: TestParams -> IO () +testChatApiNoUser ps = do + let tmp = tmpPath ps + dbPrefix = tmp "1" Right cc <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "myKey" "yesUp" chatSendCmd cc "/u" `shouldReturn` noActiveUser @@ -156,11 +159,12 @@ testChatApiNoUser tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUser chatSendCmd cc "/_start" `shouldReturn` chatStarted -testChatApi :: FilePath -> IO () -testChatApi tmp = do - let dbPrefix = tmp "1" +testChatApi :: TestParams -> IO () +testChatApi ps = do + let tmp = tmpPath ps + dbPrefix = tmp "1" f = dbPrefix <> chatSuffix - Right st <- createChatStore (DBOpts f "myKey" False True) MCYesUp + Right st <- createChatStore (DBOpts f "myKey" False True DB.TQOff) MCYesUp Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" @@ -173,8 +177,9 @@ testChatApi tmp = do chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown -testMediaApi :: HasCallStack => FilePath -> IO () -testMediaApi tmp = do +testMediaApi :: HasCallStack => TestParams -> IO () +testMediaApi ps = do + let tmp = tmpPath ps Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" cc <- newStablePtr c key <- atomically $ C.randomBytes 32 g @@ -187,8 +192,9 @@ testMediaApi tmp = do B.length encrypted `shouldBe` B.length frame' runExceptT (chatDecryptMedia keyStr encrypted) `shouldReturn` Right frame' -testMediaCApi :: HasCallStack => FilePath -> IO () -testMediaCApi tmp = do +testMediaCApi :: HasCallStack => TestParams -> IO () +testMediaCApi ps = do + let tmp = tmpPath ps Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" cc <- newStablePtr c key <- atomically $ C.randomBytes 32 g @@ -216,8 +222,9 @@ instance FromJSON WriteFileResult where instance FromJSON ReadFileResult where parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) -testFileCApi :: FilePath -> FilePath -> IO () -testFileCApi fileName tmp = do +testFileCApi :: FilePath -> TestParams -> IO () +testFileCApi fileName ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp src <- B.readFile "./tests/fixtures/test.pdf" let path = tmp (fileName <> ".pdf") @@ -241,8 +248,9 @@ testFileCApi fileName tmp = do contents `shouldBe` src sz' `shouldBe` len -testMissingFileCApi :: FilePath -> IO () -testMissingFileCApi tmp = do +testMissingFileCApi :: TestParams -> IO () +testMissingFileCApi ps = do + let tmp = tmpPath ps let path = tmp "missing_file" cPath <- newCString path CFArgs key nonce <- atomically . CF.randomArgs =<< C.newRandom @@ -253,8 +261,9 @@ testMissingFileCApi tmp = do err <- peekCAString (ptr `plusPtr` 1) err `shouldContain` "missing_file: openBinaryFile: does not exist" -testFileEncryptionCApi :: FilePath -> FilePath -> IO () -testFileEncryptionCApi fileName tmp = do +testFileEncryptionCApi :: FilePath -> TestParams -> IO () +testFileEncryptionCApi fileName ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp let fromPath = tmp (fileName <> ".source.pdf") copyFile "./tests/fixtures/test.pdf" fromPath @@ -272,8 +281,9 @@ testFileEncryptionCApi fileName tmp = do "" <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' B.readFile toPath' `shouldReturn` src -testMissingFileEncryptionCApi :: FilePath -> IO () -testMissingFileEncryptionCApi tmp = do +testMissingFileEncryptionCApi :: TestParams -> IO () +testMissingFileEncryptionCApi ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp let fromPath = tmp "missing_file.source.pdf" toPath = tmp "missing_file.encrypted.pdf" @@ -293,7 +303,7 @@ testMissingFileEncryptionCApi tmp = do mkCCPtr :: FilePath -> IO (StablePtr ChatController) mkCCPtr tmp = either (error . show) newStablePtr =<< chatMigrateInit (tmp "1") "" "yesUp" -testValidNameCApi :: FilePath -> IO () +testValidNameCApi :: TestParams -> IO () testValidNameCApi _ = do let goodName = "Джон Доу 👍" cName1 <- cChatValidName =<< newCString goodName @@ -301,7 +311,7 @@ testValidNameCApi _ = do cName2 <- cChatValidName =<< newCString " @'Джон' Доу 👍 " peekCString cName2 `shouldReturn` goodName -testChatJsonLengthCApi :: FilePath -> IO () +testChatJsonLengthCApi :: TestParams -> IO () testChatJsonLengthCApi _ = do cInt1 <- cChatJsonLength =<< newCString "Hello!" cInt1 `shouldBe` 6 diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index dd4032e274..f18b097e9c 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -6,6 +6,7 @@ module RemoteTests where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Logger.Simple import qualified Data.Aeson as J @@ -26,7 +27,7 @@ import UnliftIO import UnliftIO.Concurrent import UnliftIO.Directory -remoteTests :: SpecWith FilePath +remoteTests :: SpecWith TestParams remoteTests = describe "Remote" $ do describe "protocol handshake" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False @@ -45,7 +46,7 @@ remoteTests = describe "Remote" $ do -- * Chat commands -remoteHandshakeTest :: HasCallStack => Bool -> FilePath -> IO () +remoteHandshakeTest :: HasCallStack => Bool -> TestParams -> IO () remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" @@ -74,7 +75,7 @@ remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \m mobile ##> "/list remote ctrls" mobile <## "No remote controllers" -remoteHandshakeStoredTest :: HasCallStack => FilePath -> IO () +remoteHandshakeStoredTest :: HasCallStack => TestParams -> IO () remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do logNote "Starting new session" startRemote mobile desktop @@ -95,7 +96,7 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile startRemoteStored mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) -remoteHandshakeDiscoverTest :: HasCallStack => FilePath -> IO () +remoteHandshakeDiscoverTest :: HasCallStack => TestParams -> IO () remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do logNote "Preparing new session" startRemote mobile desktop @@ -105,7 +106,7 @@ remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobi startRemoteDiscover mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) -remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () +remoteHandshakeRejectTest :: HasCallStack => TestParams -> IO () remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do logNote "Starting new session" startRemote mobile desktop @@ -135,7 +136,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil desktop <## "remote host 1 connected" stopMobile mobile desktop -storedBindingsTest :: HasCallStack => FilePath -> IO () +storedBindingsTest :: HasCallStack => TestParams -> IO () storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/set device name My desktop" desktop <## "ok" @@ -166,7 +167,7 @@ storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile deskto -- TODO: more parser tests -remoteMessageTest :: HasCallStack => FilePath -> IO () +remoteMessageTest :: HasCallStack => TestParams -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop contactBob desktop bob @@ -192,7 +193,7 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob threadDelay 1000000 logNote "done" -remoteStoreFileTest :: HasCallStack => FilePath -> IO () +remoteStoreFileTest :: HasCallStack => TestParams -> IO () remoteStoreFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do @@ -322,7 +323,7 @@ remoteStoreFileTest = r `shouldStartWith` "remote host 1 error" r `shouldContain` err -remoteCLIFileTest :: HasCallStack => FilePath -> IO () +remoteCLIFileTest :: HasCallStack => TestParams -> IO () remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) @@ -391,7 +392,7 @@ remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob stopMobile mobile desktop -switchRemoteHostTest :: FilePath -> IO () +switchRemoteHostTest :: TestParams -> IO () switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop contactBob desktop bob @@ -417,7 +418,7 @@ switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \ desktop <## "remote host 1 error: RHEInactive" desktop ##> "/contacts" -indicateRemoteHostTest :: FilePath -> IO () +indicateRemoteHostTest :: TestParams -> IO () indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do connectUsers desktop cath startRemote mobile desktop @@ -441,7 +442,7 @@ indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile c desktop <##> cath cath <##> desktop -multipleProfilesTest :: FilePath -> IO () +multipleProfilesTest :: TestParams -> IO () multipleProfilesTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do connectUsers desktop cath diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 307e715dfb..89a90ec7e5 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -1,19 +1,32 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module SchemaDump where import ChatClient (withTmpFiles) +import ChatTests.DBUtils +import Control.Concurrent.STM import Control.DeepSeq +import qualified Control.Exception as E import Control.Monad (unless, void) -import Data.List (dropWhileEnd) +import Data.List (dropWhileEnd, sort) +import qualified Data.Map.Strict as M import Data.Maybe (fromJust, isJust) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as T +import Database.SQLite.Simple (Query (..)) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store +import Simplex.Messaging.Agent.Store.Common (withConnection) import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Util (ifM, whenM) +import Simplex.Messaging.Util (ifM, tshow, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) import Test.Hspec @@ -40,6 +53,9 @@ appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" appLint :: FilePath appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" +appQueryPlans :: FilePath +appQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt" + testSchema :: FilePath testSchema = "tests/tmp/test_agent_schema.sql" @@ -53,7 +69,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) MCError getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -61,14 +77,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) MCError getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore (DBOpts testDB "" False True) noDownMigrations MCError + Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -120,3 +136,25 @@ getLintFKeyIndexes dbPath lintPath = do void $ readCreateProcess (shell $ "sqlite3 " <> dbPath <> " '.lint fkey-indexes' > " <> lintPath) "" lint <- readFile lintPath lint `deepseq` pure lint + +saveQueryPlans :: SpecWith TestParams +saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {queryStats} -> do + savedPlans <- ifM (doesFileExist appQueryPlans) (T.readFile appQueryPlans) (pure "") + savedPlans `deepseq` pure () + queries <- sort . M.keys <$> readTVarIO queryStats + Right st <- createChatStore (DBOpts testDB "" False True TQOff) MCError + plans' <- withConnection st $ \db -> do + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" + mapM (getQueryPlan db) queries + let savedPlans' = T.unlines plans' + T.writeFile appQueryPlans savedPlans' + savedPlans' `shouldBe` savedPlans + where + getQueryPlan :: DB.Connection -> Query -> IO Text + getQueryPlan db q = + (("Query: " <> fromQuery q) <>) . result <$> E.try (DB.query_ db $ "explain query plan " <> q) + result = \case + Right r -> "\nPlan:\n" <> T.unlines (map planDetail r) + Left (e :: E.SomeException) -> "\nError: " <> tshow e <> "\n" + planDetail :: (Int, Int, Int, Text) -> Text + planDetail (_, _, _, detail) = detail diff --git a/tests/Test.hs b/tests/Test.hs index 042f699d3f..b2b4e7201a 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,9 +1,12 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TupleSections #-} import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient import ChatTests +import ChatTests.DBUtils import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System @@ -21,6 +24,7 @@ import ViewTests #if defined(dbPostgres) import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) #else +import qualified Simplex.Messaging.TMap as TM import MobileTests import SchemaDump import WebRTCTests @@ -29,6 +33,9 @@ import WebRTCTests main :: IO () main = do setLogLevel LogError +#if !defined(dbPostgres) + queryStats <- TM.emptyIO +#endif withGlobalLogging logCfg . hspec #if defined(dbPostgres) . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) @@ -48,9 +55,11 @@ main = do describe "Message batching" batchingTests describe "Operators" operatorTests describe "Random servers" randomServersTests - around testBracket #if defined(dbPostgres) + around testBracket . after_ (dropAllSchemasExceptSystem testDBConnectInfo) +#else + around (testBracket queryStats) #endif $ do #if !defined(dbPostgres) @@ -60,8 +69,15 @@ main = do xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests describe "Remote session" remoteTests +#if !defined(dbPostgres) + xdescribe'' "Save query plans" saveQueryPlans +#endif where - testBracket test = withSmpServer $ tmpBracket test +#if defined(dbPostgres) + testBracket test = withSmpServer $ tmpBracket $ test . TestParams +#else + testBracket queryStats test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, queryStats} +#endif tmpBracket test = do t <- getSystemTime let ts = show (systemSeconds t) <> show (systemNanoseconds t) From d4eedd5886f009e8caeb5999aac6eceec6ecd712 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 24 Jan 2025 10:33:36 +0000 Subject: [PATCH 005/289] core: update 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 fab2f2f9c7..7e54de3718 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: 268a1303acbad1644f81cd3b2def5754e5e2c052 + tag: eda9e36c826f8e9f9d984e4d25612e7a6abddace source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 95b8215de8..01c8732e88 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."23189753751dc52046865ce2d992335495020e91" = "0f1c0bfjqwycsb2nkphhbdiv77zx6q47jdigk7bjal1c4rfla8gy"; + "https://github.com/simplex-chat/simplexmq.git"."eda9e36c826f8e9f9d984e4d25612e7a6abddace" = "05z805iwnbffdkllga3pp7hfbs13zsipymgxqmlcpjrnq9g9i97l"; "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 d86e6b35bedc668085fac610ced2d198777f3b02 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 24 Jan 2025 17:49:31 +0400 Subject: [PATCH 006/289] test: track agent query plans (#5571) --- .../SQLite/Migrations/agent_query_plans.txt | 1103 +++++++++++++++++ tests/ChatClient.hs | 7 +- tests/ChatTests/DBUtils/SQLite.hs | 3 +- tests/SchemaDump.hs | 54 +- tests/Test.hs | 10 +- 5 files changed, 1155 insertions(+), 22 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt new file mode 100644 index 0000000000..c4f5007040 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -0,0 +1,1103 @@ +Query: + SELECT + f.snd_file_id, f.snd_file_entity_id, f.user_id, f.num_recipients, f.prefix_path, + c.snd_file_chunk_id, c.chunk_no, c.chunk_offset, c.chunk_size, c.digest, + r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE r.snd_file_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.snd_file_chunk_id = ? + +Plan: +SEARCH r USING INDEX idx_snd_file_chunk_replicas_snd_file_chunk_id (snd_file_chunk_id=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT command_id FROM commands + WHERE conn_id = ? AND host = ? AND port = ? AND failed = 0 + ORDER BY created_at ASC, command_id ASC + LIMIT 1 + +Plan: +SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?) + +Query: + SELECT command_id FROM commands + WHERE conn_id = ? AND host IS NULL AND port IS NULL AND failed = 0 + ORDER BY created_at ASC, command_id ASC + LIMIT 1 + +Plan: +SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?) + +Query: + SELECT m.msg_type, m.msg_flags, m.msg_body, m.pq_encryption, m.internal_ts, s.retry_int_slow, s.retry_int_fast + FROM messages m + JOIN snd_messages s ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id + WHERE m.conn_id = ? AND m.internal_id = ? + +Plan: +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) + +Query: + SELECT rcv_file_chunk_id, chunk_no, chunk_size, digest, tmp_path + FROM rcv_file_chunks + WHERE rcv_file_id = ? + +Plan: +SEARCH rcv_file_chunks USING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) + +Query: + SELECT snd_file_chunk_id, chunk_no, chunk_offset, chunk_size, digest + FROM snd_file_chunks + WHERE snd_file_id = ? + +Plan: +SEARCH snd_file_chunks USING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) + +Query: + SELECT + f.rcv_file_id, f.rcv_file_entity_id, f.user_id, c.rcv_file_chunk_id, c.chunk_no, c.chunk_size, c.digest, f.tmp_path, c.tmp_path, + r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries, + f.approved_relays, f.redirect_entity_id + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE r.rcv_file_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.rcv_file_chunk_id = ? + +Plan: +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chunk_id=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT c.corr_id, cs.user_id, c.command + FROM commands c + JOIN connections cs USING (conn_id) + WHERE c.command_id = ? + +Plan: +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cs USING PRIMARY KEY (conn_id=?) + +Query: + SELECT internal_id + FROM snd_message_deliveries + WHERE conn_id = ? AND snd_queue_id = ? AND failed = 0 AND internal_id <= ? + ORDER BY internal_id ASC + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=? AND failed=? AND internal_id= ? + AND failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_deleted_snd_chunk_replicas_xftp_server_id (xftp_server_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.rcv_file_chunk_replica_id, f.rcv_file_id + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE s.xftp_host = ? AND s.xftp_port = ? AND s.xftp_key_hash = ? + AND r.received = 0 AND r.replica_number = 1 + AND f.status = ? AND f.deleted = 0 AND f.created_at >= ? + AND f.failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_pending (received=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.snd_file_chunk_replica_id, f.snd_file_id + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE s.xftp_host = ? AND s.xftp_port = ? AND s.xftp_key_hash = ? + AND r.replica_status = ? AND r.replica_number = 1 + AND (f.status = ? OR f.status = ?) AND f.deleted = 0 AND f.created_at >= ? + AND f.failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_snd_file_chunk_replicas_pending (replica_status=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, deleted, redirect_id, redirect_entity_id, redirect_size, redirect_digest + FROM rcv_files + WHERE rcv_file_id = ? + +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT rcv_file_id + FROM rcv_files + WHERE status IN (?,?) AND deleted = 0 AND created_at >= ? + AND failed = 0 + ORDER BY created_at ASC LIMIT 1 + +Plan: +SEARCH rcv_files USING INDEX idx_rcv_files_status_created_at (status=? AND created_at>?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, digest, prefix_path, key, nonce, status, deleted, redirect_size, redirect_digest + FROM snd_files + WHERE snd_file_id = ? + +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT snd_file_id + FROM snd_files + WHERE status IN (?,?,?) AND deleted = 0 AND created_at >= ? + AND failed = 0 + ORDER BY created_at ASC LIMIT 1 + +Plan: +SEARCH snd_files USING INDEX idx_snd_files_status_created_at (status=? AND created_at>?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT MAX(internal_id) + FROM messages + WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND internal_ts < ? + +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=?) + +Query: + SELECT user_id FROM users u + WHERE u.user_id = ? + AND u.deleted = ? + AND NOT EXISTS (SELECT c.conn_id FROM connections c WHERE c.user_id = u.user_id) + +Plan: +SEARCH u USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH c USING COVERING INDEX idx_connections_user (user_id=?) + +Query: + INSERT INTO conn_confirmations + (confirmation_id, conn_id, sender_key, e2e_snd_pub_key, ratchet_state, sender_conn_info, smp_reply_queues, smp_client_version, accepted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0); + +Plan: + +Query: + INSERT INTO conn_invitations + (invitation_id, contact_conn_id, cr_invitation, recipient_conn_info, accepted) VALUES (?, ?, ?, ?, 0); + +Plan: + +Query: + INSERT INTO snd_file_chunk_replica_recipients + (snd_file_chunk_replica_id, rcv_replica_id, rcv_replica_key) + VALUES (?,?,?) + +Plan: + +Query: + SELECT + r.internal_id, m.internal_ts, r.broker_id, r.broker_ts, r.external_snd_id, r.integrity, r.internal_hash, + m.msg_type, m.msg_body, m.pq_encryption, s.internal_id, s.rcpt_status, r.user_ack + FROM rcv_messages r + JOIN messages m ON r.conn_id = m.conn_id AND r.internal_id = m.internal_id + JOIN connections c ON r.conn_id = c.conn_id AND c.last_internal_msg_id = r.internal_id + LEFT JOIN snd_messages s ON s.conn_id = r.conn_id AND s.rcpt_internal_id = r.internal_id + WHERE r.conn_id = ? AND r.broker_id = ? + +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH r USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) LEFT-JOIN + +Query: + SELECT + r.internal_id, m.internal_ts, r.broker_id, r.broker_ts, r.external_snd_id, r.integrity, r.internal_hash, + m.msg_type, m.msg_body, m.pq_encryption, s.internal_id, s.rcpt_status, r.user_ack + FROM rcv_messages r + JOIN messages m ON r.conn_id = m.conn_id AND r.internal_id = m.internal_id + LEFT JOIN snd_messages s ON s.conn_id = r.conn_id AND s.rcpt_internal_id = r.internal_id + WHERE r.conn_id = ? AND r.internal_id = ? + +Plan: +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH r USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) LEFT-JOIN + +Query: + SELECT + r.user_id, r.replica_id, r.replica_key, r.chunk_digest, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM deleted_snd_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.deleted_snd_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, + last_external_snd_msg_id, deleted, ratchet_sync_state, pq_support + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM deleted_snd_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_deleted_snd_chunk_replicas_pending (created_at>?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE r.received = 0 AND r.replica_number = 1 + AND f.status = ? AND f.deleted = 0 AND f.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_pending (received=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE r.replica_status = ? AND r.replica_number = 1 + AND (f.status = ? OR f.status = ?) AND f.deleted = 0 AND f.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_snd_file_chunk_replicas_pending (replica_status=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT c.host, c.port, COALESCE(c.server_key_hash, s.key_hash) + FROM commands c + LEFT JOIN servers s ON s.host = c.host AND s.port = c.port + WHERE conn_id = ? + +Plan: +SEARCH c USING INDEX idx_commands_conn_id (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) LEFT-JOIN +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT ntf_host, ntf_port, ntf_key_hash + FROM ntf_tokens_to_delete + +Plan: +SCAN ntf_tokens_to_delete +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT confirmation_id, ratchet_state, own_conn_info, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version + FROM conn_confirmations + WHERE conn_id = ? AND accepted = 1; + +Plan: +SEARCH conn_confirmations USING INDEX idx_conn_confirmations_conn_id (conn_id=?) + +Query: + SELECT conn_id, ratchet_state, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version + FROM conn_confirmations + WHERE confirmation_id = ?; + +Plan: +SEARCH conn_confirmations USING PRIMARY KEY (confirmation_id=?) + +Query: + SELECT contact_conn_id, cr_invitation, recipient_conn_info, own_conn_info, accepted + FROM conn_invitations + WHERE invitation_id = ? + AND accepted = 0 + +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: + SELECT last_internal_msg_id, last_internal_rcv_msg_id, last_external_snd_msg_id, last_rcv_msg_hash + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT last_internal_msg_id, last_internal_snd_msg_id, last_snd_msg_hash + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT s.internal_id, m.msg_type, s.internal_hash, s.rcpt_internal_id, s.rcpt_status + FROM snd_messages s + JOIN messages m ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id + WHERE s.conn_id = ? AND s.internal_snd_id = ? + +Plan: +SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) + +Query: + DELETE FROM conn_confirmations + WHERE conn_id = ? + +Plan: +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) + +Query: + INSERT INTO connections + (user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, pq_support, duplex_handshake) VALUES (?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages + (conn_id, internal_id, internal_ts, internal_rcv_id, internal_snd_id, msg_type, msg_flags, msg_body, pq_encryption) + VALUES (?,?,?,?,?,?,?,?,?); + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: + INSERT INTO messages + (conn_id, internal_id, internal_ts, internal_rcv_id, internal_snd_id, msg_type, msg_flags, msg_body, pq_encryption) + VALUES + (?,?,?,?,?,?,?,?,?); + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: + INSERT INTO ratchets + (conn_id, ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (conn_id) DO UPDATE SET + ratchet_state = EXCLUDED.ratchet_state, + x3dh_priv_key_1 = NULL, + x3dh_priv_key_2 = NULL, + x3dh_pub_key_1 = EXCLUDED.x3dh_pub_key_1, + x3dh_pub_key_2 = EXCLUDED.x3dh_pub_key_2, + pq_priv_kem = NULL, + pq_pub_kem = EXCLUDED.pq_pub_kem + +Plan: + +Query: + INSERT INTO ratchets (conn_id, ratchet_state) + VALUES (?, ?) + ON CONFLICT (conn_id) DO UPDATE SET + ratchet_state = ?, + x3dh_priv_key_1 = NULL, + x3dh_priv_key_2 = NULL, + x3dh_pub_key_1 = NULL, + x3dh_pub_key_2 = NULL, + pq_priv_kem = NULL, + pq_pub_kem = NULL + +Plan: + +Query: + INSERT INTO rcv_messages + ( conn_id, rcv_queue_id, internal_rcv_id, internal_id, external_snd_id, + broker_id, broker_ts, + internal_hash, external_prev_snd_hash, integrity) + VALUES + (?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_id=? AND internal_rcv_id=?) + +Query: + INSERT INTO rcv_queues + (host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, snd_id, snd_secure, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + +Plan: + +Query: + INSERT INTO snd_file_chunk_replicas + (snd_file_chunk_id, replica_number, xftp_server_id, replica_id, replica_key, replica_status) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO snd_messages + ( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash) + VALUES + (?,?,?,?,?) + +Plan: +SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_id=? AND internal_snd_id=?) + +Query: + INSERT INTO snd_queues + (host, port, snd_id, snd_secure, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, + status, snd_queue_id, snd_primary, replace_snd_queue_id, smp_client_version, server_key_hash) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (host, port, snd_id) DO UPDATE SET + host=EXCLUDED.host, + port=EXCLUDED.port, + snd_id=EXCLUDED.snd_id, + snd_secure=EXCLUDED.snd_secure, + conn_id=EXCLUDED.conn_id, + snd_public_key=EXCLUDED.snd_public_key, + snd_private_key=EXCLUDED.snd_private_key, + e2e_pub_key=EXCLUDED.e2e_pub_key, + e2e_dh_secret=EXCLUDED.e2e_dh_secret, + status=EXCLUDED.status, + snd_queue_id=EXCLUDED.snd_queue_id, + snd_primary=EXCLUDED.snd_primary, + replace_snd_queue_id=EXCLUDED.replace_snd_queue_id, + smp_client_version=EXCLUDED.smp_client_version, + server_key_hash=EXCLUDED.server_key_hash + +Plan: + +Query: + SELECT rcv_replica_id, rcv_replica_key + FROM snd_file_chunk_replica_recipients + WHERE snd_file_chunk_replica_id = ? + +Plan: +SEARCH snd_file_chunk_replica_recipients USING INDEX idx_snd_file_chunk_replica_recipients_snd_file_chunk_replica_id (snd_file_chunk_replica_id=?) + +Query: + UPDATE conn_confirmations + SET accepted = 1, + own_conn_info = ? + WHERE confirmation_id = ? + +Plan: +SEARCH conn_confirmations USING PRIMARY KEY (confirmation_id=?) + +Query: + UPDATE conn_invitations + SET accepted = 1, + own_conn_info = ? + WHERE invitation_id = ? + +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: + UPDATE connections + SET last_external_snd_msg_id = ?, + last_rcv_msg_hash = ? + WHERE conn_id = ? + AND last_internal_rcv_msg_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_internal_msg_id = ?, + last_internal_rcv_msg_id = ? + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_internal_msg_id = ?, + last_internal_snd_msg_id = ? + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_snd_msg_hash = ? + WHERE conn_id = ? + AND last_internal_snd_msg_id = ?; + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE ratchets + SET x3dh_priv_key_1 = ?, x3dh_priv_key_2 = ?, pq_priv_kem = ? + WHERE conn_id = ? + +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE rcv_queues + SET deleted = 1 + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET e2e_dh_secret = ?, + status = ?, + smp_client_version = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET status = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET switch_status = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE snd_queues + SET status = ? + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: + UPDATE snd_queues + SET switch_status = ? + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: + SELECT + c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.snd_secure, + q.snd_public_key, q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, + q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version + FROM snd_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_snd_queue_id (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.deleted = 0 +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_rcv_queue_id (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 0 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 1 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.rcv_queue_id = ? AND q.deleted = 0 +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 0 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: DELETE FROM commands WHERE command_id = ? +Plan: +SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) + +Query: DELETE FROM conn_invitations WHERE contact_conn_id = ? AND invitation_id = ? +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: DELETE FROM connections WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=?) +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=?) +SEARCH commands USING COVERING INDEX idx_commands_conn_id (conn_id=?) +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH conn_invitations USING COVERING INDEX idx_conn_invitations_contact_conn_id (contact_conn_id=?) +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) +SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?) +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: DELETE FROM deleted_snd_chunk_replicas WHERE deleted_snd_chunk_replica_id = ? +Plan: +SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: DELETE FROM messages WHERE conn_id = ? AND internal_id = ?; +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) +SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: DELETE FROM ntf_subscriptions WHERE conn_id = ? +Plan: +SEARCH ntf_subscriptions USING PRIMARY KEY (conn_id=?) + +Query: DELETE FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH skipped_messages USING COVERING INDEX idx_skipped_messages_conn_id (conn_id=?) + +Query: DELETE FROM rcv_files WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?) + +Query: DELETE FROM rcv_queues WHERE conn_id = ? AND rcv_queue_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: DELETE FROM snd_files WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_file_chunks USING COVERING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) + +Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? AND internal_id = ? +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM snd_queues WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM users WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_user_id (user_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_user_id (user_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_user_id (user_id=?) +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + +Query: INSERT INTO commands (host, port, corr_id, conn_id, command_tag, command, server_key_hash, created_at) VALUES (?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO deleted_snd_chunk_replicas (user_id, xftp_server_id, replica_id, replica_key, chunk_digest) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO encrypted_rcv_message_hashes (conn_id, hash) VALUES (?,?) +Plan: + +Query: INSERT INTO processed_ratchet_key_hashes (conn_id, hash) VALUES (?,?) +Plan: + +Query: INSERT INTO ratchets (conn_id, x3dh_priv_key_1, x3dh_priv_key_2, pq_priv_kem) VALUES (?, ?, ?, ?) +Plan: + +Query: INSERT INTO rcv_file_chunk_replicas (replica_number, rcv_file_chunk_id, xftp_server_id, replica_id, replica_key) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO rcv_file_chunks (rcv_file_id, chunk_no, chunk_size, digest) VALUES (?,?,?,?) +Plan: + +Query: INSERT INTO rcv_files (rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, redirect_id, redirect_entity_id, redirect_digest, redirect_size, approved_relays) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO servers (host, port, key_hash) VALUES (?,?,?) +Plan: + +Query: INSERT INTO skipped_messages (conn_id, header_key, msg_n, msg_key) VALUES (?, ?, ?, ?) +Plan: + +Query: INSERT INTO snd_file_chunks (snd_file_id, chunk_no, chunk_offset, chunk_size, digest) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO snd_files (snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, prefix_path, key, nonce, status, redirect_size, redirect_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO snd_message_deliveries (conn_id, snd_queue_id, internal_id) VALUES (?, ?, ?) +Plan: + +Query: INSERT INTO users (user_id) VALUES (1) +Plan: + +Query: INSERT INTO users DEFAULT VALUES +Plan: + +Query: INSERT INTO xftp_servers (xftp_host, xftp_port, xftp_key_hash) VALUES (?,?,?) +Plan: + +Query: SELECT 1 FROM encrypted_rcv_message_hashes WHERE conn_id = ? AND hash = ? LIMIT 1 +Plan: +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=? AND hash=?) + +Query: SELECT 1 FROM processed_ratchet_key_hashes WHERE conn_id = ? AND hash = ? LIMIT 1 +Plan: +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=? AND hash=?) + +Query: SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND failed = 0 LIMIT 1 +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) + +Query: SELECT DISTINCT conn_id FROM snd_message_deliveries WHERE failed = 0 +Plan: +SCAN snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired + +Query: SELECT conn_id FROM connections WHERE user_id = ? +Plan: +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + +Query: SELECT count(*) FROM snd_message_deliveries WHERE conn_id = ? AND internal_id = ? AND failed = 0 +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) + +Query: SELECT deleted FROM snd_files WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT header_key, msg_n, msg_key FROM skipped_messages WHERE conn_id = ? +Plan: +SEARCH skipped_messages USING INDEX idx_skipped_messages_conn_id (conn_id=?) + +Query: SELECT key_hash FROM servers WHERE host = ? AND port = ? +Plan: +SEARCH servers USING PRIMARY KEY (host=? AND port=?) + +Query: SELECT last_insert_rowid() +Plan: +SCAN CONSTANT ROW + +Query: SELECT ratchet_state FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT rcpt_internal_id, rcpt_status FROM snd_messages WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH snd_messages USING PRIMARY KEY (conn_id=?) + +Query: SELECT rcv_file_id FROM rcv_files WHERE rcv_file_entity_id = ? +Plan: +SEARCH rcv_files USING COVERING INDEX sqlite_autoindex_rcv_files_1 (rcv_file_entity_id=?) + +Query: SELECT rcv_file_id FROM rcv_files WHERE redirect_id = ? +Plan: +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?) + +Query: SELECT rcv_queue_id FROM rcv_queues WHERE conn_id = ? AND host = ? AND port = ? AND snd_id = ? +Plan: +SEARCH rcv_queues USING INDEX sqlite_autoindex_rcv_queues_2 (host=? AND port=? AND snd_id=?) + +Query: SELECT rcv_queue_id FROM rcv_queues WHERE conn_id = ? ORDER BY rcv_queue_id DESC LIMIT 1 +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: SELECT rcv_queue_id, broker_id FROM rcv_messages WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH rcv_messages USING PRIMARY KEY (conn_id=?) + +Query: SELECT snd_file_id FROM snd_files WHERE snd_file_entity_id = ? +Plan: +SEARCH snd_files USING COVERING INDEX idx_snd_files_snd_file_entity_id (snd_file_entity_id=?) + +Query: SELECT snd_queue_id FROM snd_queues WHERE conn_id = ? AND host = ? AND port = ? AND snd_id = ? +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: SELECT snd_queue_id FROM snd_queues WHERE conn_id = ? ORDER BY snd_queue_id DESC LIMIT 1 +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) + +Query: SELECT started_at, servers_stats FROM servers_stats WHERE servers_stats_id = 1 +Plan: +SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_id FROM users WHERE user_id = ? AND deleted = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT x3dh_priv_key_1, x3dh_priv_key_2, pq_priv_kem FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT xftp_server_id FROM xftp_servers WHERE xftp_host = ? AND xftp_port = ? AND xftp_key_hash = ? +Plan: +SEARCH xftp_servers USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) + +Query: UPDATE connections SET deleted = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET deleted_at_wait_delivery = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET enable_ntfs = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET pq_support = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET ratchet_sync_state = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +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=?) + +Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_file_chunks SET tmp_path = ?, updated_at = ? WHERE rcv_file_chunk_id = ? +Plan: +SEARCH rcv_file_chunks USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET deleted = 1, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET key = ?, nonce = ?, chunk_size = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET tmp_path = NULL, error = ?, status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET tmp_path = NULL, status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +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 = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: UPDATE rcv_queues SET rcv_primary = ? WHERE conn_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: UPDATE rcv_queues SET rcv_primary = ?, replace_rcv_queue_id = ? WHERE conn_id = ? AND rcv_queue_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: UPDATE servers_stats SET servers_stats = ?, updated_at = ? WHERE servers_stats_id = 1 +Plan: +SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_file_chunk_replicas SET replica_status = ?, updated_at = ? WHERE snd_file_chunk_replica_id = ? +Plan: +SEARCH snd_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET deleted = 1, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET prefix_path = NULL, status = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET status = ?, digest = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET status = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_messages SET rcpt_internal_id = ?, rcpt_status = ? WHERE conn_id = ? AND internal_snd_id = ? +Plan: +SEARCH snd_messages USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) + +Query: UPDATE snd_messages SET retry_int_slow = ?, retry_int_fast = ? WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: UPDATE snd_queues SET snd_primary = ? WHERE conn_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) + +Query: UPDATE snd_queues SET snd_primary = ?, replace_snd_queue_id = ? WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) + +Query: UPDATE users SET deleted = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d330510d62..1a04badc3e 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -70,6 +70,7 @@ import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) #else import Data.ByteArray (ScrubbedBytes) import qualified Data.Map.Strict as M +import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.Common (withConnection) import System.FilePath (()) #endif @@ -324,8 +325,10 @@ stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore} uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent #if !defined(dbPostgres) - stats <- withConnection chatStore $ readTVarIO . DB.slow - atomically $ modifyTVar' (queryStats ps) $ M.unionWith combineStats stats + chatStats <- withConnection chatStore $ readTVarIO . DB.slow + atomically $ modifyTVar' (chatQueryStats ps) $ M.unionWith combineStats chatStats + agentStats <- withConnection (agentClientStore smpAgent) $ readTVarIO . DB.slow + atomically $ modifyTVar' (agentQueryStats ps) $ M.unionWith combineStats agentStats #endif closeDBStore chatStore threadDelay 200000 diff --git a/tests/ChatTests/DBUtils/SQLite.hs b/tests/ChatTests/DBUtils/SQLite.hs index cdde43b015..b66e5ac851 100644 --- a/tests/ChatTests/DBUtils/SQLite.hs +++ b/tests/ChatTests/DBUtils/SQLite.hs @@ -6,5 +6,6 @@ import Simplex.Messaging.TMap (TMap) data TestParams = TestParams { tmpPath :: FilePath, - queryStats :: TMap Query SlowQueryStats + chatQueryStats :: TMap Query SlowQueryStats, + agentQueryStats :: TMap Query SlowQueryStats } diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 89a90ec7e5..a6ce19da87 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -20,12 +20,13 @@ import qualified Data.Text.IO as T import Database.SQLite.Simple (Query (..)) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store +import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) import Simplex.Messaging.Agent.Store.Common (withConnection) -import Simplex.Messaging.Agent.Store.Interface -import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Interface import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Util (ifM, tshow, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) @@ -34,6 +35,9 @@ import Test.Hspec testDB :: FilePath testDB = "tests/tmp/test_chat.db" +testAgentDB :: FilePath +testAgentDB = "tests/tmp/test_agent.db" + appSchema :: FilePath appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" @@ -53,8 +57,11 @@ appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" appLint :: FilePath appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" -appQueryPlans :: FilePath -appQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt" +appChatQueryPlans :: FilePath +appChatQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt" + +appAgentQueryPlans :: FilePath +appAgentQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt" testSchema :: FilePath testSchema = "tests/tmp/test_agent_schema.sql" @@ -138,18 +145,35 @@ getLintFKeyIndexes dbPath lintPath = do lint `deepseq` pure lint saveQueryPlans :: SpecWith TestParams -saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {queryStats} -> do - savedPlans <- ifM (doesFileExist appQueryPlans) (T.readFile appQueryPlans) (pure "") - savedPlans `deepseq` pure () - queries <- sort . M.keys <$> readTVarIO queryStats - Right st <- createChatStore (DBOpts testDB "" False True TQOff) MCError - plans' <- withConnection st $ \db -> do - DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" - mapM (getQueryPlan db) queries - let savedPlans' = T.unlines plans' - T.writeFile appQueryPlans savedPlans' - savedPlans' `shouldBe` savedPlans +saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryStats, agentQueryStats} -> do + (chatSavedPlans, chatSavedPlans') <- + updatePlans + appChatQueryPlans + chatQueryStats + (createChatStore (DBOpts testDB "" False True TQOff) MCError) + (`DB.execute_` "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)") + (agentSavedPlans, agentSavedPlans') <- + updatePlans + appAgentQueryPlans + agentQueryStats + (createAgentStore (DBOpts testAgentDB "" False True TQOff) MCError) + (const $ pure ()) + chatSavedPlans' `shouldBe` chatSavedPlans + agentSavedPlans' `shouldBe` agentSavedPlans + removeFile testDB + removeFile testAgentDB where + updatePlans plansFile statsSel createStore prepareStore = do + savedPlans <- ifM (doesFileExist plansFile) (T.readFile plansFile) (pure "") + savedPlans `deepseq` pure () + queries <- sort . M.keys <$> readTVarIO statsSel + Right st <- createStore + plans' <- withConnection st $ \db -> do + void $ prepareStore db + mapM (getQueryPlan db) queries + let savedPlans' = T.unlines plans' + T.writeFile plansFile savedPlans' + pure (savedPlans, savedPlans') getQueryPlan :: DB.Connection -> Query -> IO Text getQueryPlan db q = (("Query: " <> fromQuery q) <>) . result <$> E.try (DB.query_ db $ "explain query plan " <> q) diff --git a/tests/Test.hs b/tests/Test.hs index b2b4e7201a..42dc5a0524 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -34,7 +34,8 @@ main :: IO () main = do setLogLevel LogError #if !defined(dbPostgres) - queryStats <- TM.emptyIO + chatQueryStats <- TM.emptyIO + agentQueryStats <- TM.emptyIO #endif withGlobalLogging logCfg . hspec #if defined(dbPostgres) @@ -59,7 +60,7 @@ main = do around testBracket . after_ (dropAllSchemasExceptSystem testDBConnectInfo) #else - around (testBracket queryStats) + around (testBracket chatQueryStats agentQueryStats) #endif $ do #if !defined(dbPostgres) @@ -73,10 +74,11 @@ main = do xdescribe'' "Save query plans" saveQueryPlans #endif where -#if defined(dbPostgres) +#if defined(dbPostgres) testBracket test = withSmpServer $ tmpBracket $ test . TestParams #else - testBracket queryStats test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, queryStats} + testBracket chatQueryStats agentQueryStats test = + withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats} #endif tmpBracket test = do t <- getSystemTime From f9a4445e1ac1c7c70e4080c868d62014680f9437 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 25 Jan 2025 14:18:24 +0000 Subject: [PATCH 007/289] core: batch connection deletion events (#5573) * core: batch connection deletion events * simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 41 +++++++++----------------- src/Simplex/Chat/Library/Subscriber.hs | 10 ++++--- src/Simplex/Chat/View.hs | 10 ++----- 5 files changed, 24 insertions(+), 41 deletions(-) diff --git a/cabal.project b/cabal.project index 7e54de3718..733afb32be 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: eda9e36c826f8e9f9d984e4d25612e7a6abddace + tag: b3c8358a43b7a15ba38e0a0385813172d47989c7 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 01c8732e88..46ee4fc4e3 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."eda9e36c826f8e9f9d984e4d25612e7a6abddace" = "05z805iwnbffdkllga3pp7hfbs13zsipymgxqmlcpjrnq9g9i97l"; + "https://github.com/simplex-chat/simplexmq.git"."b3c8358a43b7a15ba38e0a0385813172d47989c7" = "173f9hji6a5d6wkyys3y6yxv42ycray6b8dmwiswwnjyrnasdq6k"; "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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ac4b50ed9f..4ff29d6d6a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -824,8 +824,8 @@ data ChatResponse | CRContactDisabled {user :: User, contact :: Contact} | CRConnectionDisabled {connectionEntity :: ConnectionEntity} | CRConnectionInactive {connectionEntity :: ConnectionEntity, inactive :: Bool} - | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} - | CRAgentConnDeleted {agentConnId :: AgentConnId} + | CRAgentRcvQueuesDeleted {deletedRcvQueues :: NonEmpty DeletedRcvQueue} + | CRAgentConnsDeleted {agentConnIds :: NonEmpty AgentConnId} | CRAgentUserDeleted {agentUserId :: Int64} | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} @@ -836,6 +836,14 @@ data ChatResponse | CRCustomChatResponse {user_ :: Maybe User, response :: Text} deriving (Show) +data DeletedRcvQueue = DeletedRcvQueue + { agentConnId :: AgentConnId, + server :: SMPServer, + agentQueueId :: AgentQueueId, + agentError_ :: Maybe AgentErrorType + } + deriving (Show) + -- some of these can only be used as command responses allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case @@ -874,8 +882,8 @@ logResponseToFile = \case CRHostConnected {} -> True CRHostDisconnected {} -> True CRConnectionDisabled {} -> True - CRAgentRcvQueueDeleted {} -> True - CRAgentConnDeleted {} -> True + CRAgentRcvQueuesDeleted {} -> True + CRAgentConnsDeleted {} -> True CRAgentUserDeleted {} -> True CRChatCmdError {} -> True CRChatError {} -> True @@ -1597,6 +1605,8 @@ $(JQ.deriveJSON defaultJSON ''SwitchProgress) $(JQ.deriveJSON defaultJSON ''RatchetSyncProgress) +$(JQ.deriveJSON defaultJSON ''DeletedRcvQueue) + $(JQ.deriveJSON defaultJSON ''ServerAddress) $(JQ.deriveJSON defaultJSON ''ParsedServerAddress) @@ -1609,29 +1619,6 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery) #endif --- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where --- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) - --- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where --- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) - --- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where --- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) --- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) - --- instance FromJSON AUserProtoServers where --- parseJSON v = J.withObject "AUserProtoServers" parse v --- where --- parse o = do --- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" --- case userProtocol p of --- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v --- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p - --- instance ToJSON AUserProtoServers where --- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s --- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) $(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 4c95375f73..e55e160df0 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -92,10 +92,12 @@ smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () -processAgentMessage _ connId (DEL_RCVQ srv qId err_) = - toView $ CRAgentRcvQueueDeleted (AgentConnId connId) srv (AgentQueueId qId) err_ -processAgentMessage _ connId DEL_CONN = - toView $ CRAgentConnDeleted (AgentConnId connId) +processAgentMessage _ _ (DEL_RCVQS delQs) = + toView $ CRAgentRcvQueuesDeleted $ L.map rcvQ delQs + where + rcvQ (connId, server, rcvId, err_) = DeletedRcvQueue (AgentConnId connId) server (AgentQueueId rcvId) err_ +processAgentMessage _ _ (DEL_CONNS connIds) = + toView $ CRAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = toView $ CRChatError Nothing $ ChatErrorAgent e Nothing processAgentMessage corrId connId msg = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1d578dea24..21cd9a6636 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -435,14 +435,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactDisabled u c -> ttyUser u ["[" <> ttyContact' c <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] CRConnectionDisabled entity -> viewConnectionEntityDisabled entity CRConnectionInactive entity inactive -> viewConnectionEntityInactive entity inactive - CRAgentRcvQueueDeleted acId srv aqId err_ -> - [ ("completed deleting rcv queue, agent connection id: " <> sShow acId) - <> (", server: " <> sShow srv) - <> (", agent queue id: " <> sShow aqId) - <> maybe "" (\e -> ", error: " <> sShow e) err_ - | logLevel <= CLLInfo - ] - CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo] + CRAgentRcvQueuesDeleted delQs -> ["completed deleting rcv queues: " <> sShow (length delQs) | logLevel <= CLLInfo] + CRAgentConnsDeleted acIds -> ["completed deleting connections: " <> sShow (length acIds) | logLevel <= CLLInfo] CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e From 56eaf1284069059a2df68a4bb5ced0b2ed30c0d4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 26 Jan 2025 19:46:43 +0000 Subject: [PATCH 008/289] media: SimpleX logos (#5577) * media: SimpleX logos * typo --- ...istance-signal-double-ratchet-algorithm.md | 2 +- blog/images/20240314-comparison.jpg | Bin 142707 -> 148527 bytes media-logos/README.md | 5 +++++ media-logos/simplex-app-icon-dark.png | Bin 0 -> 144630 bytes media-logos/simplex-app-icon-light.png | Bin 0 -> 71432 bytes media-logos/simplex-logo-dark.png | Bin 0 -> 23606 bytes media-logos/simplex-logo-light.png | Bin 0 -> 17601 bytes media-logos/simplex-symbol-dark.png | Bin 0 -> 18527 bytes media-logos/simplex-symbol-dark.svg | 10 ++++++++++ media-logos/simplex-symbol-light.png | Bin 0 -> 19866 bytes media-logos/simplex-symbol-light.svg | 15 +++++++++++++++ 11 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 media-logos/README.md create mode 100644 media-logos/simplex-app-icon-dark.png create mode 100644 media-logos/simplex-app-icon-light.png create mode 100644 media-logos/simplex-logo-dark.png create mode 100644 media-logos/simplex-logo-light.png create mode 100644 media-logos/simplex-symbol-dark.png create mode 100644 media-logos/simplex-symbol-dark.svg create mode 100644 media-logos/simplex-symbol-light.png create mode 100644 media-logos/simplex-symbol-light.svg diff --git a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md index 13a514c175..55158130f8 100644 --- a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md +++ b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md @@ -164,7 +164,7 @@ The main objective here is to establish the framework for comparing the security 2 Post-quantum cryptography is available in beta version, as opt-in only for direct conversations. See below how it will be rolled-out further. Some columns are marked with a yellow checkmark: -- when messages are padded, but not to a fixed size. +- when messages are padded, but not to a fixed size (Briar pads messages to the size rounded up to 1024 bytes, Signal - to 160 bytes). - when repudiation does not include client-server connection. In case of Cwtch it appears that the presence of cryptographic signatures compromises repudiation (deniability), but it needs to be clarified. - when 2-factor key exchange is optional (via security code verification). - when post-quantum cryptography is only added to the initial key agreement and does not protect break-in recovery. diff --git a/blog/images/20240314-comparison.jpg b/blog/images/20240314-comparison.jpg index 5ff22be0052c3f080a382af4aef961d207b5a44c..815922aa7a5ce341bd02a50bebe23be1cfa2a6f8 100644 GIT binary patch literal 148527 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%0A}KtEl1zv$sk^*F3KLwheG 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 z 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

Z$nDI7+EZ`)Fq-dX?-z3PJTE!M0jp1M2n* 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

;Kwejj1ojlLDx_YB-=;Ep+9)Q*n0Rfb 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 Kv+TNE%>M`di>(R( literal 0 HcmV?d00001 diff --git a/media-logos/simplex-logo-dark.png b/media-logos/simplex-logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..98c1f500e95f8885f6c0f97961adeec539322a48 GIT binary patch literal 23606 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 HMMnJ(-+x5# literal 0 HcmV?d00001 diff --git a/media-logos/simplex-logo-light.png b/media-logos/simplex-logo-light.png new file mode 100644 index 0000000000000000000000000000000000000000..6edecb0dd9bf90547ee53d6f9587eb15ee5833a5 GIT binary patch literal 17601 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 B{Br;R literal 0 HcmV?d00001 diff --git a/media-logos/simplex-symbol-dark.png b/media-logos/simplex-symbol-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c561422bdbd441af89597ac5ebe54741a8e36bb0 GIT binary patch literal 18527 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 literal 0 HcmV?d00001 diff --git a/media-logos/simplex-symbol-dark.svg b/media-logos/simplex-symbol-dark.svg new file mode 100644 index 0000000000..fa598acf3d --- /dev/null +++ b/media-logos/simplex-symbol-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media-logos/simplex-symbol-light.png b/media-logos/simplex-symbol-light.png new file mode 100644 index 0000000000000000000000000000000000000000..865f37bfc8450a1d52a183956fd82ea0bfefd68e GIT binary patch literal 19866 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

{|PPIDA7l8DfoSd~~dfJ!dw z0#h)hu`CYCtN&?0PCX!oV%0wlVE>9(v4Wg3EI>CG}$qhXu+xf^_ zgva6U*4Ox#hVa14X&%ivT;;$SvSZ;0&5~EgrfKuDZNH1E(1$2_QX2ncq1cq zPT=5KrxME+P(#t{#^YU>+AjGUR~aVK-mh@%8wj%Ur!BASmO9=K@)DmD){O~%jS o3*OmVQL)$f7@k&Qskkoq|MjCxEJLaof&c&j07*qoM6N<$f+Gq|!2kdN literal 0 HcmV?d00001 diff --git a/media-logos/simplex-symbol-light.svg b/media-logos/simplex-symbol-light.svg new file mode 100644 index 0000000000..d8b5951a0b --- /dev/null +++ b/media-logos/simplex-symbol-light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + From 1306df81e4639720498e80be61308b14b5e7b1cb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 07:50:58 +0000 Subject: [PATCH 009/289] core: role for full delete preference (#5572) * core: role for full delete preference * fix --- src/Simplex/Chat/Library/Commands.hs | 23 ++++++++++++----------- src/Simplex/Chat/Library/Subscriber.hs | 6 +++--- src/Simplex/Chat/Types/Preferences.hs | 15 ++++++++++----- tests/ChatTests/Groups.hs | 24 ++++++++++++++++++++++-- tests/ChatTests/Utils.hs | 20 +++++++++++++++++++- 5 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bf568f7946..587f948941 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -678,7 +678,7 @@ processChatCommand' vr = \case let msgIds = itemsMsgIds items events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items Nothing + delGroupChatItems user gInfo items False CTLocal -> do (nf, items) <- getCommandLocalChatItems user chatId itemIds deleteLocalCIs user nf items True False @@ -706,7 +706,7 @@ processChatCommand' vr = \case let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items (Just membership) + delGroupChatItems user gInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items = @@ -2707,15 +2707,16 @@ processChatCommand' vr = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> CM ChatResponse - delGroupChatItems user gInfo items byGroupMember = do + delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> CM ChatResponse + delGroupChatItems user gInfo@GroupInfo {membership} items moderation = do deletedTs <- liftIO getCurrentTime - forM_ byGroupMember $ \byMember -> do - ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci byMember deletedTs) - unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) - if groupFeatureAllowed SGFFullDelete gInfo - then deleteGroupCIs user gInfo items True False byGroupMember deletedTs - else markGroupCIsDeleted user gInfo items True byGroupMember deletedTs + 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 False (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 updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> @@ -3919,7 +3920,7 @@ chatCommandP = "/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), - "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayNameP <*> (A.space *> strP)), + "/set delete #" *> (SetGroupFeatureRole (AGFR SGFFullDelete) <$> displayNameP <*> _strP <*> optional memberRole), "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayNameP <*> optional (A.space *> strP)), "/set delete " *> (SetUserFeature (ACF SCFFullDelete) <$> strP), "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayNameP <*> _strP <*> optional memberRole), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e55e160df0..abef8b579b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1742,7 +1742,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = live' = fromMaybe False live_ ExtMsgContent content fInv_ itemTTL live_ = mcExtMsgContent mc createBlockedByAdmin - | groupFeatureAllowed SGFFullDelete gInfo = do + | groupFeatureAllowed SGFFullDelete gInfo = do -- ignores member role when blocked by admin ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvBlocked Nothing timed' False ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs groupMsgToView gInfo ci' @@ -1754,7 +1754,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem - | groupFeatureAllowed SGFFullDelete gInfo = do + | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt groupMsgToView gInfo ci' @@ -1854,7 +1854,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = a delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse delete cci byGroupMember - | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs + | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs | otherwise = markGroupCIsDeleted user gInfo [cci] False byGroupMember brokerTs archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 07e32e7d56..001dd27172 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -377,7 +377,7 @@ defaultGroupPrefs = FullGroupPreferences { timedMessages = TimedMessagesGroupPreference {enable = FEOff, ttl = Just 86400}, directMessages = DirectMessagesGroupPreference {enable = FEOff, role = Nothing}, - fullDelete = FullDeleteGroupPreference {enable = FEOff}, + fullDelete = FullDeleteGroupPreference {enable = FEOn, role = Just GRModerator}, reactions = ReactionsGroupPreference {enable = FEOn}, voice = VoiceGroupPreference {enable = FEOn, role = Nothing}, files = FilesGroupPreference {enable = FEOn, role = Nothing}, @@ -392,7 +392,7 @@ businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice} = defaultBusinessGroupPrefs { timedMessages = Just TimedMessagesGroupPreference {enable = maybe FEOff enableFeature timedMessages, ttl = maybe Nothing prefParam timedMessages}, - fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete}, + fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete, role = Just GRModerator}, reactions = Just ReactionsGroupPreference {enable = maybe FEOn enableFeature reactions}, voice = Just VoiceGroupPreference {enable = maybe FEOff enableFeature voice, role = Nothing} } @@ -407,7 +407,7 @@ defaultBusinessGroupPrefs = GroupPreferences { timedMessages = Just $ TimedMessagesGroupPreference FEOff Nothing, directMessages = Just $ DirectMessagesGroupPreference FEOff Nothing, - fullDelete = Just $ FullDeleteGroupPreference FEOff, + fullDelete = Just $ FullDeleteGroupPreference FEOn (Just GRModerator), reactions = Just $ ReactionsGroupPreference FEOn, voice = Just $ VoiceGroupPreference FEOff Nothing, files = Just $ FilesGroupPreference FEOn Nothing, @@ -493,7 +493,7 @@ data DirectMessagesGroupPreference = DirectMessagesGroupPreference deriving (Eq, Show) data FullDeleteGroupPreference = FullDeleteGroupPreference - {enable :: GroupFeatureEnabled} + {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) data ReactionsGroupPreference = ReactionsGroupPreference @@ -569,7 +569,7 @@ instance GroupFeatureI 'GFFullDelete where type GroupFeaturePreference 'GFFullDelete = FullDeleteGroupPreference sGroupFeature = SGFFullDelete groupPrefParam _ = Nothing - groupPrefRole _ = Nothing + groupPrefRole FullDeleteGroupPreference {role} = role instance GroupFeatureI 'GFReactions where type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference @@ -612,6 +612,9 @@ instance GroupFeatureNoRoleI 'GFHistory instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role) +instance HasField "role" FullDeleteGroupPreference (Maybe GroupMemberRole) where + hasField p@FullDeleteGroupPreference {role} = (\r -> p {role = r}, role) + instance HasField "role" VoiceGroupPreference (Maybe GroupMemberRole) where hasField p@VoiceGroupPreference {role} = (\r -> p {role = r}, role) @@ -623,6 +626,8 @@ instance HasField "role" SimplexLinksGroupPreference (Maybe GroupMemberRole) whe instance GroupFeatureRoleI 'GFDirectMessages +instance GroupFeatureRoleI 'GFFullDelete + instance GroupFeatureRoleI 'GFVoice instance GroupFeatureRoleI 'GFFiles diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6042d19195..4bf5003a31 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1249,6 +1249,7 @@ testGroupMessageDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 -- alice, bob: msg id 5, cath: msg id 4 (after group invitations & group events) alice #> "#team hello!" @@ -1260,7 +1261,7 @@ testGroupMessageDelete = msgItemId1 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId1 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=1", chat, [(0, "connected")]) + alice #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (1, "Full deletion: off")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) @@ -1286,7 +1287,7 @@ testGroupMessageDelete = msgItemId2 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId2 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=1", chat', [((0, "connected"), Nothing)]) + alice #$> ("/_get chat #1 count=2", chat', [((0, "connected"), Nothing), ((1, "Full deletion: off"), Nothing)]) bob #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((1, "hi alic"), Just (0, "hello!"))]) cath #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((0, "hi alic"), Just (0, "hello!"))]) @@ -1333,6 +1334,7 @@ testGroupMessageDeleteMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1369,6 +1371,7 @@ testGroupMessageDeleteMultipleManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob ##> "/set receipts all off" bob <## "ok" @@ -1606,6 +1609,9 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" + alice ##> "/set delete #team off" + alice <## "updated group preferences:" + alice <## "Full deletion: off" addMember "team" alice bob GRAdmin bob ##> "/j team" concurrentlyN_ @@ -1676,6 +1682,7 @@ testGroupModerate = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ [ alice <## "#team: you changed the role of cath from admin to member", @@ -1707,6 +1714,7 @@ testGroupModerateOwn = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob threadDelay 1000000 alice #> "#team hello" bob <# "#team alice> hello" @@ -1721,6 +1729,7 @@ testGroupModerateMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1756,6 +1765,7 @@ testGroupModerateFullDelete = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ [ alice <## "#team: you changed the role of cath from admin to member", @@ -1794,6 +1804,7 @@ testGroupDelayedModeration ps = do withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember @@ -1840,6 +1851,7 @@ testGroupDelayedModerationFullDelete ps = do withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember @@ -4775,6 +4787,7 @@ testGroupMsgForwardDeletion = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" @@ -5492,6 +5505,7 @@ testGroupHistoryDeletedMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob alice #> "#team hello" bob <# "#team alice> hello" @@ -6181,6 +6195,7 @@ testBlockForAllMarkedBlocked = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 @@ -6268,6 +6283,7 @@ testBlockForAllFullDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/set delete #team on" alice <## "updated group preferences:" @@ -6348,6 +6364,7 @@ testBlockForAllAnotherAdminUnblocks = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -6376,6 +6393,7 @@ testBlockForAllBeforeJoining = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -6444,6 +6462,7 @@ testBlockForAllCantRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/unblock for all #team bob" alice <## "bad chat command: already unblocked" @@ -6560,6 +6579,7 @@ testGroupMemberReports = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "jokes" alice bob cath + disableFullDeletion3 "jokes" alice bob cath alice ##> "/mr jokes bob moderator" concurrentlyN_ [ alice <## "#jokes: you changed the role of bob from admin to moderator", diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9dc7a10026..9cb8439500 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -300,7 +300,7 @@ groupFeatures'' dir = [ ((dir, e2eeInfoNoPQStr), Nothing, Nothing), ((dir, "Disappearing messages: off"), Nothing, Nothing), ((dir, "Direct messages: on"), Nothing, Nothing), - ((dir, "Full deletion: off"), Nothing, Nothing), + ((dir, "Full deletion: on for moderators"), Nothing, Nothing), ((dir, "Message reactions: on"), Nothing, Nothing), ((dir, "Voice messages: on"), Nothing, Nothing), ((dir, "Files and media: on"), Nothing, Nothing), @@ -632,6 +632,16 @@ createGroup2' gName cc1 cc2 doConnectUsers = do (cc1 <## ("#" <> gName <> ": " <> name2 <> " joined the group")) (cc2 <## ("#" <> gName <> ": you joined the group")) +disableFullDeletion2 :: HasCallStack => String -> TestCC -> TestCC -> IO () +disableFullDeletion2 gName cc1 cc2 = do + cc1 ##> ("/set delete #" <> gName <> " off") + cc1 <## "updated group preferences:" + cc1 <## "Full deletion: off" + name1 <- userName cc1 + cc2 <## (name1 <> " updated group #" <> gName <> ":") + cc2 <## "updated group preferences:" + cc2 <## "Full deletion: off" + createGroup3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () createGroup3 gName cc1 cc2 cc3 = do createGroup2 gName cc1 cc2 @@ -652,6 +662,14 @@ createGroup3 gName cc1 cc2 cc3 = do cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] +disableFullDeletion3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () +disableFullDeletion3 gName cc1 cc2 cc3 = do + disableFullDeletion2 gName cc1 cc2 + name1 <- userName cc1 + cc3 <## (name1 <> " updated group #" <> gName <> ":") + cc3 <## "updated group preferences:" + cc3 <## "Full deletion: off" + create2Groups3 :: HasCallStack => String -> String -> TestCC -> TestCC -> TestCC -> IO () create2Groups3 gName1 gName2 cc1 cc2 cc3 = do createGroup3 gName1 cc1 cc2 cc3 From 62895a72b568a8335bfe79c3e76d86616677feeb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 19:00:19 +0000 Subject: [PATCH 010/289] ui: translations (#5575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Catalan) Currently translated at 100.0% (2220 of 2220 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2220 of 2220 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2220 of 2220 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Korean) Currently translated at 7.2% (140 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/ * Translated using Weblate (Korean) Currently translated at 7.9% (154 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/ * Translated using Weblate (Italian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Arabic) Currently translated at 99.9% (2220 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Korean) Currently translated at 21.0% (407 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/ * Translated using Weblate (German) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Japanese) Currently translated at 85.8% (1907 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/uk/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2222 of 2222 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2222 of 2222 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2245 of 2245 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 99.1% (2227 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2245 of 2245 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% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Spanish) Currently translated at 98.9% (2221 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Italian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Bulgarian) Currently translated at 76.0% (1708 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.4% (1783 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.5% (1785 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.7% (1790 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.8% (1793 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 84.0% (1887 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 84.7% (1903 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 84.9% (1908 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.0% (1909 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.1% (1911 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.3% (1915 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.3% (1916 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.4% (1918 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 75.0% (1452 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/bg/ * Translated using Weblate (Bulgarian) Currently translated at 91.5% (2055 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Bulgarian) Currently translated at 77.5% (1500 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/bg/ * Translated using Weblate (Bulgarian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Vietnamese) Currently translated at 63.4% (1425 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Japanese) Currently translated at 62.7% (1215 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ja/ * Translated using Weblate (Vietnamese) Currently translated at 64.4% (1446 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Japanese) Currently translated at 62.7% (1214 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ja/ * Translated using Weblate (Japanese) Currently translated at 85.0% (1909 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Turkish) Currently translated at 92.5% (2077 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Vietnamese) Currently translated at 65.4% (1470 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Vietnamese) Currently translated at 66.6% (1497 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 67.8% (1524 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Romanian) Currently translated at 32.6% (733 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ro/ * Translated using Weblate (Vietnamese) Currently translated at 68.9% (1547 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2248 of 2248 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% (2248 of 2248 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 73.2% (1646 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Russian) Currently translated at 99.9% (2246 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Turkish) Currently translated at 96.3% (1865 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/tr/ * Translated using Weblate (Turkish) Currently translated at 92.5% (2080 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2248 of 2248 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 75.8% (1706 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (German) Currently translated at 100.0% (2249 of 2249 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% (2249 of 2249 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2249 of 2249 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2249 of 2249 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Japanese) Currently translated at 86.0% (1937 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2250 of 2250 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% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 77.1% (1736 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 78.4% (1766 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Polish) Currently translated at 91.6% (2061 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Polish) Currently translated at 93.3% (1806 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (German) Currently translated at 99.8% (2273 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Czech) Currently translated at 86.9% (1980 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 78.6% (1791 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 78.8% (1794 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Italian) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Vietnamese) Currently translated at 78.9% (1796 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * 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 (German) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2271 of 2271 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% (2271 of 2271 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2271 of 2271 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% (2271 of 2271 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 80.2% (1822 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2277 of 2277 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% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Dutch) Currently translated at 99.9% (2276 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (German) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1935 of 1935 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% (2277 of 2277 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 81.2% (1849 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Croatian) Currently translated at 4.4% (101 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Croatian) Currently translated at 0.8% (16 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hr/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Vietnamese) Currently translated at 82.5% (1880 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2282 of 2282 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% (2282 of 2282 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% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Indonesian) Currently translated at 98.5% (2249 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2288 of 2288 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 99.9% (2287 of 2288 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.9% (2287 of 2288 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% (2288 of 2288 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% (2288 of 2288 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% (2288 of 2288 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% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 83.4% (1910 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Italian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Spanish) Currently translated at 99.7% (2282 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (German) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (French) Currently translated at 90.7% (2077 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (French) Currently translated at 90.9% (2080 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (French) Currently translated at 93.0% (2128 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 93.0% (2128 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 84.8% (1941 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Croatian) Currently translated at 8.5% (196 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Croatian) Currently translated at 12.8% (293 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Czech) Currently translated at 60.4% (1170 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/ * Translated using Weblate (Croatian) Currently translated at 16.8% (386 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Dutch) Currently translated at 99.8% (2284 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (French) Currently translated at 93.2% (2133 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Japanese) Currently translated at 84.7% (1938 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Croatian) Currently translated at 22.6% (518 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Croatian) Currently translated at 2.5% (49 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hr/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Vietnamese) Currently translated at 86.1% (1971 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Croatian) Currently translated at 32.6% (748 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (French) Currently translated at 97.5% (2231 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 90.4% (2069 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Croatian) Currently translated at 34.8% (798 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Polish) Currently translated at 94.4% (1827 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Vietnamese) Currently translated at 87.4% (2001 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (French) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Polish) Currently translated at 91.7% (2099 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Vietnamese) Currently translated at 88.8% (2032 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Croatian) Currently translated at 37.0% (847 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Polish) Currently translated at 93.1% (2131 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Vietnamese) Currently translated at 90.0% (2061 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 91.4% (2092 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 92.7% (2122 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 75.8% (1736 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hant/ * Translated using Weblate (Japanese) Currently translated at 84.7% (1939 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Czech) Currently translated at 86.5% (1981 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 90.4% (2070 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Lithuanian) Currently translated at 75.0% (1718 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/lt/ * Translated using Weblate (Finnish) Currently translated at 63.2% (1447 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fi/ * Translated using Weblate (Hebrew) Currently translated at 81.0% (1856 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Thai) Currently translated at 55.3% (1268 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/th/ * Translated using Weblate (Persian) Currently translated at 78.1% (1788 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fa/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Dutch) Currently translated at 99.9% (2288 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (German) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2289 of 2289 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Polish) Currently translated at 94.5% (1829 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Italian) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (German) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 93.6% (2152 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2297 of 2297 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% (2297 of 2297 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% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 94.9% (2182 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * ios: process localizations * android: fix translations * android: fix apostrophes --------- Co-authored-by: dtalens Co-authored-by: summoner001 Co-authored-by: Bezruchenko Simon Co-authored-by: 이상은 Co-authored-by: Random Co-authored-by: 大王叫我来巡山 Co-authored-by: jonnysemon Co-authored-by: mlanp Co-authored-by: Miyu Sakatsuki Co-authored-by: Максим Горпиніч Co-authored-by: Rafi Co-authored-by: M1K4 Co-authored-by: fran secs Co-authored-by: No name Co-authored-by: elgratea Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: ikyhtr Co-authored-by: goknarbahceli Co-authored-by: Shorten Age Co-authored-by: Andrea Andre Co-authored-by: gfbdrgng Co-authored-by: Kaanito Co-authored-by: Jester Hodl Co-authored-by: ZerFEr Co-authored-by: zenobit Co-authored-by: Hosted Weblate Co-authored-by: Ghost of Sparta Co-authored-by: Nenad Co-authored-by: Eraorahan Co-authored-by: Retis2025 Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: Igor Julliano Co-authored-by: Jan Kowal Co-authored-by: Anonymous --- .../bg.xcloc/Localized Contents/bg.xliff | 391 ++- .../cs.xcloc/Localized Contents/cs.xliff | 331 ++- .../de.xcloc/Localized Contents/de.xliff | 365 ++- .../en.xcloc/Localized Contents/en.xliff | 398 ++- .../es.xcloc/Localized Contents/es.xliff | 393 ++- .../fi.xcloc/Localized Contents/fi.xliff | 323 ++- .../fr.xcloc/Localized Contents/fr.xliff | 453 +++- .../hr.xcloc/Localized Contents/hr.xliff | 132 +- .../hu.xcloc/Localized Contents/hu.xliff | 713 +++-- .../it.xcloc/Localized Contents/it.xliff | 328 ++- .../ja.xcloc/Localized Contents/ja.xliff | 368 ++- .../ko.xcloc/Localized Contents/ko.xliff | 1441 ++++++++++- .../nl.xcloc/Localized Contents/nl.xliff | 337 ++- .../pl.xcloc/Localized Contents/pl.xliff | 362 ++- .../ru.xcloc/Localized Contents/ru.xliff | 323 ++- .../th.xcloc/Localized Contents/th.xliff | 323 ++- .../tr.xcloc/Localized Contents/tr.xliff | 382 ++- .../uk.xcloc/Localized Contents/uk.xliff | 351 ++- .../Localized Contents/zh-Hans.xliff | 323 ++- .../SimpleX NSE/fr.lproj/Localizable.strings | 20 +- .../ios/SimpleX SE/de.lproj/InfoPlist.strings | 2 +- .../SimpleX SE/de.lproj/Localizable.strings | 18 +- .../SimpleX SE/hu.lproj/Localizable.strings | 10 +- apps/ios/bg.lproj/Localizable.strings | 186 +- apps/ios/cs.lproj/Localizable.strings | 46 +- apps/ios/de.lproj/Localizable.strings | 48 +- apps/ios/es.lproj/Localizable.strings | 96 +- apps/ios/fi.lproj/Localizable.strings | 22 +- apps/ios/fr.lproj/Localizable.strings | 398 ++- apps/ios/hu.lproj/Localizable.strings | 404 +-- apps/ios/it.lproj/Localizable.strings | 37 +- apps/ios/ja.lproj/Localizable.strings | 113 +- apps/ios/nl.lproj/Localizable.strings | 40 +- apps/ios/pl.lproj/Localizable.strings | 135 +- apps/ios/ru.lproj/Localizable.strings | 26 +- apps/ios/th.lproj/Localizable.strings | 22 +- apps/ios/tr.lproj/Localizable.strings | 203 +- apps/ios/uk.lproj/Localizable.strings | 110 +- apps/ios/zh-Hans.lproj/Localizable.strings | 26 +- .../commonMain/resources/MR/ar/strings.xml | 265 +- .../commonMain/resources/MR/base/strings.xml | 2 +- .../commonMain/resources/MR/bg/strings.xml | 604 ++++- .../commonMain/resources/MR/ca/strings.xml | 2297 +++++++++++++++++ .../commonMain/resources/MR/cs/strings.xml | 5 +- .../commonMain/resources/MR/de/strings.xml | 123 +- .../commonMain/resources/MR/es/strings.xml | 171 +- .../commonMain/resources/MR/fa/strings.xml | 2 +- .../commonMain/resources/MR/fi/strings.xml | 2 +- .../commonMain/resources/MR/fr/strings.xml | 323 ++- .../commonMain/resources/MR/hr/strings.xml | 853 +++++- .../commonMain/resources/MR/hu/strings.xml | 548 ++-- .../commonMain/resources/MR/in/strings.xml | 976 ++++++- .../commonMain/resources/MR/it/strings.xml | 93 +- .../commonMain/resources/MR/iw/strings.xml | 2 +- .../commonMain/resources/MR/ja/strings.xml | 50 +- .../commonMain/resources/MR/ko/strings.xml | 42 +- .../commonMain/resources/MR/lt/strings.xml | 2 +- .../commonMain/resources/MR/nl/strings.xml | 102 +- .../commonMain/resources/MR/pl/strings.xml | 101 +- .../resources/MR/pt-rBR/strings.xml | 40 +- .../commonMain/resources/MR/ro/strings.xml | 109 +- .../commonMain/resources/MR/ru/strings.xml | 50 +- .../commonMain/resources/MR/th/strings.xml | 2 +- .../commonMain/resources/MR/tr/strings.xml | 18 +- .../commonMain/resources/MR/uk/strings.xml | 264 +- .../commonMain/resources/MR/vi/strings.xml | 861 +++++- .../resources/MR/zh-rCN/strings.xml | 97 +- .../resources/MR/zh-rTW/strings.xml | 2 +- 68 files changed, 16092 insertions(+), 1913 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml 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 4aa1f2213f..048ea3990b 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -132,6 +136,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -194,6 +199,10 @@ %d сек. time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропуснато(и) съобщение(я) @@ -264,11 +273,6 @@ %lld нови езици на интерфейса No comment provided by engineer. - - %lld second(s) - %lld секунда(и) - No comment provided by engineer. - %lld seconds %lld секунди @@ -462,7 +466,8 @@ 1 day 1 ден - time interval + delete after time + time interval 1 hour @@ -477,15 +482,22 @@ 1 month 1 месец - time interval + delete after time + time interval 1 week 1 седмица - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + Еднократен линк No comment provided by engineer. @@ -568,10 +580,12 @@ About operators + За операторите No comment provided by engineer. Accent + Акцент No comment provided by engineer. @@ -583,6 +597,7 @@ Accept conditions + Приеми условията No comment provided by engineer. @@ -603,18 +618,22 @@ Accepted conditions + Приети условия No comment provided by engineer. Acknowledged + Потвърден No comment provided by engineer. Acknowledgement errors + Грешки при потвърждението No comment provided by engineer. Active connections + Активни връзки No comment provided by engineer. @@ -624,6 +643,11 @@ Add friends + Добави приятели + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -643,6 +667,7 @@ Add team members + Добави членове на екипа No comment provided by engineer. @@ -650,6 +675,10 @@ Добави към друго устройство No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Добави съобщение при посрещане @@ -657,26 +686,32 @@ Add your team members to the conversations. + Добавете членовете на вашия екип към разговорите. No comment provided by engineer. Added media & file servers + Добавени медийни и файлови сървъри No comment provided by engineer. Added message servers + Добавени сървъри за съобщения No comment provided by engineer. Additional accent + Допълнителен акцент No comment provided by engineer. Additional accent 2 + Допълнителен акцент 2 No comment provided by engineer. Additional secondary + Допълнителен вторичен No comment provided by engineer. @@ -691,10 +726,12 @@ Address or 1-time link? + Адрес или еднократен линк? No comment provided by engineer. Address settings + Настройки на адреса No comment provided by engineer. @@ -714,6 +751,11 @@ Advanced settings + Разширени настройки + No comment provided by engineer. + + + All No comment provided by engineer. @@ -726,6 +768,10 @@ Всички чатове и съобщения ще бъдат изтрити - това не може да бъде отменено! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Всички данни се изтриват при въвеждане. @@ -733,6 +779,7 @@ All data is kept private on your device. + Всички данни се съхраняват поверително на вашето устройство. No comment provided by engineer. @@ -761,6 +808,7 @@ All profiles + Всички профили profile dropdown @@ -790,6 +838,7 @@ Allow calls? + Позволи обаждания? No comment provided by engineer. @@ -799,6 +848,7 @@ Allow downgrade + Позволи понижаване No comment provided by engineer. @@ -828,6 +878,7 @@ Allow sharing + Позволи споделяне No comment provided by engineer. @@ -902,6 +953,7 @@ Always use private routing. + Винаги използвай поверително рутиране. No comment provided by engineer. @@ -914,6 +966,10 @@ Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. No comment provided by engineer. + + Another reason + report reason + Answer call Отговор на повикване @@ -939,6 +995,10 @@ Приложението криптира нови локални файлове (с изключение на видеоклипове). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Икона на приложението @@ -956,6 +1016,7 @@ App session + Сесия на приложението No comment provided by engineer. @@ -980,6 +1041,11 @@ Apply to + Приложи към + No comment provided by engineer. + + + Archive No comment provided by engineer. @@ -989,10 +1055,20 @@ Archive contacts to chat later. + Архивирайте контактите, за да разговаряте по-късно. + No comment provided by engineer. + + + Archive report + No comment provided by engineer. + + + Archive report? No comment provided by engineer. Archived contacts + Архивирани контакти No comment provided by engineer. @@ -1000,6 +1076,10 @@ Архивиране на база данни No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Прикачи @@ -1062,6 +1142,7 @@ Auto-accept settings + Автоматично приемане на настройки alert title @@ -1071,6 +1152,7 @@ Background + Фон No comment provided by engineer. @@ -1090,6 +1172,7 @@ Better calls + По-добри обаждания No comment provided by engineer. @@ -1099,6 +1182,7 @@ Better message dates. + По-добри дати на съобщението. No comment provided by engineer. @@ -1108,22 +1192,27 @@ Better networking + Подобрена мрежа No comment provided by engineer. Better notifications + Подобрени известия No comment provided by engineer. Better security ✅ + По-добра сигурност ✅ No comment provided by engineer. Better user experience + Подобрен интерфейс No comment provided by engineer. Black + Черна No comment provided by engineer. @@ -1163,10 +1252,12 @@ Blur for better privacy. + Размазване за по-добра поверителност. No comment provided by engineer. Blur media + Размазване на медия No comment provided by engineer. @@ -1201,10 +1292,16 @@ Business address + Бизнес адрес No comment provided by engineer. Business chats + Бизнес чатове + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1224,6 +1321,7 @@ Calls prohibited! + Обажданията са забранени! No comment provided by engineer. @@ -1233,10 +1331,12 @@ Can't call contact + Обаждането на контакта не е позволено No comment provided by engineer. Can't call member + Обаждането на члена не е позволено No comment provided by engineer. @@ -1280,6 +1380,7 @@ Capacity exceeded - recipient did not receive previously sent messages. + Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения. snd error text @@ -1292,8 +1393,13 @@ Промени No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Промени чат профилите authentication reason @@ -1365,7 +1471,7 @@ Chat database - База данни за чата + База данни No comment provided by engineer. @@ -1379,7 +1485,7 @@ Chat database imported - Базата данни на чат е импортирана + Базата данни на е импортирана No comment provided by engineer. @@ -1497,6 +1603,10 @@ Изчисти разговора? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Изчистване на лични бележки? @@ -1515,6 +1625,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Сравни файл @@ -1731,6 +1845,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Грешка при свързване @@ -1741,6 +1859,15 @@ This is your own one-time link! Грешка при свързване (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1750,6 +1877,10 @@ This is your own one-time link! Заявката за връзка е изпратена! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1824,6 +1955,10 @@ This is your own one-time link! Контактите могат да маркират съобщения за изтриване; ще можете да ги разглеждате. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продължи @@ -1895,6 +2030,10 @@ This is your own one-time link! Създай линк No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Създайте нов профил в [настолното приложение](https://simplex.chat/downloads/). 💻 @@ -2105,7 +2244,6 @@ This is your own one-time link! Delete Изтрий alert action - chat item action swipe action @@ -2146,6 +2284,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Изтрий чат профила @@ -2234,6 +2376,10 @@ This is your own one-time link! Изтрий линк? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Изтрий съобщението на члена? @@ -2247,7 +2393,7 @@ This is your own one-time link! Delete messages Изтрий съобщенията - No comment provided by engineer. + alert button Delete messages after @@ -2283,6 +2429,10 @@ This is your own one-time link! Изтрий опашка server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2431,6 +2581,14 @@ This is your own one-time link! Деактивирай SimpleX заключване authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Деактивиране за всички @@ -2517,6 +2675,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не създавай адрес @@ -2532,6 +2694,10 @@ This is your own one-time link! Не показвай отново No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Понижи версията и отвори чата @@ -2630,7 +2796,7 @@ This is your own one-time link! Enable automatic message deletion? Активиране на автоматично изтриване на съобщения? - No comment provided by engineer. + alert title Enable camera access @@ -2756,6 +2922,10 @@ This is your own one-time link! Неуспешно повторно договаряне на криптирането. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Въведете kодa за достъп @@ -2881,6 +3051,10 @@ This is your own one-time link! Грешка при създаване на групов линк No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Грешка при създаване на контакт с член @@ -2896,6 +3070,10 @@ This is your own one-time link! Грешка при създаване на профил! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Грешка при декриптирането на файла @@ -2903,7 +3081,7 @@ This is your own one-time link! Error deleting chat database - Грешка при изтриване на чат базата данни + Грешка при изтриване на базата данни No comment provided by engineer. @@ -2958,7 +3136,7 @@ This is your own one-time link! Error exporting chat database - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни No comment provided by engineer. @@ -2967,7 +3145,7 @@ This is your own one-time link! Error importing chat database - Грешка при импортиране на чат базата данни + Грешка при импортиране на базата данни No comment provided by engineer. @@ -3006,6 +3184,10 @@ This is your own one-time link! Грешка при отстраняване на член No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -3015,6 +3197,10 @@ This is your own one-time link! Грешка при запазване на ICE сървърите No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Грешка при запазване на профила на групата @@ -3219,15 +3405,24 @@ This is your own one-time link! Любим swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3574,6 +3769,10 @@ Error: %2$@ Групата ще бъде изтрита за вас - това не може да бъде отменено! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Помощ @@ -3637,6 +3836,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Информация @@ -3718,7 +3921,7 @@ Error: %2$@ Import chat database? - Импортиране на чат база данни? + Импортиране на база данни? No comment provided by engineer. @@ -3775,6 +3978,14 @@ More improvements are coming soon! Звуци по време на разговор No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Инкогнито @@ -4134,6 +4345,18 @@ This is your link for group %@! Запомнени настолни устройства No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Съобщение на живо! @@ -4379,6 +4602,10 @@ This is your link for group %@! Съобщенията от %@ ще бъдат показани! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4476,6 +4703,10 @@ This is your link for group %@! Модерирано в: %@ copied message info + + More + swipe action + More improvements are coming soon! Очаквайте скоро още подобрения! @@ -4636,6 +4867,18 @@ This is your link for group %@! Приложението няма kод за достъп Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Няма избрани контакти @@ -4731,6 +4974,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Първата платформа без никакви потребителски идентификатори – поверителна по дизайн. @@ -4741,6 +4988,10 @@ This is your link for group %@! Несъвместим! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4844,6 +5095,14 @@ Requires compatible VPN. Само собствениците на групата могат да активират гласови съобщения. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Само вие можете да добавяте реакции на съобщенията. @@ -4927,11 +5186,23 @@ Requires compatible VPN. Отвори група No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Отвори миграцията към друго устройство authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Приложението се отваря… @@ -5622,6 +5893,38 @@ Enable in *Network & servers* settings. Отговори chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Задължително @@ -5787,6 +6090,10 @@ Enable in *Network & servers* settings. Запази профила на групата No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Запази паролата и отвори чата @@ -6211,6 +6518,10 @@ Enable in *Network & servers* settings. Задай 1 ден No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Задай име на контакт… @@ -6516,6 +6827,11 @@ Enable in *Network & servers* settings. Някой notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Квадрат, кръг или нещо между тях. @@ -6599,6 +6915,10 @@ Enable in *Network & servers* settings. Спиране на чата No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6717,7 +7037,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6846,6 +7166,10 @@ It can happen because of some bug or when the connection is compromised.Профилът се споделя само с вашите контакти. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6914,6 +7238,10 @@ It can happen because of some bug or when the connection is compromised.Това действие не може да бъде отменено - съобщенията, изпратени и получени по-рано от избраното, ще бъдат изтрити. Може да отнеме няколко минути. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Това действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. @@ -7649,6 +7977,10 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Не трябва** да използвате една и съща база данни на две устройства. @@ -7988,12 +8320,12 @@ Repeat connection request? Your chat database - Вашата чат база данни + Вашата база данни No comment provided by engineer. Your chat database is not encrypted - set passphrase to encrypt it. - Вашата чат база данни не е криптирана - задайте парола, за да я криптирате. + Вашата база данни не е криптирана - задайте парола, за да я криптирате. No comment provided by engineer. @@ -8030,7 +8362,7 @@ Repeat connection request? Your current chat database will be DELETED and REPLACED with the imported one. - Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. + Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. No comment provided by engineer. @@ -8165,6 +8497,10 @@ Repeat connection request? и %lld други събития No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8370,7 +8706,8 @@ Repeat connection request? default (%@) по подразбиране (%@) - pref value + delete after time + pref value default (no) @@ -8394,7 +8731,7 @@ Repeat connection request? deleted group - групата изтрита + групата е изтрита rcv group event chat item @@ -8671,6 +9008,10 @@ Repeat connection request? модерирано от %@ marked deleted chat item preview text + + moderator + member role + months месеци @@ -8683,7 +9024,7 @@ Repeat connection request? never никога - No comment provided by engineer. + delete after time new message 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 668888c20e..410463098f 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -94,6 +98,7 @@ %@ downloaded + %@ staženo No comment provided by engineer. @@ -113,10 +118,12 @@ %@ server + %@ server No comment provided by engineer. %@ servers + %@ servery No comment provided by engineer. @@ -130,6 +137,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -191,6 +199,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d přeskočené zprávy @@ -232,14 +244,17 @@ %lld messages blocked + %lld zprávy blokovaný No comment provided by engineer. %lld messages blocked by admin + %lld zprávy blokovaný adminem No comment provided by engineer. %lld messages marked deleted + %lld zprávy označeno jako smazáno No comment provided by engineer. @@ -256,11 +271,6 @@ %d nové jazyky rozhraní No comment provided by engineer. - - %lld second(s) - %lld vteřin - No comment provided by engineer. - %lld seconds %lld vteřin @@ -322,6 +332,7 @@ (this device v%@) + (toto zařízení v%@) No comment provided by engineer. @@ -444,7 +455,8 @@ 1 day 1 den - time interval + delete after time + time interval 1 hour @@ -459,12 +471,18 @@ 1 month 1 měsíc - time interval + delete after time + time interval 1 week 1 týden - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -608,6 +626,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Přidat profil @@ -632,6 +654,10 @@ Přidat do jiného zařízení No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Přidat uvítací zprávu @@ -697,6 +723,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Všechna data aplikace jsou smazána. @@ -707,6 +737,10 @@ Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Všechna data se při zadání vymažou. @@ -889,6 +923,10 @@ Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle. No comment provided by engineer. + + Another reason + report reason + Answer call Přijmout hovor @@ -913,6 +951,10 @@ Aplikace šifruje nové místní soubory (s výjimkou videí). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Ikona aplikace @@ -955,6 +997,10 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -963,6 +1009,14 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -971,6 +1025,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Připojit @@ -1169,6 +1227,10 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1251,6 +1313,10 @@ Změnit No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1453,6 +1519,10 @@ Vyčistit konverzaci? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1470,6 +1540,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Porovnat soubor @@ -1671,6 +1745,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Chyba připojení @@ -1681,6 +1759,15 @@ This is your own one-time link! Chyba spojení (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1690,6 +1777,10 @@ This is your own one-time link! Požadavek na připojení byl odeslán! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1763,6 +1854,10 @@ This is your own one-time link! Kontakty mohou označit zprávy ke smazání; vy je budete moci zobrazit. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Pokračovat @@ -1831,6 +1926,10 @@ This is your own one-time link! Vytvořit odkaz No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻 @@ -2036,7 +2135,6 @@ This is your own one-time link! Delete Smazat alert action - chat item action swipe action @@ -2075,6 +2173,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Smazat chat profil @@ -2162,6 +2264,10 @@ This is your own one-time link! Smazat odkaz? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Smazat zprávu člena? @@ -2175,7 +2281,7 @@ This is your own one-time link! Delete messages Smazat zprávy - No comment provided by engineer. + alert button Delete messages after @@ -2211,6 +2317,10 @@ This is your own one-time link! Odstranit frontu server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2356,6 +2466,14 @@ This is your own one-time link! Vypnutí zámku SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Vypnout pro všechny @@ -2439,6 +2557,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Nevytvářet adresu @@ -2454,6 +2576,10 @@ This is your own one-time link! Znovu neukazuj No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Snížit a otevřít chat @@ -2548,7 +2674,7 @@ This is your own one-time link! Enable automatic message deletion? Povolit automatické mazání zpráv? - No comment provided by engineer. + alert title Enable camera access @@ -2668,6 +2794,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Zadat heslo @@ -2789,6 +2919,10 @@ This is your own one-time link! Chyba při vytváření odkazu skupiny No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Chyba vytvoření kontaktu člena @@ -2803,6 +2937,10 @@ This is your own one-time link! Chyba při vytváření profilu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Chyba dešifrování souboru @@ -2911,6 +3049,10 @@ This is your own one-time link! Chyba při odebrání člena No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2920,6 +3062,10 @@ This is your own one-time link! Chyba při ukládání serverů ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Chyba při ukládání profilu skupiny @@ -3117,15 +3263,24 @@ This is your own one-time link! Oblíbené swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3461,6 +3616,10 @@ Error: %2$@ Skupina bude smazána pro vás - toto nelze vzít zpět! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Pomoc @@ -3523,6 +3682,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Jak @@ -3655,6 +3818,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Inkognito @@ -3995,6 +4166,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Živé zprávy! @@ -4236,6 +4419,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4324,6 +4511,10 @@ This is your link for group %@! Upraveno v: %@ copied message info + + More + swipe action + More improvements are coming soon! Další vylepšení se chystají již brzy! @@ -4480,6 +4671,18 @@ This is your link for group %@! Žádné heslo aplikace Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Nebyl vybrán žádný kontakt @@ -4574,6 +4777,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Bez uživatelských identifikátorů @@ -4583,6 +4790,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4685,6 +4896,14 @@ Vyžaduje povolení sítě VPN. Pouze majitelé skupin mohou povolit zasílání hlasových zpráv. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Reakce na zprávy můžete přidávat pouze vy. @@ -4767,10 +4986,22 @@ Vyžaduje povolení sítě VPN. Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5434,6 +5665,38 @@ Enable in *Network & servers* settings. Odpověď chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Povinné @@ -5597,6 +5860,10 @@ Enable in *Network & servers* settings. Uložení profilu skupiny No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Uložte heslo a otevřete chat @@ -6013,6 +6280,10 @@ Enable in *Network & servers* settings. Nastavit 1 den No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Nastavení jména kontaktu… @@ -6312,6 +6583,11 @@ Enable in *Network & servers* settings. Někdo notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6391,6 +6667,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6506,7 +6786,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6634,6 +6914,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Profil je sdílen pouze s vašimi kontakty. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6701,6 +6985,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Tuto akci nelze vzít zpět - zprávy odeslané a přijaté dříve, než bylo zvoleno, budou smazány. Může to trvat několik minut. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tuto akci nelze vzít zpět - váš profil, kontakty, zprávy a soubory budou nenávratně ztraceny. @@ -7398,6 +7686,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7893,6 +8185,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8093,7 +8389,8 @@ Repeat connection request? default (%@) výchozí (%@) - pref value + delete after time + pref value default (no) @@ -8390,6 +8687,10 @@ Repeat connection request? moderovaný %@ marked deleted chat item preview text + + moderator + member role + months měsíců @@ -8402,7 +8703,7 @@ Repeat connection request? never nikdy - No comment provided by engineer. + delete after time new message 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 e993740f1c..d978641d4a 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d s time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d übersprungene Nachricht(en) @@ -272,11 +280,6 @@ %lld neue Sprachen für die Bedienoberfläche No comment provided by engineer. - - %lld second(s) - %lld Sekunde(n) - No comment provided by engineer. - %lld seconds %lld Sekunden @@ -471,7 +474,8 @@ 1 day täglich - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month monatlich - time interval + delete after time + time interval 1 week wöchentlich - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -579,7 +589,7 @@ About operators - Über Betreiber + Über die Betreiber No comment provided by engineer. @@ -645,6 +655,10 @@ Freunde aufnehmen No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Profil hinzufügen @@ -670,6 +684,10 @@ Einem anderen Gerät hinzufügen No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Begrüßungsmeldung hinzufügen @@ -745,6 +763,10 @@ Erweiterte Einstellungen No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Werden die App-Daten komplett gelöscht. @@ -755,6 +777,10 @@ Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Alle Daten werden gelöscht, sobald dieser eingegeben wird. @@ -950,6 +976,10 @@ Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt. No comment provided by engineer. + + Another reason + report reason + Answer call Anruf annehmen @@ -975,6 +1005,10 @@ Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt. No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon App-Icon @@ -1020,6 +1054,10 @@ Anwenden auf No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archivieren und Hochladen @@ -1030,6 +1068,14 @@ Kontakte für spätere Chats archivieren. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Archivierte Kontakte @@ -1040,6 +1086,10 @@ Datenbank wird archiviert No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Anhängen @@ -1217,7 +1267,7 @@ Blur media - Medium unscharf machen + Medium verpixeln No comment provided by engineer. @@ -1260,6 +1310,10 @@ Geschäftliche Chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1405,10 @@ Ändern No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Chat-Profile wechseln @@ -1572,6 +1630,10 @@ Chat-Inhalte entfernen? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Private Notizen entfernen? @@ -1592,6 +1654,10 @@ Farbvariante No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Datei vergleichen @@ -1826,6 +1892,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungs- und Server-Status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Verbindungsfehler @@ -1836,6 +1906,15 @@ Das ist Ihr eigener Einmal-Link! Verbindungsfehler (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Verbindungsbenachrichtigungen @@ -1846,6 +1925,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungsanfrage wurde gesendet! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Verbindungs-Sicherheit @@ -1926,6 +2009,10 @@ Das ist Ihr eigener Einmal-Link! Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Weiter @@ -2001,6 +2088,10 @@ Das ist Ihr eigener Einmal-Link! Link erzeugen No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻 @@ -2218,7 +2309,6 @@ Das ist Ihr eigener Einmal-Link! Delete Löschen alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ Das ist Ihr eigener Einmal-Link! Chat löschen No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Chat-Profil löschen @@ -2351,6 +2445,10 @@ Das ist Ihr eigener Einmal-Link! Link löschen? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Nachricht des Mitglieds löschen? @@ -2364,7 +2462,7 @@ Das ist Ihr eigener Einmal-Link! Delete messages Nachrichten löschen - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ Das ist Ihr eigener Einmal-Link! Lösche Warteschlange server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Löschen Sie bis zu 20 Nachrichten auf einmal. @@ -2561,6 +2663,14 @@ Das ist Ihr eigener Einmal-Link! SimpleX-Sperre deaktivieren authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Für Alle deaktivieren @@ -2651,6 +2761,10 @@ Das ist Ihr eigener Einmal-Link! Verwenden Sie keine Anmeldeinformationen mit einem Proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Keine Adresse erstellt @@ -2666,6 +2780,10 @@ Das ist Ihr eigener Einmal-Link! Nicht nochmals anzeigen No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Datenbank herabstufen und den Chat öffnen @@ -2770,7 +2888,7 @@ Das ist Ihr eigener Einmal-Link! Enable automatic message deletion? Automatisches Löschen von Nachrichten aktivieren? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3015,10 @@ Das ist Ihr eigener Einmal-Link! Neuverhandlung der Verschlüsselung fehlgeschlagen. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Zugangscode eingeben @@ -3027,6 +3149,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Erzeugen des Gruppen-Links No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Fehler beim Anlegen eines Mitglied-Kontaktes @@ -3042,6 +3168,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Erstellen des Profils! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Fehler beim Entschlüsseln der Datei @@ -3157,6 +3287,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Entfernen des Mitglieds No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Fehler beim Zurücksetzen der Statistiken @@ -3167,6 +3301,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Speichern der ICE-Server No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Fehler beim Speichern des Gruppenprofils @@ -3377,10 +3515,14 @@ Das ist Ihr eigener Einmal-Link! Favorit swipe action + + Favorites + No comment provided by engineer. + File error Datei-Fehler - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ Das ist Ihr eigener Einmal-Link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen. @@ -3758,6 +3905,10 @@ Fehler: %2$@ Die Gruppe wird nur bei Ihnen gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Hilfe @@ -3823,6 +3974,10 @@ Fehler: %2$@ Wie es die Privatsphäre schützt No comment provided by engineer. + + How it works + alert button + How to Anleitung @@ -3965,6 +4120,14 @@ Weitere Verbesserungen sind bald verfügbar! Klingeltöne No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Inkognito @@ -4330,6 +4493,18 @@ Das ist Ihr Link für die Gruppe %@! Verknüpfte Desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Live Nachricht! @@ -4457,7 +4632,7 @@ Das ist Ihr Link für die Gruppe %@! Members can send SimpleX links. - Gruppenmitglieder können SimpleX-Links senden. + Gruppenmitglieder können SimpleX-Links versenden. No comment provided by engineer. @@ -4467,12 +4642,12 @@ Das ist Ihr Link für die Gruppe %@! Members can send disappearing messages. - Gruppenmitglieder können verschwindende Nachrichten senden. + Gruppenmitglieder können verschwindende Nachrichten versenden. No comment provided by engineer. Members can send files and media. - Gruppenmitglieder können Dateien und Medien senden. + Gruppenmitglieder können Dateien und Medien versenden. No comment provided by engineer. @@ -4590,6 +4765,10 @@ Das ist Ihr Link für die Gruppe %@! Die Nachrichten von %@ werden angezeigt! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Empfangene Nachrichten @@ -4690,6 +4869,10 @@ Das ist Ihr Link für die Gruppe %@! Moderiert um: %@ copied message info + + More + swipe action + More improvements are coming soon! Weitere Verbesserungen sind bald verfügbar! @@ -4860,6 +5043,18 @@ Das ist Ihr Link für die Gruppe %@! Kein App-Passwort Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Keine Kontakte ausgewählt @@ -4965,6 +5160,10 @@ Das ist Ihr Link für die Gruppe %@! Keine Server für das Versenden von Dateien. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Keine Benutzerkennungen. @@ -4975,6 +5174,10 @@ Das ist Ihr Link für die Gruppe %@! Nicht kompatibel! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nichts ausgewählt @@ -5083,6 +5286,14 @@ Dies erfordert die Aktivierung eines VPNs. Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Nur Sie können Reaktionen auf Nachrichten geben. @@ -5168,11 +5379,23 @@ Dies erfordert die Aktivierung eines VPNs. Gruppe öffnen No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Migration auf ein anderes Gerät öffnen authentication reason + + Open web link? + No comment provided by engineer. + Opening app… App wird geöffnet… @@ -5902,6 +6125,38 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Antwort chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Erforderlich @@ -6078,6 +6333,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Gruppenprofil speichern No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Passwort speichern und Chat öffnen @@ -6533,6 +6792,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Einen Tag festlegen No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Kontaktname festlegen… @@ -6861,6 +7124,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Jemand notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Quadratisch, kreisförmig oder irgendetwas dazwischen. @@ -6946,6 +7214,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Chat wird beendet No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Hart @@ -7074,7 +7346,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Temporary file error Temporärer Datei-Fehler - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7480,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Das Profil wird nur mit Ihren Kontakten geteilt. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**. @@ -7275,17 +7551,21 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. + Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. + Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. @@ -8057,6 +8337,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s XFTP-Server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Sie dürfen die selbe Datenbank **nicht** auf zwei Geräten nutzen. @@ -8363,7 +8647,7 @@ Verbindungsanfrage wiederholen? You will stop receiving messages from this chat. Chat history will be preserved. - Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten. + Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten. No comment provided by engineer. @@ -8468,7 +8752,7 @@ Verbindungsanfrage wiederholen? Your privacy - Ihre Privatsphäre + Privatsphäre No comment provided by engineer. @@ -8553,7 +8837,7 @@ Verbindungsanfrage wiederholen? accepted invitation - Einladung akzeptiert + Einladung angenommen chat list item title @@ -8591,6 +8875,10 @@ Verbindungsanfrage wiederholen? und %lld weitere Ereignisse No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts Versuche @@ -8799,7 +9087,8 @@ Verbindungsanfrage wiederholen? default (%@) Voreinstellung (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9395,10 @@ Verbindungsanfrage wiederholen? Von %@ moderiert marked deleted chat item preview text + + moderator + member role + months Monate @@ -9119,7 +9412,7 @@ Verbindungsanfrage wiederholen? never nie - No comment provided by engineer. + delete after time new message @@ -9617,7 +9910,7 @@ Zuletzt empfangene Nachricht: %2$@ Copyright © 2024 SimpleX Chat. All rights reserved. - Copyright © 2024 SimpleX Chat. Alle Rechte vorbehalten. + Copyright © 2025 SimpleX Chat. Alle Rechte vorbehalten. Copyright (human-readable) @@ -9659,17 +9952,17 @@ Zuletzt empfangene Nachricht: %2$@ Currently maximum supported file size is %@. - Die maximale erlaubte Dateigröße beträgt aktuell %@. + Die maximal erlaubte Dateigröße beträgt aktuell %@. No comment provided by engineer. Database downgrade required - Datenbank-Herabstufung erforderlich + Datenbank-Herunterstufung ist erforderlich No comment provided by engineer. Database encrypted! - Datenbank verschlüsselt! + Datenbank ist verschlüsselt! No comment provided by engineer. @@ -9684,7 +9977,7 @@ Zuletzt empfangene Nachricht: %2$@ Database passphrase is required to open chat. - Ein Datenbank-Passwort ist erforderlich, um den Chat zu öffnen. + Um den Chat zu öffnen, ist ein Datenbank-Passwort ist erforderlich. No comment provided by engineer. @@ -9709,7 +10002,7 @@ Zuletzt empfangene Nachricht: %2$@ File error - Dateifehler + Datei-Fehler No comment provided by engineer. @@ -9744,12 +10037,12 @@ Zuletzt empfangene Nachricht: %2$@ Open the app to downgrade the database. - Öffne die App, um die Datenbank herabzustufen. + Öffnen Sie die App, um die Datenbank herunterzustufen. No comment provided by engineer. Open the app to upgrade the database. - Öffne die App, um die Datenbank zu aktualisieren. + Öffnen Sie die App, um die Datenbank zu aktualisieren. No comment provided by engineer. @@ -9759,7 +10052,7 @@ Zuletzt empfangene Nachricht: %2$@ Please create a profile in the SimpleX app - Bitte erstelle ein Profil in der SimpleX-App + Bitte erstellen Sie ein Profil in der SimpleX-App No comment provided by engineer. @@ -9809,7 +10102,7 @@ Zuletzt empfangene Nachricht: %2$@ You can allow sharing in Privacy & Security / SimpleX Lock settings. - Du kannst das Teilen in den Einstellungen zu Datenschutz & Sicherheit - SimpleX-Sperre erlauben. + Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index cebd6c90d1..79eb1573a4 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -12,6 +12,11 @@ No comment provided by engineer. + + + + No comment provided by engineer. + ( ( @@ -202,6 +207,11 @@ %d sec time interval + + %d seconds(s) + %d seconds(s) + delete after time + %d skipped message(s) %d skipped message(s) @@ -272,11 +282,6 @@ %lld new interface languages No comment provided by engineer. - - %lld second(s) - %lld second(s) - No comment provided by engineer. - %lld seconds %lld seconds @@ -471,7 +476,8 @@ 1 day 1 day - time interval + delete after time + time interval 1 hour @@ -486,12 +492,19 @@ 1 month 1 month - time interval + delete after time + time interval 1 week 1 week - time interval + delete after time + time interval + + + 1 year + 1 year + delete after time 1-time link @@ -645,6 +658,11 @@ Add friends No comment provided by engineer. + + Add list + Add list + No comment provided by engineer. + Add profile Add profile @@ -670,6 +688,11 @@ Add to another device No comment provided by engineer. + + Add to list + Add to list + No comment provided by engineer. + Add welcome message Add welcome message @@ -745,6 +768,11 @@ Advanced settings No comment provided by engineer. + + All + All + No comment provided by engineer. + All app data is deleted. All app data is deleted. @@ -755,6 +783,11 @@ All chats and messages will be deleted - this cannot be undone! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. All data is erased when it is entered. @@ -950,6 +983,11 @@ An empty chat profile with the provided name is created, and the app opens as usual. No comment provided by engineer. + + Another reason + Another reason + report reason + Answer call Answer call @@ -975,6 +1013,11 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + App group: + No comment provided by engineer. + App icon App icon @@ -1020,6 +1063,11 @@ Apply to No comment provided by engineer. + + Archive + Archive + No comment provided by engineer. + Archive and upload Archive and upload @@ -1030,6 +1078,16 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + Archive report + No comment provided by engineer. + + + Archive report? + Archive report? + No comment provided by engineer. + Archived contacts Archived contacts @@ -1040,6 +1098,11 @@ Archiving database No comment provided by engineer. + + Ask + Ask + No comment provided by engineer. + Attach Attach @@ -1260,6 +1323,11 @@ Business chats No comment provided by engineer. + + Businesses + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1419,11 @@ Change No comment provided by engineer. + + Change automatic message deletion? + Change automatic message deletion? + alert title + Change chat profiles Change chat profiles @@ -1572,6 +1645,11 @@ Clear conversation? No comment provided by engineer. + + Clear or delete group? + Clear or delete group? + No comment provided by engineer. + Clear private notes? Clear private notes? @@ -1592,6 +1670,11 @@ Color mode No comment provided by engineer. + + Community guidelines violation + Community guidelines violation + report reason + Compare file Compare file @@ -1826,6 +1909,11 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + Connection blocked + No comment provided by engineer. + Connection error Connection error @@ -1836,6 +1924,18 @@ This is your own one-time link! Connection error (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + Connection not ready. + No comment provided by engineer. + Connection notifications Connection notifications @@ -1846,6 +1946,11 @@ This is your own one-time link! Connection request sent! No comment provided by engineer. + + Connection requires encryption renegotiation. + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Connection security @@ -1926,6 +2031,11 @@ This is your own one-time link! Contacts can mark messages for deletion; you will be able to view them. No comment provided by engineer. + + Content violates conditions of use + Content violates conditions of use + blocking reason + Continue Continue @@ -2001,6 +2111,11 @@ This is your own one-time link! Create link No comment provided by engineer. + + Create list + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 @@ -2218,7 +2333,6 @@ This is your own one-time link! Delete Delete alert action - chat item action swipe action @@ -2261,6 +2375,11 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Delete chat profile @@ -2351,6 +2470,11 @@ This is your own one-time link! Delete link? No comment provided by engineer. + + Delete list? + Delete list? + alert title + Delete member message? Delete member message? @@ -2364,7 +2488,7 @@ This is your own one-time link! Delete messages Delete messages - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2525,11 @@ This is your own one-time link! Delete queue server test step + + Delete report + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Delete up to 20 messages at once. @@ -2561,6 +2690,16 @@ This is your own one-time link! Disable SimpleX Lock authentication reason + + Disable automatic message deletion? + Disable automatic message deletion? + alert title + + + Disable delete messages + Disable delete messages + alert button + Disable for all Disable for all @@ -2651,6 +2790,11 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + Documents: + No comment provided by engineer. + Don't create address Don't create address @@ -2666,6 +2810,11 @@ This is your own one-time link! Don't show again No comment provided by engineer. + + Done + Done + No comment provided by engineer. + Downgrade and open chat Downgrade and open chat @@ -2770,7 +2919,7 @@ This is your own one-time link! Enable automatic message deletion? Enable automatic message deletion? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3046,11 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Enter Passcode @@ -3027,6 +3181,11 @@ This is your own one-time link! Error creating group link No comment provided by engineer. + + Error creating list + Error creating list + alert title + Error creating member contact Error creating member contact @@ -3042,6 +3201,11 @@ This is your own one-time link! Error creating profile! No comment provided by engineer. + + Error creating report + Error creating report + No comment provided by engineer. + Error decrypting file Error decrypting file @@ -3157,6 +3321,11 @@ This is your own one-time link! Error removing member No comment provided by engineer. + + Error reordering lists + Error reordering lists + alert title + Error resetting statistics Error resetting statistics @@ -3167,6 +3336,11 @@ This is your own one-time link! Error saving ICE servers No comment provided by engineer. + + Error saving chat list + Error saving chat list + alert title + Error saving group profile Error saving group profile @@ -3377,10 +3551,15 @@ This is your own one-time link! Favorite swipe action + + Favorites + Favorites + No comment provided by engineer. + File error File error - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3568,13 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. File not found - most likely file was deleted or cancelled. @@ -3758,6 +3944,11 @@ Error: %2$@ Group will be deleted for you - this cannot be undone! No comment provided by engineer. + + Groups + Groups + No comment provided by engineer. + Help Help @@ -3823,6 +4014,11 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + How it works + alert button + How to How to @@ -3965,6 +4161,16 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + Inappropriate content + report reason + + + Inappropriate profile + Inappropriate profile + report reason + Incognito Incognito @@ -4330,6 +4536,21 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + List + swipe action + + + List name and emoji should be different for all lists. + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + List name... + No comment provided by engineer. + Live message! Live message! @@ -4590,6 +4811,11 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + Messages in this chat will never be deleted. + alert message + Messages received Messages received @@ -4690,6 +4916,11 @@ This is your link for group %@! Moderated at: %@ copied message info + + More + More + swipe action + More improvements are coming soon! More improvements are coming soon! @@ -4860,6 +5091,21 @@ This is your link for group %@! No app password Authentication unavailable + + No chats + No chats + No comment provided by engineer. + + + No chats found + No chats found + No comment provided by engineer. + + + No chats in list %@ + No chats in list %@ + No comment provided by engineer. + No contacts selected No contacts selected @@ -4965,6 +5211,11 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No unread chats + No comment provided by engineer. + No user identifiers. No user identifiers. @@ -4975,6 +5226,11 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + Notes + No comment provided by engineer. + Nothing selected Nothing selected @@ -5083,6 +5339,16 @@ Requires compatible VPN. Only group owners can enable voice messages. No comment provided by engineer. + + Only sender and moderators see it + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Only you can add message reactions. @@ -5168,11 +5434,26 @@ Requires compatible VPN. Open group No comment provided by engineer. + + Open link + Open link + No comment provided by engineer. + + + Open links from chat list + Open links from chat list + No comment provided by engineer. + Open migration to another device Open migration to another device authentication reason + + Open web link? + Open web link? + No comment provided by engineer. + Opening app… Opening app… @@ -5902,6 +6183,46 @@ Enable in *Network & servers* settings. Reply chat item action + + Report + Report + chat item action + + + Report content: only group moderators will see it. + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + Report other: only group moderators will see it. + report reason + + + Report reason? + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + Report violation: only group moderators will see it. + report reason + + + Reports + Reports + No comment provided by engineer. + Required Required @@ -6078,6 +6399,11 @@ Enable in *Network & servers* settings. Save group profile No comment provided by engineer. + + Save list + Save list + No comment provided by engineer. + Save passphrase and open chat Save passphrase and open chat @@ -6533,6 +6859,11 @@ Enable in *Network & servers* settings. Set 1 day No comment provided by engineer. + + Set chat name… + Set chat name… + No comment provided by engineer. + Set contact name… Set contact name… @@ -6861,6 +7192,12 @@ Enable in *Network & servers* settings. Somebody notification title + + Spam + Spam + blocking reason + report reason + Square, circle, or anything in between. Square, circle, or anything in between. @@ -6946,6 +7283,11 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + Storage + No comment provided by engineer. + Strong Strong @@ -7074,7 +7416,7 @@ Enable in *Network & servers* settings. Temporary file error Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7550,11 @@ It can happen because of some bug or when the connection is compromised.The profile is only shared with your contacts. No comment provided by engineer. + + The report will be archived for you. + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. The same conditions will apply to operator **%@**. @@ -7283,6 +7630,11 @@ It can happen because of some bug or when the connection is compromised.This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. @@ -8057,6 +8409,11 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. You **must not** use the same database on two devices. @@ -8591,6 +8948,11 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + archived report + No comment provided by engineer. + attempts attempts @@ -8799,7 +9161,8 @@ Repeat connection request? default (%@) default (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9469,11 @@ Repeat connection request? moderated by %@ marked deleted chat item preview text + + moderator + moderator + member role + months months @@ -9119,7 +9487,7 @@ Repeat connection request? never never - No comment provided by engineer. + delete after time new message 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 08522cc617..dd8790768a 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d segundo(s) time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d mensaje(s) omitido(s) @@ -272,11 +280,6 @@ %lld idiomas de interfaz nuevos No comment provided by engineer. - - %lld second(s) - %lld segundo(s) - No comment provided by engineer. - %lld seconds %lld segundos @@ -471,7 +474,8 @@ 1 day un dia - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month un mes - time interval + delete after time + time interval 1 week una semana - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -554,7 +564,7 @@ A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**. -**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar. +**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar. No comment provided by engineer. @@ -645,6 +655,10 @@ Añadir amigos No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Añadir perfil @@ -670,6 +684,10 @@ Añadir a otro dispositivo No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Añadir mensaje de bienvenida @@ -677,7 +695,7 @@ Add your team members to the conversations. - Añade a los miembros de tu equipo a las conversaciones. + Añade a miembros de tu equipo a las conversaciones. No comment provided by engineer. @@ -722,7 +740,7 @@ Address settings - Configuración de dirección + Configurar dirección No comment provided by engineer. @@ -745,6 +763,10 @@ Configuración avanzada No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Todos los datos de la aplicación se eliminarán. @@ -755,6 +777,10 @@ Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Al introducirlo todos los datos son eliminados. @@ -950,6 +976,10 @@ Se creará un perfil vacío con el nombre proporcionado, y la aplicación se abrirá como de costumbre. No comment provided by engineer. + + Another reason + report reason + Answer call Responder llamada @@ -975,6 +1005,10 @@ Cifrado de los nuevos archivos locales (excepto vídeos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Icono aplicación @@ -992,7 +1026,7 @@ App session - Sesión de aplicación + por sesión No comment provided by engineer. @@ -1020,6 +1054,10 @@ Aplicar a No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archivar y subir @@ -1030,6 +1068,14 @@ Archiva contactos para charlar más tarde. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Contactos archivados @@ -1040,6 +1086,10 @@ Archivando base de datos No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Adjuntar @@ -1260,6 +1310,10 @@ Chats empresariales No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1405,10 @@ Cambiar No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Cambiar perfil de usuario @@ -1572,9 +1630,13 @@ ¿Vaciar conversación? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? - ¿Borrar notas privadas? + ¿Eliminar notas privadas? No comment provided by engineer. @@ -1592,6 +1654,10 @@ Modo de color No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Comparar archivo @@ -1826,6 +1892,10 @@ This is your own one-time link! Estado de tu conexión y servidores. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Error conexión @@ -1836,6 +1906,15 @@ This is your own one-time link! Error de conexión (Autenticación) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Notificaciones de conexión @@ -1846,6 +1925,10 @@ This is your own one-time link! ¡Solicitud de conexión enviada! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Seguridad de conexión @@ -1926,6 +2009,10 @@ This is your own one-time link! Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Continuar @@ -2001,6 +2088,10 @@ This is your own one-time link! Crear enlace No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻 @@ -2218,7 +2309,6 @@ This is your own one-time link! Delete Eliminar alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ This is your own one-time link! Eliminar chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Eliminar perfil @@ -2351,6 +2445,10 @@ This is your own one-time link! ¿Eliminar enlace? No comment provided by engineer. + + Delete list? + alert title + Delete member message? ¿Eliminar el mensaje de miembro? @@ -2364,7 +2462,7 @@ This is your own one-time link! Delete messages Eliminar mensaje - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ This is your own one-time link! Eliminar cola server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Elimina hasta 20 mensajes a la vez. @@ -2561,6 +2663,14 @@ This is your own one-time link! Desactivar Bloqueo SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Desactivar para todos @@ -2651,6 +2761,10 @@ This is your own one-time link! No uses credenciales con proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address No crear dirección SimpleX @@ -2666,6 +2780,10 @@ This is your own one-time link! No volver a mostrar No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Degradar y abrir Chat @@ -2770,7 +2888,7 @@ This is your own one-time link! Enable automatic message deletion? ¿Activar eliminación automática de mensajes? - No comment provided by engineer. + alert title Enable camera access @@ -2839,7 +2957,7 @@ This is your own one-time link! Encrypt local files - Cifra archivos locales + Cifrar archivos locales No comment provided by engineer. @@ -2897,6 +3015,10 @@ This is your own one-time link! Renegociación de cifrado fallida. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Introduce Código @@ -2929,7 +3051,7 @@ This is your own one-time link! Enter server manually - Introduce el servidor manualmente + Añadir manualmente No comment provided by engineer. @@ -3027,6 +3149,10 @@ This is your own one-time link! Error al crear enlace de grupo No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Error al establecer contacto con el miembro @@ -3042,6 +3168,10 @@ This is your own one-time link! ¡Error al crear perfil! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Error al descifrar el archivo @@ -3157,6 +3287,10 @@ This is your own one-time link! Error al eliminar miembro No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Error al restablecer las estadísticas @@ -3167,6 +3301,10 @@ This is your own one-time link! Error al guardar servidores ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Error al guardar perfil de grupo @@ -3377,10 +3515,14 @@ This is your own one-time link! Favoritos swipe action + + Favorites + No comment provided by engineer. + File error Error de archivo - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Archivo no encontrado, probablemente haya sido borrado o cancelado. @@ -3531,7 +3678,7 @@ This is your own one-time link! For private routing - Para el enrutamiento privado + Para enrutamiento privado No comment provided by engineer. @@ -3758,6 +3905,10 @@ Error: %2$@ El grupo será eliminado para tí. ¡No podrá deshacerse! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Ayuda @@ -3823,6 +3974,10 @@ Error: %2$@ Cómo ayuda a la privacidad No comment provided by engineer. + + How it works + alert button + How to Cómo @@ -3965,6 +4120,14 @@ More improvements are coming soon! Sonido de llamada No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incógnito @@ -4330,6 +4493,18 @@ This is your link for group %@! Ordenadores enlazados No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ¡Mensaje en vivo! @@ -4590,6 +4765,10 @@ This is your link for group %@! ¡Los mensajes de %@ serán mostrados! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Mensajes recibidos @@ -4690,6 +4869,10 @@ This is your link for group %@! Moderado: %@ copied message info + + More + swipe action + More improvements are coming soon! ¡Pronto habrá más mejoras! @@ -4782,7 +4965,7 @@ This is your link for group %@! New SOCKS credentials will be used for each server. - Se usarán credenciales SOCKS nuevas por cada servidor. + Se usarán credenciales SOCKS nuevas para cada servidor. No comment provided by engineer. @@ -4860,6 +5043,18 @@ This is your link for group %@! Sin contraseña de la aplicación Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Ningún contacto seleccionado @@ -4907,12 +5102,12 @@ This is your link for group %@! No media & file servers. - Ningún servidor de archivos y multimedia. + Sin servidores para archivos y multimedia. servers error No message servers. - Ningún servidor de mensajes. + Sin servidores para mensajes. servers error @@ -4937,7 +5132,7 @@ This is your link for group %@! No push server - Ningún servidor push + Sin servidores push No comment provided by engineer. @@ -4947,24 +5142,28 @@ This is your link for group %@! No servers for private message routing. - Ningún servidor para enrutamiento privado. + Sin servidores para enrutamiento privado. servers error No servers to receive files. - Ningún servidor para recibir archivos. + Sin servidores para recibir archivos. servers error No servers to receive messages. - Ningún servidor para recibir mensajes. + Sin servidores para recibir mensajes. servers error No servers to send files. - Ningún servidor para enviar archivos. + Sin servidores para enviar archivos. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Sin identificadores de usuario. @@ -4975,6 +5174,10 @@ This is your link for group %@! ¡No compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nada seleccionado @@ -5065,7 +5268,7 @@ Requiere activación de la VPN. Only delete conversation - Sólo borrar la conversación + Eliminar sólo la conversación No comment provided by engineer. @@ -5083,6 +5286,14 @@ Requiere activación de la VPN. Sólo los propietarios del grupo pueden activar los mensajes de voz. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Sólo tú puedes añadir reacciones a los mensajes. @@ -5168,11 +5379,23 @@ Requiere activación de la VPN. Grupo abierto No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Abrir menú migración a otro dispositivo authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Iniciando aplicación… @@ -5210,7 +5433,7 @@ Requiere activación de la VPN. Or show this code - O muestra este código QR + O muestra el código QR No comment provided by engineer. @@ -5638,7 +5861,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reachable chat toolbar - Barra de herramientas accesible + Barra de chat accesible No comment provided by engineer. @@ -5773,7 +5996,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reconnect server to force message delivery. It uses additional traffic. - Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional. + Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional. No comment provided by engineer. @@ -5902,6 +6125,38 @@ Actívalo en ajustes de *Servidores y Redes*. Responder chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Obligatorio @@ -6078,6 +6333,10 @@ Actívalo en ajustes de *Servidores y Redes*. Guardar perfil de grupo No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Guardar contraseña y abrir el chat @@ -6175,7 +6434,7 @@ Actívalo en ajustes de *Servidores y Redes*. Scan server QR code - Escanear código QR del servidor + Escanear código QR No comment provided by engineer. @@ -6533,6 +6792,10 @@ Actívalo en ajustes de *Servidores y Redes*. Establecer 1 día No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Escribe el nombre del contacto… @@ -6641,7 +6904,7 @@ Actívalo en ajustes de *Servidores y Redes*. Share profile - Comparte perfil + Perfil a compartir No comment provided by engineer. @@ -6716,7 +6979,7 @@ Actívalo en ajustes de *Servidores y Redes*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. - Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación + Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. No comment provided by engineer. @@ -6861,6 +7124,11 @@ Actívalo en ajustes de *Servidores y Redes*. Alguien notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Cuadrada, circular o cualquier forma intermedia. @@ -6908,7 +7176,7 @@ Actívalo en ajustes de *Servidores y Redes*. Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. - Para poder exportar, importar o eliminar la base de datos primero debes parar SimpleX. Mientras tanto no podrás recibir ni enviar mensajes. + Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. No comment provided by engineer. @@ -6946,6 +7214,10 @@ Actívalo en ajustes de *Servidores y Redes*. Parando chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Fuerte @@ -6958,7 +7230,7 @@ Actívalo en ajustes de *Servidores y Redes*. Subscribed - Suscrito + Suscritas No comment provided by engineer. @@ -7074,7 +7346,7 @@ Actívalo en ajustes de *Servidores y Redes*. Temporary file error Error en archivo temporal - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7480,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. El perfil sólo se comparte con tus contactos. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Las mismas condiciones se aplicarán al operador **%@**. @@ -7235,12 +7511,12 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The servers for new connections of your current chat profile **%@**. - Lista de servidores para las conexiones nuevas del perfil **%@**. + Servidores para conexiones nuevas en tu perfil **%@**. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - Los servidores para archivos nuevos en tu perfil actual **%@**. + Servidores para archivos nuevos en tu perfil **%@**. No comment provided by engineer. @@ -7283,6 +7559,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Esta acción es irreversible. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Podría tardar varios minutos. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente. @@ -7427,7 +7707,7 @@ Se te pedirá que completes la autenticación antes de activar esta función. To use the servers of **%@**, accept conditions of use. - Para usar los servidores de **%@**, acepta las condiciones de uso. + Para usar los servidores de **%@**, debes aceptar las condiciones de uso. No comment provided by engineer. @@ -7583,7 +7863,7 @@ Se te pedirá que completes la autenticación antes de activar esta función. Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. - A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo. + A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo. Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. No comment provided by engineer. @@ -7714,17 +7994,17 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use for files - Usar para archivos + Uso para archivos No comment provided by engineer. Use for messages - Usar para mensajes + Uso para mensajes No comment provided by engineer. Use for new connections - Usar para conexiones nuevas + Para conexiones nuevas No comment provided by engineer. @@ -7754,7 +8034,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use private routing with unknown servers. - Usar enrutamiento privado con servidores de retransmisión desconocidos. + Usar enrutamiento privado con servidores de mensaje desconocidos. No comment provided by engineer. @@ -8057,6 +8337,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Servidor XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **No debes** usar la misma base de datos en dos dispositivos. @@ -8136,7 +8420,7 @@ Repeat join request? You are not connected to these servers. Private routing is used to deliver messages to them. - No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado. + No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. No comment provided by engineer. @@ -8308,7 +8592,7 @@ Repeat connection request? You need to allow your contact to call to be able to call them. - Necesitas permitir que tus contacto llamen para poder llamarles. + Debes permitir que tus contacto te llamen para poder llamarles. No comment provided by engineer. @@ -8591,6 +8875,10 @@ Repeat connection request? y %lld evento(s) más No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts intentos @@ -8799,7 +9087,8 @@ Repeat connection request? default (%@) predeterminado (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9395,10 @@ Repeat connection request? moderado por %@ marked deleted chat item preview text + + moderator + member role + months meses @@ -9119,7 +9412,7 @@ Repeat connection request? never nunca - No comment provided by engineer. + delete after time new message 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 2caa98e25b..5a27563de3 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -189,6 +193,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d ohitettua viestiä @@ -254,11 +262,6 @@ %lld uutta käyttöliittymän kieltä No comment provided by engineer. - - %lld second(s) - %lld sekunti(a) - No comment provided by engineer. - %lld seconds %lld sekuntia @@ -439,7 +442,8 @@ 1 day 1 päivä - time interval + delete after time + time interval 1 hour @@ -454,12 +458,18 @@ 1 month 1 kuukausi - time interval + delete after time + time interval 1 week 1 viikko - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -603,6 +613,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Lisää profiili @@ -627,6 +641,10 @@ Lisää toiseen laitteeseen No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Lisää tervetuloviesti @@ -692,6 +710,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Kaikki sovelluksen tiedot poistetaan. @@ -702,6 +724,10 @@ Kaikki keskustelut ja viestit poistetaan - tätä ei voi kumota! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Kaikki tiedot poistetaan, kun se syötetään. @@ -884,6 +910,10 @@ Luodaan tyhjä chat-profiili annetulla nimellä, ja sovellus avautuu normaalisti. No comment provided by engineer. + + Another reason + report reason + Answer call Vastaa puheluun @@ -907,6 +937,10 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Sovelluksen kuvake @@ -949,6 +983,10 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -957,6 +995,14 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -965,6 +1011,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Liitä @@ -1162,6 +1212,10 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Chat-profiilin mukaan (oletus) tai [yhteyden mukaan](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1244,6 +1298,10 @@ Muuta No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1446,6 +1504,10 @@ Tyhjennä keskustelu? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1463,6 +1525,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Vertaa tiedostoa @@ -1664,6 +1730,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Yhteysvirhe @@ -1674,6 +1744,15 @@ This is your own one-time link! Yhteysvirhe (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1683,6 +1762,10 @@ This is your own one-time link! Yhteyspyyntö lähetetty! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1756,6 +1839,10 @@ This is your own one-time link! Kontaktit voivat merkitä viestit poistettaviksi; voit katsella niitä. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Jatka @@ -1824,6 +1911,10 @@ This is your own one-time link! Luo linkki No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻 @@ -2029,7 +2120,6 @@ This is your own one-time link! Delete Poista alert action - chat item action swipe action @@ -2068,6 +2158,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Poista keskusteluprofiili @@ -2155,6 +2249,10 @@ This is your own one-time link! Poista linkki? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Poista jäsenviesti? @@ -2168,7 +2266,7 @@ This is your own one-time link! Delete messages Poista viestit - No comment provided by engineer. + alert button Delete messages after @@ -2204,6 +2302,10 @@ This is your own one-time link! Poista jono server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2349,6 +2451,14 @@ This is your own one-time link! Poista SimpleX Lock käytöstä authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Poista käytöstä kaikilta @@ -2432,6 +2542,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Älä luo osoitetta @@ -2447,6 +2561,10 @@ This is your own one-time link! Älä näytä uudelleen No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Alenna ja avaa keskustelu @@ -2541,7 +2659,7 @@ This is your own one-time link! Enable automatic message deletion? Ota automaattinen viestien poisto käyttöön? - No comment provided by engineer. + alert title Enable camera access @@ -2660,6 +2778,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Syötä pääsykoodi @@ -2781,6 +2903,10 @@ This is your own one-time link! Virhe ryhmälinkin luomisessa No comment provided by engineer. + + Error creating list + alert title + Error creating member contact No comment provided by engineer. @@ -2794,6 +2920,10 @@ This is your own one-time link! Virhe profiilin luomisessa! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Virhe tiedoston salauksen purussa @@ -2902,6 +3032,10 @@ This is your own one-time link! Virhe poistettaessa jäsentä No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2911,6 +3045,10 @@ This is your own one-time link! Virhe ICE-palvelimien tallentamisessa No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Virhe ryhmäprofiilin tallentamisessa @@ -3107,15 +3245,24 @@ This is your own one-time link! Suosikki swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3451,6 +3598,10 @@ Error: %2$@ Ryhmä poistetaan sinulta - tätä ei voi perua! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Apua @@ -3513,6 +3664,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Miten @@ -3645,6 +3800,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -3985,6 +4148,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Live-viesti! @@ -4226,6 +4401,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4314,6 +4493,10 @@ This is your link for group %@! Moderoitu klo: %@ copied message info + + More + swipe action + More improvements are coming soon! Lisää parannuksia on tulossa pian! @@ -4469,6 +4652,18 @@ This is your link for group %@! Ei sovelluksen salasanaa Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Kontakteja ei ole valittu @@ -4563,6 +4758,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. @@ -4572,6 +4771,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4674,6 +4877,14 @@ Edellyttää VPN:n sallimista. Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Vain sinä voit lisätä viestireaktioita. @@ -4755,10 +4966,22 @@ Edellyttää VPN:n sallimista. Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5422,6 +5645,38 @@ Enable in *Network & servers* settings. Vastaa chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Pakollinen @@ -5585,6 +5840,10 @@ Enable in *Network & servers* settings. Tallenna ryhmäprofiili No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Tallenna tunnuslause ja avaa keskustelu @@ -6000,6 +6259,10 @@ Enable in *Network & servers* settings. Aseta 1 päivä No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Aseta kontaktin nimi… @@ -6298,6 +6561,11 @@ Enable in *Network & servers* settings. Joku notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6377,6 +6645,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6492,7 +6764,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6620,6 +6892,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Profiili jaetaan vain kontaktiesi kanssa. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6687,6 +6963,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti. @@ -7383,6 +7663,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7878,6 +8162,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8077,7 +8365,8 @@ Repeat connection request? default (%@) oletusarvo (%@) - pref value + delete after time + pref value default (no) @@ -8375,6 +8664,10 @@ Repeat connection request? %@ moderoi marked deleted chat item preview text + + moderator + member role + months kuukautta @@ -8387,7 +8680,7 @@ Repeat connection request? never ei koskaan - No comment provided by engineer. + delete after time new message 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 148156b07c..f0baecb04d 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -114,10 +118,12 @@ %@ server + Serveur %@ No comment provided by engineer. %@ servers + Serveurs %@ No comment provided by engineer. @@ -200,6 +206,10 @@ %d sec time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d message·s sauté·s @@ -270,11 +280,6 @@ %lld nouvelles langues d'interface No comment provided by engineer. - - %lld second(s) - %lld seconde·s - No comment provided by engineer. - %lld seconds %lld secondes @@ -382,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + **Scanner / Coller** : pour vous connecter via un lien que vous avez reçu. No comment provided by engineer. @@ -468,7 +474,8 @@ 1 day 1 jour - time interval + delete after time + time interval 1 hour @@ -483,19 +490,27 @@ 1 month 1 mois - time interval + delete after time + time interval 1 week 1 semaine - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + Lien unique No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Le lien unique peut être utilisé *avec un seul contact* - partagez le en personne ou via n'importe quelle messagerie. No comment provided by engineer. @@ -574,6 +589,7 @@ About operators + À propos des opérateurs No comment provided by engineer. @@ -590,6 +606,7 @@ Accept conditions + Accepter les conditions No comment provided by engineer. @@ -610,6 +627,7 @@ Accepted conditions + Conditions acceptées No comment provided by engineer. @@ -634,6 +652,11 @@ Add friends + Ajouter des amis + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -653,6 +676,7 @@ Add team members + Ajouter des membres à l'équipe No comment provided by engineer. @@ -660,6 +684,10 @@ Ajouter à un autre appareil No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Ajouter un message d'accueil @@ -667,14 +695,17 @@ Add your team members to the conversations. + Ajoutez les membres de votre équipe aux conversations. No comment provided by engineer. Added media & file servers + Ajout de serveurs de médias et de fichiers No comment provided by engineer. Added message servers + Ajout de serveurs de messages No comment provided by engineer. @@ -704,10 +735,12 @@ Address or 1-time link? + Adresse ou lien unique ? No comment provided by engineer. Address settings + Paramètres de l'adresse No comment provided by engineer. @@ -730,6 +763,10 @@ Paramètres avancés No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Toutes les données de l'application sont supprimées. @@ -740,6 +777,10 @@ Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière ! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Toutes les données sont effacées lorsqu'il est saisi. @@ -757,6 +798,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs. No comment provided by engineer. @@ -934,6 +976,10 @@ Un profil de chat vierge portant le nom fourni est créé et l'application s'ouvre normalement. No comment provided by engineer. + + Another reason + report reason + Answer call Répondre à l'appel @@ -959,6 +1005,10 @@ L'application chiffre les nouveaux fichiers locaux (sauf les vidéos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Icône de l'app @@ -1004,6 +1054,10 @@ Appliquer à No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiver et téléverser @@ -1014,6 +1068,14 @@ Archiver les contacts pour discuter plus tard. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Contacts archivés @@ -1024,6 +1086,10 @@ Archivage de la base de données No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Attacher @@ -1236,10 +1302,16 @@ Business address + Adresse professionnelle No comment provided by engineer. Business chats + Discussions professionnelles + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1333,8 +1405,13 @@ Changer No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Changer de profil de discussion authentication reason @@ -1385,14 +1462,17 @@ Chat + Discussions No comment provided by engineer. Chat already exists + La discussion existe déjà No comment provided by engineer. Chat already exists! + La discussion existe déjà ! No comment provided by engineer. @@ -1472,10 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! + La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière ! No comment provided by engineer. @@ -1485,10 +1567,12 @@ Check messages every 20 min. + Consulter les messages toutes les 20 minutes. No comment provided by engineer. Check messages when allowed. + Consulter les messages quand c'est possible. No comment provided by engineer. @@ -1546,6 +1630,10 @@ Effacer la conversation ? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Effacer les notes privées ? @@ -1566,6 +1654,10 @@ Mode de couleur No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Comparer le fichier @@ -1583,38 +1675,47 @@ Conditions accepted on: %@. + Conditions acceptées le : %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Les conditions sont acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Les conditions sont déjà acceptées pour ces opérateurs : **%@**. No comment provided by engineer. Conditions of use + Conditions d'utilisation No comment provided by engineer. Conditions will be accepted for enabled operators after 30 days. + Les conditions seront acceptées pour les opérateurs activés après 30 jours. No comment provided by engineer. Conditions will be accepted for operator(s): **%@**. + Les conditions seront acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions will be accepted for the operator(s): **%@**. + Les conditions seront acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Les conditions seront acceptées le : %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Les conditions seront automatiquement acceptées pour les opérateurs activés le : %@. No comment provided by engineer. @@ -1791,6 +1892,10 @@ Il s'agit de votre propre lien unique ! État de la connexion et des serveurs. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Erreur de connexion @@ -1801,6 +1906,15 @@ Il s'agit de votre propre lien unique ! Erreur de connexion (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Notifications de connexion @@ -1811,8 +1925,13 @@ Il s'agit de votre propre lien unique ! Demande de connexion envoyée ! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security + Sécurité des connexions No comment provided by engineer. @@ -1890,6 +2009,10 @@ Il s'agit de votre propre lien unique ! Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Continuer @@ -1932,6 +2055,7 @@ Il s'agit de votre propre lien unique ! Create 1-time link + Créer un lien unique No comment provided by engineer. @@ -1964,6 +2088,10 @@ Il s'agit de votre propre lien unique ! Créer un lien No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Créer un nouveau profil sur [l'application de bureau](https://simplex.chat/downloads/). 💻 @@ -2021,6 +2149,7 @@ Il s'agit de votre propre lien unique ! Current conditions text couldn't be loaded, you can review conditions via this link: + Le texte sur les conditions actuelles n'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien : No comment provided by engineer. @@ -2180,7 +2309,6 @@ Il s'agit de votre propre lien unique ! Delete Supprimer alert action - chat item action swipe action @@ -2220,6 +2348,11 @@ Il s'agit de votre propre lien unique ! Delete chat + Supprimer la discussion + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2234,6 +2367,7 @@ Il s'agit de votre propre lien unique ! Delete chat? + Supprimer la discussion ? No comment provided by engineer. @@ -2311,6 +2445,10 @@ Il s'agit de votre propre lien unique ! Supprimer le lien ? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Supprimer le message de ce membre ? @@ -2324,7 +2462,7 @@ Il s'agit de votre propre lien unique ! Delete messages Supprimer les messages - No comment provided by engineer. + alert button Delete messages after @@ -2361,6 +2499,10 @@ Il s'agit de votre propre lien unique ! Supprimer la file d'attente server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Supprimez jusqu'à 20 messages à la fois. @@ -2398,6 +2540,7 @@ Il s'agit de votre propre lien unique ! Delivered even when Apple drops them. + Distribués même quand Apple les oublie. No comment provided by engineer. @@ -2502,6 +2645,7 @@ Il s'agit de votre propre lien unique ! Direct messages between members are prohibited in this chat. + Les messages directs entre membres sont interdits dans cette discussion. No comment provided by engineer. @@ -2519,6 +2663,14 @@ Il s'agit de votre propre lien unique ! Désactiver SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Désactiver pour tous @@ -2609,6 +2761,10 @@ Il s'agit de votre propre lien unique ! Ne pas utiliser d'identifiants avec le proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Ne pas créer d'adresse @@ -2624,6 +2780,10 @@ Il s'agit de votre propre lien unique ! Ne plus afficher No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Rétrograder et ouvrir le chat @@ -2687,6 +2847,7 @@ Il s'agit de votre propre lien unique ! E2E encrypted notifications. + Notifications chiffrées E2E. No comment provided by engineer. @@ -2711,6 +2872,7 @@ Il s'agit de votre propre lien unique ! Enable Flux + Activer Flux No comment provided by engineer. @@ -2726,7 +2888,7 @@ Il s'agit de votre propre lien unique ! Enable automatic message deletion? Activer la suppression automatique des messages ? - No comment provided by engineer. + alert title Enable camera access @@ -2853,6 +3015,10 @@ Il s'agit de votre propre lien unique ! La renégociation du chiffrement a échoué. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Entrer le code d'accès @@ -2920,6 +3086,7 @@ Il s'agit de votre propre lien unique ! Error accepting conditions + Erreur lors de la validation des conditions alert title @@ -2934,6 +3101,7 @@ Il s'agit de votre propre lien unique ! Error adding server + Erreur lors de l'ajout du serveur alert title @@ -2981,6 +3149,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la création du lien du groupe No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Erreur lors de la création du contact du membre @@ -2996,6 +3168,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la création du profil ! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Erreur lors du déchiffrement du fichier @@ -3078,6 +3254,7 @@ Il s'agit de votre propre lien unique ! Error loading servers + Erreur de chargement des serveurs alert title @@ -3110,6 +3287,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la suppression d'un membre No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Erreur de réinitialisation des statistiques @@ -3120,6 +3301,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la sauvegarde des serveurs ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Erreur lors de la sauvegarde du profil de groupe @@ -3137,6 +3322,7 @@ Il s'agit de votre propre lien unique ! Error saving servers + Erreur d'enregistrement des serveurs alert title @@ -3211,6 +3397,7 @@ Il s'agit de votre propre lien unique ! Error updating server + Erreur de mise à jour du serveur alert title @@ -3260,6 +3447,7 @@ Il s'agit de votre propre lien unique ! Errors in servers configuration. + Erreurs dans la configuration des serveurs. servers error @@ -3327,10 +3515,14 @@ Il s'agit de votre propre lien unique ! Favoris swipe action + + Favorites + No comment provided by engineer. + File error Erreur de fichier - No comment provided by engineer. + file error alert title File errors: @@ -3339,6 +3531,11 @@ Il s'agit de votre propre lien unique ! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Fichier introuvable - le fichier a probablement été supprimé ou annulé. @@ -3466,6 +3663,7 @@ Il s'agit de votre propre lien unique ! For chat profile %@: + Pour le profil de discussion %@ : servers error @@ -3475,14 +3673,17 @@ Il s'agit de votre propre lien unique ! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. No comment provided by engineer. For private routing + Pour le routage privé No comment provided by engineer. For social media + Pour les réseaux sociaux No comment provided by engineer. @@ -3704,6 +3905,10 @@ Erreur : %2$@ Le groupe va être supprimé pour vous - impossible de revenir en arrière ! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Aide @@ -3761,12 +3966,18 @@ Erreur : %2$@ How it affects privacy + L'impact sur la vie privée No comment provided by engineer. How it helps privacy + Comment il contribue à la protection de la vie privée No comment provided by engineer. + + How it works + alert button + How to Comment faire @@ -3909,6 +4120,14 @@ D'autres améliorations sont à venir ! Sons d'appel No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4063,6 +4282,7 @@ D'autres améliorations sont à venir ! Invite to chat + Inviter à discuter No comment provided by engineer. @@ -4225,10 +4445,12 @@ Voici votre lien pour le groupe %@ ! Leave chat + Quitter la discussion No comment provided by engineer. Leave chat? + Quitter la discussion ? No comment provided by engineer. @@ -4271,6 +4493,18 @@ Voici votre lien pour le groupe %@ ! Bureaux liés No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Message dynamique ! @@ -4363,6 +4597,7 @@ Voici votre lien pour le groupe %@ ! Member role will be changed to "%@". All chat members will be notified. + Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés. No comment provided by engineer. @@ -4377,6 +4612,7 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! + Le membre sera retiré de la discussion - cela ne peut pas être annulé ! No comment provided by engineer. @@ -4529,6 +4765,10 @@ Voici votre lien pour le groupe %@ ! Les messages de %@ seront affichés ! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Messages reçus @@ -4629,6 +4869,10 @@ Voici votre lien pour le groupe %@ ! Modéré à : %@ copied message info + + More + swipe action + More improvements are coming soon! Plus d'améliorations à venir ! @@ -4641,6 +4885,7 @@ Voici votre lien pour le groupe %@ ! More reliable notifications + Notifications plus fiables No comment provided by engineer. @@ -4680,6 +4925,7 @@ Voici votre lien pour le groupe %@ ! Network decentralization + Décentralisation du réseau No comment provided by engineer. @@ -4694,6 +4940,7 @@ Voici votre lien pour le groupe %@ ! Network operator + Opérateur de réseau No comment provided by engineer. @@ -4753,6 +5000,7 @@ Voici votre lien pour le groupe %@ ! New events + Nouveaux événements notification @@ -4782,6 +5030,7 @@ Voici votre lien pour le groupe %@ ! New server + Nouveau serveur No comment provided by engineer. @@ -4794,6 +5043,18 @@ Voici votre lien pour le groupe %@ ! Pas de mot de passe pour l'app Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Aucun contact sélectionné @@ -4841,10 +5102,12 @@ Voici votre lien pour le groupe %@ ! No media & file servers. + Pas de serveurs de médias et de fichiers. servers error No message servers. + Pas de serveurs de messages. servers error @@ -4879,20 +5142,28 @@ Voici votre lien pour le groupe %@ ! No servers for private message routing. + Pas de serveurs pour le routage privé des messages. servers error No servers to receive files. + Pas de serveurs pour recevoir des fichiers. servers error No servers to receive messages. + Pas de serveurs pour recevoir des messages. servers error No servers to send files. + Pas de serveurs pour envoyer des fichiers. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Aucun identifiant d'utilisateur. @@ -4903,6 +5174,10 @@ Voici votre lien pour le groupe %@ ! Non compatible ! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Aucune sélection @@ -4925,6 +5200,7 @@ Voici votre lien pour le groupe %@ ! Notifications privacy + Notifications sécurisées No comment provided by engineer. @@ -4982,6 +5258,7 @@ Nécessite l'activation d'un VPN. Only chat owners can change preferences. + Seuls les propriétaires peuvent modifier les préférences. No comment provided by engineer. @@ -5009,6 +5286,14 @@ Nécessite l'activation d'un VPN. Seuls les propriétaires de groupes peuvent activer les messages vocaux. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Vous seul pouvez ajouter des réactions aux messages. @@ -5071,6 +5356,7 @@ Nécessite l'activation d'un VPN. Open changes + Ouvrir les modifications No comment provided by engineer. @@ -5085,6 +5371,7 @@ Nécessite l'activation d'un VPN. Open conditions + Ouvrir les conditions No comment provided by engineer. @@ -5092,11 +5379,23 @@ Nécessite l'activation d'un VPN. Ouvrir le groupe No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Ouvrir le transfert vers un autre appareil authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Ouverture de l'app… @@ -5104,14 +5403,17 @@ Nécessite l'activation d'un VPN. Operator + Opérateur No comment provided by engineer. Operator server + Serveur de l'opérateur alert title Or import archive file + Ou importer un fichier d'archive No comment provided by engineer. @@ -5136,6 +5438,7 @@ Nécessite l'activation d'un VPN. Or to share privately + Ou à partager en privé No comment provided by engineer. @@ -5356,6 +5659,7 @@ Erreur : %@ Preset servers + Serveurs prédéfinis No comment provided by engineer. @@ -5375,6 +5679,7 @@ Erreur : %@ Privacy for your customers. + Respect de la vie privée de vos clients. No comment provided by engineer. @@ -5820,6 +6125,38 @@ Activez-le dans les paramètres *Réseau et serveurs*. Répondre chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Requis @@ -5907,10 +6244,12 @@ Activez-le dans les paramètres *Réseau et serveurs*. Review conditions + Vérifier les conditions No comment provided by engineer. Review later + Vérifier plus tard No comment provided by engineer. @@ -5994,6 +6333,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Enregistrer le profil du groupe No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Enregistrer la phrase secrète et ouvrir le chat @@ -6361,6 +6704,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Server added to operator %@. + Serveur ajouté à l'opérateur %@. alert message @@ -6380,14 +6724,17 @@ Activez-le dans les paramètres *Réseau et serveurs*. Server operator changed. + L'opérateur du serveur a changé. alert title Server operators + Opérateurs de serveur No comment provided by engineer. Server protocol changed. + Le protocole du serveur a été modifié. alert title @@ -6445,6 +6792,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Définir 1 jour No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Définir le nom du contact… @@ -6518,10 +6869,12 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share 1-time link with a friend + Partager un lien unique avec un ami No comment provided by engineer. Share SimpleX address on social media. + Partagez votre adresse SimpleX sur les réseaux sociaux. No comment provided by engineer. @@ -6531,6 +6884,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share address publicly + Partager publiquement votre adresse No comment provided by engineer. @@ -6625,6 +6979,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application. No comment provided by engineer. @@ -6659,10 +7014,12 @@ Activez-le dans les paramètres *Réseau et serveurs*. SimpleX address and 1-time links are safe to share via any messenger. + Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n'importe quelle messagerie. No comment provided by engineer. SimpleX address or 1-time link? + Adresse SimpleX ou lien unique ? No comment provided by engineer. @@ -6758,6 +7115,8 @@ Activez-le dans les paramètres *Réseau et serveurs*. Some servers failed the test: %@ + Certains serveurs ont échoué le test : +%@ alert message @@ -6765,6 +7124,11 @@ Activez-le dans les paramètres *Réseau et serveurs*. Quelqu'un notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Carré, circulaire, ou toute autre forme intermédiaire. @@ -6850,6 +7214,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Arrêt du chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Fort @@ -6937,6 +7305,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Tap Create SimpleX address in the menu to create it later. + Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. No comment provided by engineer. @@ -6977,7 +7346,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Temporary file error Erreur de fichier temporaire - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7028,6 +7397,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The app protects your privacy by using different operators in each conversation. + L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. No comment provided by engineer. @@ -7047,6 +7417,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The connection reached the limit of undelivered messages, your contact may be offline. + La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. No comment provided by engineer. @@ -7109,16 +7480,23 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le profil n'est partagé qu'avec vos contacts. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. + Les mêmes conditions s'appliquent à l'opérateur **%@**. No comment provided by engineer. The same conditions will apply to operator(s): **%@**. + Les mêmes conditions s'appliquent à(aux) l'opérateur(s) : **%@**. No comment provided by engineer. The second preset operator in the app! + Le deuxième opérateur prédéfini de l'application ! No comment provided by engineer. @@ -7138,6 +7516,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The servers for new files of your current chat profile **%@**. + Les serveurs pour les nouveaux fichiers de votre profil de chat actuel **%@**. No comment provided by engineer. @@ -7157,6 +7536,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. These conditions will also apply for: **%@**. + Ces conditions s'appliquent également aux : **%@**. No comment provided by engineer. @@ -7179,6 +7559,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. @@ -7261,6 +7645,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. To protect against your link being replaced, you can compare contact security codes. + Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. No comment provided by engineer. @@ -7287,6 +7672,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To receive + Pour recevoir No comment provided by engineer. @@ -7311,6 +7697,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To send + Pour envoyer No comment provided by engineer. @@ -7320,6 +7707,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To use the servers of **%@**, accept conditions of use. + Pour utiliser les serveurs de **%@**, acceptez les conditions d'utilisation. No comment provided by engineer. @@ -7414,6 +7802,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Undelivered messages + Messages non distribués No comment provided by engineer. @@ -7575,6 +7964,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use %@ + Utiliser %@ No comment provided by engineer. @@ -7604,10 +7994,12 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use for files + Utiliser pour les fichiers No comment provided by engineer. Use for messages + Utiliser pour les messages No comment provided by engineer. @@ -7652,6 +8044,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use servers + Utiliser les serveurs No comment provided by engineer. @@ -7746,6 +8139,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien View conditions + Voir les conditions No comment provided by engineer. @@ -7755,6 +8149,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien View updated conditions + Voir les conditions mises à jour No comment provided by engineer. @@ -7869,6 +8264,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + Lorsque plusieurs opérateurs sont activés, aucun d'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. No comment provided by engineer. @@ -7941,6 +8337,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Serveur XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Vous **ne devez pas** utiliser la même base de données sur deux appareils. @@ -7968,6 +8368,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien You are already connected with %@. + Vous êtes déjà connecté avec %@. No comment provided by engineer. @@ -8034,10 +8435,12 @@ Répéter la demande d'adhésion ? You can configure operators in Network & servers settings. + Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. No comment provided by engineer. You can configure servers via settings. + Vous pouvez configurer les serveurs via les paramètres. No comment provided by engineer. @@ -8082,6 +8485,7 @@ Répéter la demande d'adhésion ? You can set connection name, to remember who the link was shared with. + Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. No comment provided by engineer. @@ -8243,6 +8647,7 @@ Répéter la demande de connexion ? You will stop receiving messages from this chat. Chat history will be preserved. + Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé. No comment provided by engineer. @@ -8387,6 +8792,7 @@ Répéter la demande de connexion ? Your servers + Vos serveurs No comment provided by engineer. @@ -8431,6 +8837,7 @@ Répéter la demande de connexion ? accepted invitation + invitation acceptée chat list item title @@ -8468,6 +8875,10 @@ Répéter la demande de connexion ? et %lld autres événements No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts tentatives @@ -8676,7 +9087,8 @@ Répéter la demande de connexion ? default (%@) défaut (%@) - pref value + delete after time + pref value default (no) @@ -8815,6 +9227,7 @@ Répéter la demande de connexion ? for better metadata privacy. + pour une meilleure protection des métadonnées. No comment provided by engineer. @@ -8982,6 +9395,10 @@ Répéter la demande de connexion ? modéré par %@ marked deleted chat item preview text + + moderator + member role + months mois @@ -8995,7 +9412,7 @@ Répéter la demande de connexion ? never jamais - No comment provided by engineer. + delete after time new message @@ -9116,6 +9533,7 @@ Répéter la demande de connexion ? requested to connect + demande à se connecter chat list item title @@ -9450,22 +9868,27 @@ dernier message reçu : %2$@ %d new events + %d nouveaux événements notification body From: %@ + De : %@ notification body New events + Nouveaux événements notification New messages + Nouveaux messages notification New messages in %d chats + Nouveaux messages dans %d chats notification body diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index 2fd96e3492..286533d5fe 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -114,12 +114,12 @@ %lld - + No comment provided by engineer. %lld %@ - + No comment provided by engineer. @@ -144,12 +144,12 @@ %lldd - + No comment provided by engineer. %lldh - + No comment provided by engineer. @@ -158,7 +158,7 @@ %lldm - + No comment provided by engineer. @@ -173,12 +173,14 @@ %lldw No comment provided by engineer. - + ( + ( No comment provided by engineer. - + ) + ) No comment provided by engineer. @@ -248,22 +250,22 @@ 1 day - 1 dan + 1 dan message ttl 1 hour - 1 sat + 1 sat message ttl 1 month - 1 mjesec + 1 mesec message ttl 1 week - 1 tjedan + 1 nedelja message ttl @@ -1518,16 +1520,19 @@ Immune to spam No comment provided by engineer. - + Import + Uvesti No comment provided by engineer. - + Import chat database? + Uvesti data bazu razgovora? No comment provided by engineer. - + Import database + Uvesti data bazu No comment provided by engineer. @@ -3152,8 +3157,9 @@ SimpleX servers cannot see your profile. \_italic_ No comment provided by engineer. - + \`a + b` + \`a + b` No comment provided by engineer. @@ -3613,6 +3619,102 @@ SimpleX servers cannot see your profile. \~strike~ No comment provided by engineer. + + # %@ + # %@ + + + %@ server + %@ server + + + %@ servers + %@ serveri + + + Import failed + Uvoz neuspešan + + + %@ downloaded + %@ preuzeto + + + %@ uploaded + %@ otpremljeno + + + 1 minute + 1 minut + + + Password + Šifra + + + ## History + ## Istorija + + + %@ (current) + %@ (trenutan) + + + %@ and %@ + %@ i %@ + + + %@ connected + %@ povezan + + + 0 sec + 0 sek + + + 5 minutes + 5 minuta + + + %@ (current): + %@ (trenutan): + + + %@ and %@ connected + %@ i %@ su povezani + + + %@: + %@: + + + %1$@ at %2$@: + %1$@ u %2$@: + + + 30 seconds + 30 sekundi + + + Password to show + Prikazati šifru + + + %1$@, %2$@ + %1$@, %2$@ + + + 0s + 0s + + + Import theme + Uvesti temu + + + Immediately + Odmah + 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 231c33523d..49f863e810 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d mp time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d üzenet kihagyva @@ -224,7 +232,7 @@ %lld contact(s) selected - %lld ismerős kiválasztva + %lld ismerős kijelölve No comment provided by engineer. @@ -272,11 +280,6 @@ %lld új kezelőfelületi nyelv No comment provided by engineer. - - %lld second(s) - %lld másodperc - No comment provided by engineer. - %lld seconds %lld másodperc @@ -359,7 +362,7 @@ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. - **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken megosztásra kerül a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van. + **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van. No comment provided by engineer. @@ -374,12 +377,12 @@ **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Megjegyzés:** NEM tudja visszaállítani vagy megváltoztatni jelmondatát, ha elveszíti azt. + **Megjegyzés:** NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti. No comment provided by engineer. **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. - **Megjegyzés:** az eszköztoken és az értesítések elküldésre kerülnek a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. + **Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. No comment provided by engineer. @@ -394,7 +397,7 @@ **Warning**: the archive will be removed. - **Figyelmeztetés:** az archívum eltávolításra kerül. + **Figyelmeztetés:** az archívum el lesz távolítva. No comment provided by engineer. @@ -471,7 +474,8 @@ 1 day 1 nap - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 hónap - time interval + delete after time + time interval 1 week 1 hét - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -500,7 +510,7 @@ 1-time link can be used *with one contact only* - share in person or via any messenger. - Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* - személyesen vagy bármilyen üzenetküldőn keresztül megosztható. + Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható. No comment provided by engineer. @@ -532,7 +542,7 @@ A few more things - Még néhány dolog + Néhány további dolog No comment provided by engineer. @@ -542,7 +552,7 @@ A new random profile will be shared. - Egy új, véletlenszerű profil kerül megosztásra. + Egy új, véletlenszerű profil lesz megosztva. No comment provided by engineer. @@ -622,12 +632,12 @@ Acknowledged - Nyugtázva + Visszaigazolt No comment provided by engineer. Acknowledgement errors - Nyugtázott hibák + Visszaigazolási hibák No comment provided by engineer. @@ -637,7 +647,7 @@ Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés elküldésre kerül az ismerősei számára. + Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára. No comment provided by engineer. @@ -645,6 +655,10 @@ Barátok hozzáadása No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Profil hozzáadása @@ -662,7 +676,7 @@ Add team members - Csapattagok hozzáadása + Munkatársak hozzáadása No comment provided by engineer. @@ -670,6 +684,10 @@ Hozzáadás egy másik eszközhöz No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Üdvözlőüzenet hozzáadása @@ -677,7 +695,7 @@ Add your team members to the conversations. - Adja hozzá csapattagjait a beszélgetésekhez. + Adja hozzá a munkatársait a beszélgetésekhez. No comment provided by engineer. @@ -712,7 +730,7 @@ Address change will be aborted. Old receiving address will be used. - A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra. + A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva. No comment provided by engineer. @@ -745,6 +763,10 @@ Speciális beállítások No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Az összes alkalmazásadat törölve. @@ -752,17 +774,21 @@ All chats and messages will be deleted - this cannot be undone! - Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. - A jelkód megadása után az összes adat törlésre kerül. + A jelkód megadása után az összes adat törölve lesz. No comment provided by engineer. All data is kept private on your device. - Az összes adat biztonságban van az eszközén. + Az összes adat privát módon van tárolva az Ön eszközén. No comment provided by engineer. @@ -772,22 +798,22 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. - Az összes üzenetet és fájlt **végpontok közötti titkosítással** küldi, a közvetlen üzenetekben pedig kvantumrezisztens biztonsággal. + Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek. No comment provided by engineer. All messages will be deleted - this cannot be undone! - Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza! + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. - Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. No comment provided by engineer. All new messages from %@ will be hidden! - Az összes új üzenet elrejtésre kerül tőle: %@! + Az összes %@ által írt új üzenet el lesz rejtve! No comment provided by engineer. @@ -802,12 +828,12 @@ All your contacts will remain connected. Profile update will be sent to your contacts. - Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél. + Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára. No comment provided by engineer. All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra. + Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra. No comment provided by engineer. @@ -942,7 +968,7 @@ Always use relay - Mindig használjon közvetítő-kiszolgálót + Mindig használjon továbbító-kiszolgálót No comment provided by engineer. @@ -950,6 +976,10 @@ Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik. No comment provided by engineer. + + Another reason + report reason + Answer call Hívás fogadása @@ -962,7 +992,7 @@ App build: %@ - Az alkalmazás build száma: %@ + Az alkalmazás összeállítási száma: %@ No comment provided by engineer. @@ -975,6 +1005,10 @@ Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Alkalmazásikon @@ -987,7 +1021,7 @@ App passcode is replaced with self-destruct passcode. - Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal. + Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. No comment provided by engineer. @@ -1020,6 +1054,10 @@ Alkalmazás erre No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiválás és feltöltés @@ -1030,6 +1068,14 @@ Az ismerősök archiválása a későbbi csevegéshez. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Archivált ismerősök @@ -1040,6 +1086,10 @@ Adatbázis archiválása No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Csatolás @@ -1117,7 +1167,7 @@ Bad desktop address - Hibás számítógép cím + Érvénytelen számítógépcím No comment provided by engineer. @@ -1127,7 +1177,7 @@ Bad message hash - Hibás az üzenet hasító értéke + Érvénytelen az üzenet hasító-értéke No comment provided by engineer. @@ -1260,6 +1310,10 @@ Üzleti csevegések No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA). @@ -1338,7 +1392,7 @@ Capacity exceeded - recipient did not receive previously sent messages. - Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket. + Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. snd error text @@ -1348,9 +1402,13 @@ Change - Változtatás + Megváltoztatás No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Csevegési profilok megváltoztatása @@ -1358,7 +1416,7 @@ Change database passphrase? - Adatbázis-jelmondat megváltoztatása? + Az adatbázis jelmondatának megváltoztatása? No comment provided by engineer. @@ -1378,12 +1436,12 @@ Change receiving address - A fogadó cím megváltoztatása + A fogadócím megváltoztatása No comment provided by engineer. Change receiving address? - Megváltoztatja a fogadó címet? + Megváltoztatja a fogadócímet? No comment provided by engineer. @@ -1393,12 +1451,12 @@ Change self-destruct mode - Önmegsemmisítő mód megváltoztatása + Önmegsemmisítő-mód megváltoztatása authentication reason Change self-destruct passcode - Önmegsemmisító jelkód megváltoztatása + Önmegsemmisítő-jelkód megváltoztatása authentication reason set passcode view @@ -1454,12 +1512,12 @@ Chat is stopped - A csevegés leállt + A csevegés megállt No comment provided by engineer. Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. - A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt. + A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. No comment provided by engineer. @@ -1494,12 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! - A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni! + A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni! + A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -1544,17 +1602,17 @@ Chunks deleted - Törölt fájltöredékek + Törölt töredékek No comment provided by engineer. Chunks downloaded - Letöltött fájltöredékek + Letöltött töredékek No comment provided by engineer. Chunks uploaded - Feltöltött fájltöredékek + Feltöltött töredékek No comment provided by engineer. @@ -1572,6 +1630,10 @@ Üzenetek kiürítése? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Privát jegyzetek kiürítése? @@ -1592,6 +1654,10 @@ Színmód No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Fájl összehasonlítás @@ -1649,7 +1715,7 @@ Conditions will be automatically accepted for enabled operators on: %@. - A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltető számára: %@. + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %@. No comment provided by engineer. @@ -1826,6 +1892,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolatok- és kiszolgálók állapotának megjelenítése. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Kapcsolódási hiba @@ -1836,6 +1906,15 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolódási hiba (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Kapcsolódási értesítések @@ -1846,6 +1925,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolatkérés elküldve! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Kapcsolatbiztonság @@ -1913,7 +1996,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Contact will be deleted - this cannot be undone! - Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza! + Az ismerős törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -1926,6 +2009,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Folytatás @@ -1958,7 +2045,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Correct name to %@? - Név javítása erre: %@? + Név helyesbítése a következőre: %@? No comment provided by engineer. @@ -2001,6 +2088,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hivatkozás létrehozása No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻 @@ -2058,7 +2149,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Current conditions text couldn't be loaded, you can review conditions via this link: - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: No comment provided by engineer. @@ -2129,14 +2220,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database encryption passphrase will be updated and stored in the keychain. - Az adatbázis titkosítási jelmondata frissítve lesz és a kulcstartóban kerül tárolásra. + Az adatbázis titkosítási jelmondata frissülni fog és a kulcstartóban lesz tárolva. No comment provided by engineer. Database encryption passphrase will be updated. - Az datbázis titkosítási jelmondata frissítve lesz. + Az datbázis titkosítási jelmondata frissülni fog. No comment provided by engineer. @@ -2167,7 +2258,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata eltér a kulcstartóban mentettől. + Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. No comment provided by engineer. @@ -2183,20 +2274,20 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database will be encrypted and the passphrase stored in the keychain. - Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban kerül tárolásra. + Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban lesz tárolva. No comment provided by engineer. Database will be encrypted. - Az adatbázis titkosításra kerül. + Az adatbázis titkosítva lesz. No comment provided by engineer. Database will be migrated when the app restarts - Az adatbázis az alkalmazás újraindításakor átköltöztetésre kerül + Az adatbázis az alkalmazás újraindításakor lesz átköltöztetve No comment provided by engineer. @@ -2218,7 +2309,6 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete Törlés alert action - chat item action swipe action @@ -2243,7 +2333,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete after - Törlés ennyi idő után + Törlés ennyi idő után: No comment provided by engineer. @@ -2261,6 +2351,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Csevegés törlése No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Csevegési profil törlése @@ -2351,6 +2445,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hivatkozás törlése? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Csoporttag üzenetének törlése? @@ -2364,11 +2462,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete messages Üzenetek törlése - No comment provided by engineer. + alert button Delete messages after - Üzenetek törlése ennyi idő után + Üzenetek törlése ennyi idő után: No comment provided by engineer. @@ -2401,6 +2499,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Sorbaállítás törlése server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Legfeljebb 20 üzenet egyszerre való törlése. @@ -2561,6 +2663,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! SimpleX-zár kikapcsolása authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Letiltás az összes tag számára @@ -2623,7 +2733,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do NOT send messages directly, even if your or destination server does not support private routing. - Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. No comment provided by engineer. @@ -2633,7 +2743,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do NOT use private routing. - Ne használjon privát útválasztást. + NE használjon privát útválasztást. No comment provided by engineer. @@ -2643,7 +2753,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do not send history to new members. - Az előzmények ne kerüljenek elküldésre az új tagok számára. + Az előzmények ne legyenek elküldve az új tagok számára. No comment provided by engineer. @@ -2651,6 +2761,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Ne használja a hitelesítőadatokat proxyval. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Ne hozzon létre címet @@ -2666,6 +2780,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Ne mutasd újra No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Visszafejlesztés és a csevegés megnyitása @@ -2770,7 +2888,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable automatic message deletion? Automatikus üzenet törlés engedélyezése? - No comment provided by engineer. + alert title Enable camera access @@ -2814,7 +2932,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable self-destruct passcode - Önmegsemmisítő jelkód engedélyezése + Önmegsemmisítő-jelkód engedélyezése set passcode view @@ -2859,7 +2977,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encrypted message: app is stopped - Titkosított üzenet: az alkalmazás leállt + Titkosított üzenet: az alkalmazás megállt notification @@ -2889,7 +3007,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encryption re-negotiation error - Titkosítás újraegyeztetési hiba + Titkosítás-újraegyeztetési hiba message decrypt error item @@ -2897,9 +3015,13 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Sikertelen titkosítás-újraegyeztetés. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode - Jelkód megadása + Adja meg a jelkódot No comment provided by engineer. @@ -2909,27 +3031,27 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enter group name… - Csoportnév megadása… + Adja meg a csoport nevét… No comment provided by engineer. Enter passphrase - Jelmondat megadása + Adja meg a jelmondatot No comment provided by engineer. Enter passphrase… - Jelmondat megadása… + Adja meg a jelmondatot… No comment provided by engineer. Enter password above to show! - Jelszó megadása a megjelenítéshez! + Adja meg a jelszót fentebb a megjelenítéshez! No comment provided by engineer. Enter server manually - Kiszolgáló megadása kézzel + Adja meg a kiszolgálót kézzel No comment provided by engineer. @@ -2939,12 +3061,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enter welcome message… - Üdvözlőüzenet megadása… + Adja meg az üdvözlőüzenetet… placeholder Enter welcome message… (optional) - Üdvözlőüzenet megadása… (nem kötelező) + Adja meg az üdvözlőüzenetet… (nem kötelező) placeholder @@ -3027,6 +3149,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a csoporthivatkozás létrehozásakor No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Hiba az ismerőssel történő kapcsolat létrehozásában @@ -3042,6 +3168,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a profil létrehozásakor! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Hiba a fájl visszafejtésekor @@ -3157,6 +3287,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a tag eltávolításakor No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Hiba a statisztikák visszaállításakor @@ -3167,6 +3301,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba az ICE-kiszolgálók mentésekor No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Hiba a csoportprofil mentésekor @@ -3377,10 +3515,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kedvenc swipe action + + Favorites + No comment provided by engineer. + File error Fájlhiba - No comment provided by engineer. + file error alert title File errors: @@ -3389,9 +3531,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. - A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták. + A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták. file error text @@ -3635,12 +3782,12 @@ Hiba: %2$@ Fully decentralized – visible only to members. - Teljesen decentralizált - csak a tagok számára látható. + Teljesen decentralizált – csak a tagok számára látható. No comment provided by engineer. Fully re-implemented - work in background! - Teljesen újra implementálva - háttérben történő működés! + Teljesen újra implementálva – háttérben történő működés! No comment provided by engineer. @@ -3685,7 +3832,7 @@ Hiba: %2$@ Group full name (optional) - Csoport teljes neve (nem kötelező) + A csoport teljes neve (nem kötelező) No comment provided by engineer. @@ -3750,12 +3897,16 @@ Hiba: %2$@ Group will be deleted for all members - this cannot be undone! - A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az összes tag számára – ez a művelet nem vonható vissza! No comment provided by engineer. Group will be deleted for you - this cannot be undone! - A csoport törlésre kerül az Ön számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza! + No comment provided by engineer. + + + Groups No comment provided by engineer. @@ -3805,7 +3956,7 @@ Hiba: %2$@ History is not sent to new members. - Az előzmények nem kerülnek elküldésre az új tagok számára. + Az előzmények nem lesznek elküldve az új tagok számára. No comment provided by engineer. @@ -3823,6 +3974,10 @@ Hiba: %2$@ Hogyan segíti az adatvédelmet No comment provided by engineer. + + How it works + alert button + How to Hogyan @@ -3860,17 +4015,17 @@ Hiba: %2$@ If you enter this passcode when opening the app, all app data will be irreversibly removed! - Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül! + Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! No comment provided by engineer. If you enter your self-destruct passcode while opening the app: - Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: + Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot: No comment provided by engineer. If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). - Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor felajánlásra kerül az adatbázis átköltöztetése). + Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). No comment provided by engineer. @@ -3965,6 +4120,14 @@ További fejlesztések hamarosan! Bejövő hívás csengőhangja No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Inkognitó @@ -4043,7 +4206,7 @@ További fejlesztések hamarosan! Instant push notifications will be hidden! - Az azonnali push-értesítések elrejtésre kerülnek! + Az azonnali push-értesítések el lesznek rejtve! No comment provided by engineer. @@ -4330,6 +4493,18 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Társított számítógépek No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Élő üzenet! @@ -4427,22 +4602,22 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Member role will be changed to "%@". All group members will be notified. - A tag szerepköre meg fog változni erre: „%@”. A csoportban az összes tag értesítve lesz. + A tag szerepköre meg fog változni a következőre: „%@”. A csoportban az összes tag értesítve lesz. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. - A tag szerepköre meg fog változni erre: „%@”. A tag új meghívást fog kapni. + A tag szerepköre meg fog változni a következőre: „%@”. A tag új meghívást fog kapni. No comment provided by engineer. Member will be removed from chat - this cannot be undone! - A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni! + A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! No comment provided by engineer. Member will be removed from group - this cannot be undone! - A tag eltávolítása a csoportból - ez a művelet nem vonható vissza! + A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -4590,6 +4765,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! A(z) %@ által írt üzenetek megjelennek! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Fogadott üzenetek @@ -4602,7 +4781,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages were deleted after you selected them. - Az üzeneteket törölték miután kiválasztotta őket. + Az üzeneteket törölték miután kijelölte őket. alert message @@ -4612,7 +4791,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. - Az üzeneteket, fájlokat és hívásokat **végpontok közötti kvantumrezisztens titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi. + Az üzenetek, fájlok és hívások **végpontok közötti kvantumálló titkosítással** sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve. No comment provided by engineer. @@ -4690,6 +4869,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Moderálva ekkor: %@ copied message info + + More + swipe action + More improvements are coming soon! Hamarosan további fejlesztések érkeznek! @@ -4707,7 +4890,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Most likely this connection is deleted. - Valószínűleg ez a kapcsolat törlésre került. + Valószínűleg ez a kapcsolat törölve lett. item status description @@ -4747,7 +4930,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Network issues - message expired after many attempts to send it. - Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt. + Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. snd error text @@ -4860,9 +5043,21 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincs alkalmazás jelszó Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected - Nincs kiválasztva ismerős + Nincs kijelölve ismerős No comment provided by engineer. @@ -4965,6 +5160,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincsenek fájlküldő-kiszolgálók. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Nincsenek felhasználó-azonosítók. @@ -4975,9 +5174,13 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nem kompatibilis! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected - Nincs kiválasztva semmi + Nincs kijelölve semmi No comment provided by engineer. @@ -5055,12 +5258,12 @@ VPN engedélyezése szükséges. Only chat owners can change preferences. - Csak a csevegés tulajdonosai módosíthatják a beállításokat. + Csak a csevegés tulajdonosai változtathatják meg a beállításokat. No comment provided by engineer. Only client devices store user profiles, contacts, groups, and messages. - Csak az eszközök alkalmazásai tárolják a felhasználó-profilokat, névjegyeket, csoportokat és a **2 rétegű végpontok közötti titkosítással** küldött üzeneteket. + A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. No comment provided by engineer. @@ -5070,7 +5273,7 @@ VPN engedélyezése szükséges. Only group owners can change group preferences. - Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat. + Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat. No comment provided by engineer. @@ -5083,6 +5286,14 @@ VPN engedélyezése szükséges. Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Csak Ön adhat hozzá üzenetreakciókat. @@ -5168,11 +5379,23 @@ VPN engedélyezése szükséges. Csoport megnyitása No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device - Átköltöztetés megkezdése egy másik eszközre + Átköltöztetés indítása egy másik eszközre authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Az alkalmazás megnyitása… @@ -5257,7 +5480,7 @@ VPN engedélyezése szükséges. Passcode not changed! - A jelkód nem változott! + A jelkód nem változott meg! No comment provided by engineer. @@ -5376,17 +5599,17 @@ Hiba: %@ Please enter correct current passphrase. - Adja meg a helyes, jelenlegi jelmondatát. + Adja meg a helyes, jelenlegi jelmondatot. No comment provided by engineer. Please enter the previous password after restoring database backup. This action can not be undone. - Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. + Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. No comment provided by engineer. Please remember or store it securely - there is no way to recover a lost passcode! - Jegyezze fel vagy tárolja el biztonságosan - az elveszett jelkódot nem lehet visszaállítani! + Jegyezze fel vagy tárolja el biztonságosan – az elveszett jelkódot nem lehet visszaállítani! No comment provided by engineer. @@ -5521,7 +5744,7 @@ Hiba: %@ Profile update will be sent to your contacts. - A profilfrissítés elküldésre került az ismerősök számára. + A profilfrissítés el lesz küldve az Ön ismerősei számára. alert message @@ -5582,7 +5805,7 @@ Hiba: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben. + Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben. Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. No comment provided by engineer. @@ -5628,7 +5851,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Quantum resistant encryption - Kvantumrezisztens titkosítás + Kvantumálló titkosítás No comment provided by engineer. @@ -5723,7 +5946,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Receiving address will be changed to a different server. Address change will complete after sender comes online. - A fogadó cím egy másik kiszolgálóra változik. A címváltoztatás a feladó online állapotba kerülése után fejeződik be. + A fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be. No comment provided by engineer. @@ -5819,12 +6042,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Relay server is only used if necessary. Another party can observe your IP address. - A közvetítő-kiszolgáló csak szükség esetén kerül használatra. 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ímet. No comment provided by engineer. Relay server protects your IP address, but it can observe the duration of the call. - A közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. + A továbbító-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. No comment provided by engineer. @@ -5902,6 +6125,38 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Válasz chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Szükséges @@ -6029,7 +6284,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SOCKS proxy - SOCKS proxy + SOCKS-proxy No comment provided by engineer. @@ -6078,6 +6333,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csoportprofil mentése No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Jelmondat mentése és a csevegés megnyitása @@ -6125,7 +6384,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Saved WebRTC ICE servers will be removed - A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek + A mentett WebRTC ICE-kiszolgálók el lesznek távolítva No comment provided by engineer. @@ -6220,22 +6479,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Select - Kiválasztás + Kijelölés chat item action Select chat profile - Csevegési profil kiválasztása + Csevegési profil kijelölése No comment provided by engineer. Selected %lld - %lld kiválasztva + %lld kijelölve No comment provided by engineer. Selected chat preferences prohibit this message. - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -6245,17 +6504,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Self-destruct passcode - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód No comment provided by engineer. Self-destruct passcode changed! - Az önmegsemmisítési jelkód megváltozott! + Az önmegsemmisítő-jelkód megváltozott! No comment provided by engineer. Self-destruct passcode enabled! - Az önmegsemmisítési jelkód engedélyezve! + Az önmegsemmisítő-jelkód engedélyezve! No comment provided by engineer. @@ -6265,7 +6524,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send a live message - it will update for the recipient(s) as you type it - Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja + Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet No comment provided by engineer. @@ -6350,12 +6609,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sending delivery receipts will be enabled for all contacts in all visible chat profiles. - A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára. + A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára. No comment provided by engineer. Sending delivery receipts will be enabled for all contacts. - A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül. + A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz. No comment provided by engineer. @@ -6385,7 +6644,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sending via - Küldés ezen keresztül + Küldés a következőn keresztül: No comment provided by engineer. @@ -6420,7 +6679,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sent messages will be deleted after set time. - Az elküldött üzenetek törlésre kerülnek a beállított idő után. + Az elküldött üzenetek törölve lesznek a beállított idő után. No comment provided by engineer. @@ -6520,7 +6779,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Servers statistics will be reset - this cannot be undone! - A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza! + A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -6533,6 +6792,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Beállítva 1 nap No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Ismerős nevének beállítása… @@ -6590,7 +6853,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Shape profile images - Profilkép alakzat + Profilkép alakzata No comment provided by engineer. @@ -6676,7 +6939,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Show last messages - Szobák utolsó üzeneteinek megjelenítése a listanézetben + Legutóbbi üzenet előnézetének megjelenítése No comment provided by engineer. @@ -6836,7 +7099,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Some file(s) were not exported: - Néhány fájl nem került exportálásra: + Néhány fájl nem lett exportálva: No comment provided by engineer. @@ -6861,6 +7124,11 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Valaki notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Négyzet, kör vagy bármi a kettő között. @@ -6946,6 +7214,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csevegés megállítása folyamatban No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Erős @@ -7074,7 +7346,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Temporary file error Ideiglenesfájl-hiba - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7108,14 +7380,14 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Thanks to the users – contribute via Weblate! - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! No comment provided by engineer. The ID of the next message is incorrect (less or equal to the previous). It can happen because of some bug or when the connection is compromised. - A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel). -Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. + A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel). +Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. No comment provided by engineer. @@ -7180,7 +7452,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The message will be deleted for all members. - Az üzenet az összes tag számára törlésre kerül. + Az üzenet az összes tag számára törölve lesz. No comment provided by engineer. @@ -7190,7 +7462,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The messages will be deleted for all members. - Az üzenetek az összes tag számára törlésre kerülnek. + Az üzenetek az összes tag számára törölve lesznek. No comment provided by engineer. @@ -7200,12 +7472,16 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The old database was not removed during the migration, it can be deleted. - A régi adatbázis nem került eltávolításra az átköltöztetéskor, így törölhető. + 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. The profile is only shared with your contacts. - A profilja csak az ismerőseivel kerül megosztásra. + A profilja csak az ismerőseivel van megosztva. + No comment provided by engineer. + + + The report will be archived for you. No comment provided by engineer. @@ -7235,12 +7511,12 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The servers for new connections of your current chat profile **%@**. - A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói **%@**. + A jelenlegi **%@** nevű csevegő-profiljához tartozó új kapcsolatok kiszolgálói. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - Az Ön jelenlegi **%@** nevű csevegőprofiljához tartozó új fájlok kiszolgálói. + A jelenlegi **%@** nevű csevegő-profiljához tartozó új fájlok kiszolgálói. No comment provided by engineer. @@ -7250,7 +7526,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The uploaded database archive will be permanently removed from the servers. - A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról. + A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. No comment provided by engineer. @@ -7265,7 +7541,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. These settings are for your current profile **%@**. - Ezek a beállítások csak a jelenlegi (**%@**) profiljára vonatkoznak. + Ezek a beállítások csak a jelenlegi **%@** nevű csevegő-profiljára vonatkoznak. No comment provided by engineer. @@ -7275,17 +7551,21 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerül. Az alacsony felbontású képek viszont megmaradnak. + Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai visszafordíthatatlanul törlésre kerülnek. + Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek. No comment provided by engineer. @@ -7295,7 +7575,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This chat is protected by quantum resistant end-to-end encryption. - Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett. + Ez a csevegés végpontok közötti kvantumálló tikosítással védett. E2EE info chat item @@ -7305,12 +7585,12 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This display name is invalid. Please choose another name. - Ez a megjelenített név érvénytelen. Válasszon egy másik nevet. + Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet. No comment provided by engineer. This group has over %lld members, delivery receipts are not sent. - Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem kerülnek elküldésre. + Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem lesznek elküldve. No comment provided by engineer. @@ -7335,7 +7615,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This setting applies to messages in your current chat profile **%@**. - Ez a beállítás csak a jelenlegi (**%@**) profiljában lévő üzenetekre vonatkozik. + Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegő-profiljában lévő üzenetekre vonatkozik. No comment provided by engineer. @@ -7412,7 +7692,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Rejtett profilja megjelenítéséhez írja be a teljes jelszavát a keresőmezőbe a **Csevegési profilok** menüben. + Rejtett profilja megjelenítéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben. No comment provided by engineer. @@ -7442,7 +7722,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Toggle incognito when connecting. - Inkognitómód használata kapcsolódáskor. + Inkognitóra váltás kapcsolódáskor. No comment provided by engineer. @@ -7619,7 +7899,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Up to 100 last messages are sent to new members. - Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. No comment provided by engineer. @@ -7629,7 +7909,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Update database passphrase - Adatbázis-jelmondat megváltoztatása + Az adatbázis jelmondatának megváltoztatása No comment provided by engineer. @@ -7694,7 +7974,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Use SOCKS proxy - SOCKS proxy használata + SOCKS-proxy használata No comment provided by engineer. @@ -7779,7 +8059,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc User selection - Felhasználó kiválasztása + Felhasználó kijelölése No comment provided by engineer. @@ -7834,7 +8114,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Via secure quantum resistant protocol. - Biztonságos kvantumrezisztens-protokollon keresztül. + Biztonságos kvantumálló-protokollon keresztül. No comment provided by engineer. @@ -7929,7 +8209,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Wallpaper accent - Háttérkép kiemelés + Háttérkép kiemelőszíne No comment provided by engineer. @@ -8029,27 +8309,27 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc 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ó lesz a következő XFTP-közvetítő-kiszolgálók számára: %@. + Tor vagy VPN nélkül az IP-címe látható lesz a következő XFTP-továbbító-kiszolgálók számára: %@. alert message Wrong database passphrase - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat No comment provided by engineer. Wrong key or unknown connection - most likely this connection is deleted. - Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött. + Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. snd error text Wrong key or unknown file chunk address - most likely file is deleted. - Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött. + Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. file error text Wrong passphrase! - Hibás jelmondat! + Érvénytelen jelmondat! No comment provided by engineer. @@ -8057,6 +8337,10 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc XFTP-kiszolgáló No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Nem szabad** ugyanazt az adatbázist használni egyszerre két eszközön. @@ -8079,7 +8363,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already connected to %@. - Ön már kapcsolódva van ehhez: %@. + Ön már kapcsolódva van a következőhöz: %@. No comment provided by engineer. @@ -8089,7 +8373,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already connecting to %@. - Már folyamatban van a kapcsolódás ehhez: %@. + Már folyamatban van a kapcsolódás a következőhöz: %@. No comment provided by engineer. @@ -8146,7 +8430,7 @@ Csatlakozáskérés megismétlése? You can change it in Appearance settings. - Ezt a „Megjelenés” menüben módosíthatja. + Ezt a „Megjelenés” menüben változtathatja meg. No comment provided by engineer. @@ -8181,7 +8465,7 @@ Csatlakozáskérés megismétlése? You can hide or mute a user profile - swipe it to the right. - Elrejtheti vagy lenémíthatja a felhasználó -profiljait - csúsztassa jobbra a profilt. + Elrejtheti vagy lenémíthatja a felhasználó -profiljait – csúsztassa jobbra a profilt. No comment provided by engineer. @@ -8211,7 +8495,7 @@ Csatlakozáskérés megismétlése? 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. - Megoszthat egy hivatkozást vagy QR-kódot - így bárki csatlakozhat a csoporthoz. Ha a csoport később törlésre kerül, akkor nem fogja elveszíteni annak tagjait. + 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. No comment provided by engineer. @@ -8273,7 +8557,7 @@ Kapcsolatkérés megismétlése? You have to enter passphrase every time the app starts - it is not stored on the device. - A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra. + A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. No comment provided by engineer. @@ -8283,12 +8567,12 @@ Kapcsolatkérés megismétlése? You joined this group - Csatlakozott ehhez a csoporthoz + Ön csatlakozott ehhez a csoporthoz No comment provided by engineer. You joined this group. Connecting to inviting group member. - Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. + Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. No comment provided by engineer. @@ -8338,7 +8622,7 @@ Kapcsolatkérés megismétlése? You will be connected when your connection request is accepted, please wait or check later! - Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később! No comment provided by engineer. @@ -8383,7 +8667,7 @@ Kapcsolatkérés megismétlése? You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed - 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 + 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 No comment provided by engineer. @@ -8453,7 +8737,7 @@ Kapcsolatkérés megismétlése? Your current chat database will be DELETED and REPLACED with the imported one. - A jelenlegi csevegési adatbázis TÖRLŐDNI FOG, és a HELYÉRE az importált adatbázis kerül. + A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra. No comment provided by engineer. @@ -8478,22 +8762,22 @@ Kapcsolatkérés megismétlése? Your profile **%@** will be shared. - A(z) **%@** nevű profilja megosztásra fog kerülni. + A(z) **%@** nevű profilja meg lesz osztva. 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 az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját. + A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. No comment provided by engineer. Your profile was changed. If you save it, the updated profile will be sent to all your contacts. - A profilja megváltozott. Ha elmenti, a frissített profil elküldésre kerül az összes ismerősének. + A profilja megváltozott. Ha elmenti, a profilfrissítés el lesz küldve az Ön ismerősei számára. alert message Your profile, contacts and delivered messages are stored on your device. - A profilja, az ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra. + A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva. No comment provided by engineer. @@ -8591,6 +8875,10 @@ Kapcsolatkérés megismétlése? és további %lld esemény No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts próbálkozások @@ -8613,7 +8901,7 @@ Kapcsolatkérés megismétlése? bad message hash - hibás az üzenet hasító értéke + érvénytelen az üzenet hasító-értéke integrity error chat item @@ -8663,22 +8951,22 @@ Kapcsolatkérés megismétlése? changed address for you - cím megváltoztatva + megváltoztatta a címet az Ön számára chat item text changed role of %1$@ to %2$@ - %1$@ szerepkörét megváltoztatta erre: %2$@ + %1$@ szerepkörét megváltoztatta a következőre: %2$@ rcv group event chat item changed your role to %@ - megváltoztatta az Ön szerepkörét erre: %@ + megváltoztatta az Ön szerepkörét a következőre: %@ rcv group event chat item changing address for %@… - cím megváltoztatása nála: %@… + cím megváltoztatása %@ számára… chat item text @@ -8758,7 +9046,7 @@ Kapcsolatkérés megismétlése? contact %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét erre: %2$@ + %1$@ megváltoztatta a nevét a következőre: %2$@ profile update event chat item @@ -8783,7 +9071,7 @@ Kapcsolatkérés megismétlése? database version is newer than the app, but no down migration for: %@ - az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés ehhez: %@ + az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés a következőhöz: %@ No comment provided by engineer. @@ -8799,7 +9087,8 @@ Kapcsolatkérés megismétlése? default (%@) alapértelmezett (%@) - pref value + delete after time + pref value default (no) @@ -8888,27 +9177,27 @@ Kapcsolatkérés megismétlése? encryption ok for %@ - titkosítás rendben vele: %@ + titkosítás rendben van %@ számára chat item text encryption re-negotiation allowed - titkosítás újraegyeztetés engedélyezve + titkosítás-újraegyeztetés engedélyezve chat item text encryption re-negotiation allowed for %@ - titkosítás újraegyeztetés engedélyezve vele: %@ + a titkosítás újraegyeztetése engedélyezve van %@ számára chat item text encryption re-negotiation required - titkosítás újraegyeztetés szükséges + titkosítás-újraegyeztetés szükséges chat item text encryption re-negotiation required for %@ - titkosítás újraegyeztetés szükséges %@ számára + a titkosítás újraegyeztetése szükséges %@ számára chat item text @@ -8963,12 +9252,12 @@ Kapcsolatkérés megismétlése? iOS Keychain is used to securely store passphrase - it allows receiving push notifications. - Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását. No comment provided by engineer. iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Az iOS kulcstartó az alkalmazás újraindítása, vagy a jelmondat módosítása után a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi a push-értesítések fogadását. No comment provided by engineer. @@ -9068,7 +9357,7 @@ Kapcsolatkérés megismétlése? member %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét erre: %2$@ + %1$@ megváltoztatta a nevét a következőre: %2$@ profile update event chat item @@ -9106,6 +9395,10 @@ Kapcsolatkérés megismétlése? moderálva lett %@ által marked deleted chat item preview text + + moderator + member role + months hónap @@ -9119,7 +9412,7 @@ Kapcsolatkérés megismétlése? never soha - No comment provided by engineer. + delete after time new message @@ -9195,7 +9488,7 @@ Kapcsolatkérés megismétlése? quantum resistant e2e encryption - végpontok közötti kvantumrezisztens titkosítás + végpontok közötti kvantumálló titkosítás chat item text @@ -9374,7 +9667,7 @@ utoljára fogadott üzenet: %2$@ via contact address link - kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási cím-hivatkozáson keresztül chat list item description @@ -9384,12 +9677,12 @@ utoljára fogadott üzenet: %2$@ via one-time link - egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívó-hivatkozáson keresztül chat list item description via relay - közvetítő-kiszolgálón keresztül + egy továbbító-kiszolgálón keresztül No comment provided by engineer. @@ -9454,22 +9747,22 @@ utoljára fogadott üzenet: %2$@ you changed address - cím megváltoztatva + Ön megváltoztatta a címet chat item text you changed address for %@ - cím megváltoztatva nála: %@ + Ön megváltoztatta a címet %@ számára chat item text you changed role for yourself to %@ - saját szerepköre megváltozott erre: %@ + Ön megváltoztatta a saját szerepkörét a következőre: %@ snd group event chat item you changed role of %1$@ to %2$@ - Ön megváltoztatta %1$@ szerepkörét erre: %@ + Ön megváltoztatta %1$@ szerepkörét a következőre: %@ snd group event chat item @@ -9634,7 +9927,7 @@ utoljára fogadott üzenet: %2$@ App is locked! - Az alkalmazás zárolva! + Az alkalmazás zárolva van! No comment provided by engineer. @@ -9679,12 +9972,12 @@ utoljára fogadott üzenet: %2$@ Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata eltér a kulcstartóban lévőtől. + Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. No comment provided by engineer. Database passphrase is required to open chat. - Adatbázis-jelmondat szükséges a csevegés megnyitásához. + A csevegés megnyitásához adja meg az adatbázis jelmondatát. No comment provided by engineer. @@ -9764,7 +10057,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -9804,7 +10097,7 @@ utoljára fogadott üzenet: %2$@ Wrong database passphrase - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index d785acda81..fce4c9f6ac 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d sec time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d messaggio/i saltato/i @@ -272,11 +280,6 @@ %lld nuove lingue dell'interfaccia No comment provided by engineer. - - %lld second(s) - %lld secondo/i - No comment provided by engineer. - %lld seconds %lld secondi @@ -471,7 +474,8 @@ 1 day 1 giorno - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 mese - time interval + delete after time + time interval 1 week 1 settimana - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -579,6 +589,7 @@ About operators + Info sugli operatori No comment provided by engineer. @@ -644,6 +655,10 @@ Aggiungi amici No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Aggiungi profilo @@ -669,6 +684,10 @@ Aggiungi ad un altro dispositivo No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Aggiungi messaggio di benvenuto @@ -744,6 +763,10 @@ Impostazioni avanzate No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Tutti i dati dell'app vengono eliminati. @@ -754,6 +777,10 @@ Tutte le chat e i messaggi verranno eliminati. Non è reversibile! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Tutti i dati vengono cancellati quando inserito. @@ -949,6 +976,10 @@ Viene creato un profilo di chat vuoto con il nome scelto e l'app si apre come al solito. No comment provided by engineer. + + Another reason + report reason + Answer call Rispondi alla chiamata @@ -974,6 +1005,10 @@ L'app cripta i nuovi file locali (eccetto i video). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Icona app @@ -1019,6 +1054,10 @@ Applica a No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archivia e carica @@ -1029,6 +1068,14 @@ Archivia contatti per chattare più tardi. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Contatti archiviati @@ -1039,6 +1086,10 @@ Archiviazione del database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Allega @@ -1259,6 +1310,10 @@ Chat di lavoro No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1350,6 +1405,10 @@ Cambia No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Modifica profili utente @@ -1571,6 +1630,10 @@ Svuotare la conversazione? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Svuotare le note private? @@ -1591,6 +1654,10 @@ Modalità di colore No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Confronta file @@ -1825,6 +1892,10 @@ Questo è il tuo link una tantum! Stato della connessione e dei server. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Errore di connessione @@ -1835,6 +1906,15 @@ Questo è il tuo link una tantum! Errore di connessione (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Notifiche di connessione @@ -1845,6 +1925,10 @@ Questo è il tuo link una tantum! Richiesta di connessione inviata! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Sicurezza della connessione @@ -1925,6 +2009,10 @@ Questo è il tuo link una tantum! I contatti possono contrassegnare i messaggi per l'eliminazione; potrai vederli. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Continua @@ -2000,6 +2088,10 @@ Questo è il tuo link una tantum! Crea link No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Crea un nuovo profilo nell'[app desktop](https://simplex.chat/downloads/). 💻 @@ -2217,7 +2309,6 @@ Questo è il tuo link una tantum! Delete Elimina alert action - chat item action swipe action @@ -2260,6 +2351,10 @@ Questo è il tuo link una tantum! Elimina chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Elimina il profilo di chat @@ -2350,6 +2445,10 @@ Questo è il tuo link una tantum! Eliminare il link? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Eliminare il messaggio del membro? @@ -2363,7 +2462,7 @@ Questo è il tuo link una tantum! Delete messages Elimina messaggi - No comment provided by engineer. + alert button Delete messages after @@ -2400,6 +2499,10 @@ Questo è il tuo link una tantum! Elimina coda server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Elimina fino a 20 messaggi contemporaneamente. @@ -2560,6 +2663,14 @@ Questo è il tuo link una tantum! Disattiva SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Disattiva per tutti @@ -2650,6 +2761,10 @@ Questo è il tuo link una tantum! Non usare credenziali con proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Non creare un indirizzo @@ -2665,6 +2780,10 @@ Questo è il tuo link una tantum! Non mostrare più No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Esegui downgrade e apri chat @@ -2769,7 +2888,7 @@ Questo è il tuo link una tantum! Enable automatic message deletion? Attivare l'eliminazione automatica dei messaggi? - No comment provided by engineer. + alert title Enable camera access @@ -2896,6 +3015,10 @@ Questo è il tuo link una tantum! Rinegoziazione crittografia fallita. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Inserisci il codice di accesso @@ -3026,6 +3149,10 @@ Questo è il tuo link una tantum! Errore nella creazione del link del gruppo No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Errore di creazione del contatto @@ -3041,6 +3168,10 @@ Questo è il tuo link una tantum! Errore nella creazione del profilo! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Errore decifrando il file @@ -3156,6 +3287,10 @@ Questo è il tuo link una tantum! Errore nella rimozione del membro No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Errore di azzeramento statistiche @@ -3166,6 +3301,10 @@ Questo è il tuo link una tantum! Errore nel salvataggio dei server ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Errore nel salvataggio del profilo del gruppo @@ -3376,10 +3515,14 @@ Questo è il tuo link una tantum! Preferito swipe action + + Favorites + No comment provided by engineer. + File error Errore del file - No comment provided by engineer. + file error alert title File errors: @@ -3388,6 +3531,11 @@ Questo è il tuo link una tantum! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. File non trovato - probabilmente è stato eliminato o annullato. @@ -3757,6 +3905,10 @@ Errore: %2$@ Il gruppo verrà eliminato per te. Non è reversibile! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Aiuto @@ -3822,6 +3974,10 @@ Errore: %2$@ Come aiuta la privacy No comment provided by engineer. + + How it works + alert button + How to Come si fa @@ -3964,6 +4120,14 @@ Altri miglioramenti sono in arrivo! Suoni nelle chiamate No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4329,6 +4493,18 @@ Questo è il tuo link per il gruppo %@! Desktop collegati No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Messaggio in diretta! @@ -4589,6 +4765,10 @@ Questo è il tuo link per il gruppo %@! I messaggi da %@ verranno mostrati! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Messaggi ricevuti @@ -4689,6 +4869,10 @@ Questo è il tuo link per il gruppo %@! Moderato il: %@ copied message info + + More + swipe action + More improvements are coming soon! Altri miglioramenti sono in arrivo! @@ -4859,6 +5043,18 @@ Questo è il tuo link per il gruppo %@! Nessuna password dell'app Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Nessun contatto selezionato @@ -4964,6 +5160,10 @@ Questo è il tuo link per il gruppo %@! Nessun server per inviare file. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Nessun identificatore utente. @@ -4974,6 +5174,10 @@ Questo è il tuo link per il gruppo %@! Non compatibile! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nessuna selezione @@ -5082,6 +5286,14 @@ Richiede l'attivazione della VPN. Solo i proprietari del gruppo possono attivare i messaggi vocali. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Solo tu puoi aggiungere reazioni ai messaggi. @@ -5167,11 +5379,23 @@ Richiede l'attivazione della VPN. Apri gruppo No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Apri migrazione ad un altro dispositivo authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Apertura dell'app… @@ -5901,6 +6125,38 @@ Attivalo nelle impostazioni *Rete e server*. Rispondi chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Obbligatorio @@ -6077,6 +6333,10 @@ Attivalo nelle impostazioni *Rete e server*. Salva il profilo del gruppo No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Salva la password e apri la chat @@ -6532,6 +6792,10 @@ Attivalo nelle impostazioni *Rete e server*. Imposta 1 giorno No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Imposta nome del contatto… @@ -6715,7 +6979,7 @@ Attivalo nelle impostazioni *Rete e server*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. - SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app + SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app. No comment provided by engineer. @@ -6860,6 +7124,11 @@ Attivalo nelle impostazioni *Rete e server*. Qualcuno notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Quadrata, circolare o qualsiasi forma tra le due. @@ -6945,6 +7214,10 @@ Attivalo nelle impostazioni *Rete e server*. Arresto della chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Forte @@ -7073,7 +7346,7 @@ Attivalo nelle impostazioni *Rete e server*. Temporary file error Errore del file temporaneo - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7207,6 +7480,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il profilo è condiviso solo con i tuoi contatti. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Le stesse condizioni si applicheranno all'operatore **%@**. @@ -7282,6 +7559,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. @@ -8056,6 +8337,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Server XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Non devi** usare lo stesso database su due dispositivi. @@ -8552,6 +8837,7 @@ Ripetere la richiesta di connessione? accepted invitation + invito accettato chat list item title @@ -8589,6 +8875,10 @@ Ripetere la richiesta di connessione? e altri %lld eventi No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts tentativi @@ -8797,7 +9087,8 @@ Ripetere la richiesta di connessione? default (%@) predefinito (%@) - pref value + delete after time + pref value default (no) @@ -9104,6 +9395,10 @@ Ripetere la richiesta di connessione? moderato da %@ marked deleted chat item preview text + + moderator + member role + months mesi @@ -9117,7 +9412,7 @@ Ripetere la richiesta di connessione? never mai - No comment provided by engineer. + delete after time new message @@ -9238,6 +9533,7 @@ Ripetere la richiesta di connessione? requested to connect + richiesto di connettersi chat list item title 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 72e68cff48..8b754050a2 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -59,7 +63,7 @@ %@ (current) - %@ (現在) + %@ (現在) No comment provided by engineer. @@ -114,10 +118,12 @@ %@ server + %@ サーバー No comment provided by engineer. %@ servers + %@ サーバー No comment provided by engineer. @@ -132,6 +138,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -156,18 +163,22 @@ %d file(s) are still being downloaded. + %d 個のファイルをダウンロードしています。 forward confirmation reason %d file(s) failed to download. + %d 個のファイルがダウンロードに失敗しました。 forward confirmation reason %d file(s) were deleted. + %d 個のファイルが削除されました。 forward confirmation reason %d file(s) were not downloaded. + %d 個のファイルがダウンロードされていません。 forward confirmation reason @@ -177,6 +188,7 @@ %d messages not forwarded + %d 個のメッセージが未転送 alert title @@ -194,6 +206,10 @@ %d 秒 time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d 件のスキップされたメッセージ @@ -206,7 +222,7 @@ %lld - %lld + No comment provided by engineer. @@ -264,11 +280,6 @@ %lldつの新しいインターフェース言語 No comment provided by engineer. - - %lld second(s) - %lld 秒 - No comment provided by engineer. - %lld seconds %lld 秒 @@ -376,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + **QRスキャン / リンクの貼り付け**: 受け取ったリンクで接続する。 No comment provided by engineer. @@ -412,6 +424,9 @@ - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - [ディレクトリサービス](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) に接続 (ベータ)! +- 配信証明を送信する (最大 20 人まで)。 +- より速く、より安定。 No comment provided by engineer. @@ -427,6 +442,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - 任意で削除された連絡先へ通知します。 +- プロフィール名に空白を含めることができます。 +- and more! No comment provided by engineer. @@ -456,7 +474,8 @@ 1 day 1日 - time interval + delete after time + time interval 1 hour @@ -471,19 +490,27 @@ 1 month 1ヶ月 - time interval + delete after time + time interval 1 week 1週間 - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + 使い捨てリンク No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + 使い捨てリンクは、*ひとつの連絡先にのみ* 使用できます - 対面または任意のチャットで共有してください。 No comment provided by engineer. @@ -620,6 +647,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile プロフィールを追加 @@ -644,6 +675,10 @@ 別の端末に追加 No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message ウェルカムメッセージを追加 @@ -655,10 +690,12 @@ Added media & file servers + 追加されたメディア & ファイルサーバー No comment provided by engineer. Added message servers + 追加されたメッセージサーバー No comment provided by engineer. @@ -707,6 +744,11 @@ Advanced settings + 詳細設定 + No comment provided by engineer. + + + All No comment provided by engineer. @@ -719,6 +761,10 @@ 全チャットとメッセージが削除されます(※元に戻せません※)! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. 入力するとすべてのデータが消去されます。 @@ -752,6 +798,7 @@ All profiles + すべてのプロフィール profile dropdown @@ -794,7 +841,7 @@ Allow irreversible message deletion only if your contact allows it to you. (24 hours) - 送信相手も永久メッセージ削除を許可する時のみに許可する。 + 送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間) No comment provided by engineer. @@ -819,6 +866,7 @@ Allow sharing + 共有を許可 No comment provided by engineer. @@ -893,6 +941,7 @@ Always use private routing. + プライベートルーティングを常に使用する。 No comment provided by engineer. @@ -905,6 +954,10 @@ 指定された名前の空のチャット プロファイルが作成され、アプリが通常どおり開きます。 No comment provided by engineer. + + Another reason + report reason + Answer call 通話に応答 @@ -930,6 +983,10 @@ アプリは新しいローカルファイル(ビデオを除く)を暗号化します。 No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon アプリのアイコン @@ -961,25 +1018,40 @@ Appearance - 見た目 + アピアランス No comment provided by engineer. Apply + 適用 No comment provided by engineer. Apply to + に適用する + No comment provided by engineer. + + + Archive No comment provided by engineer. Archive and upload + アーカイブとアップロード No comment provided by engineer. Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -988,6 +1060,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach 添付する @@ -1186,6 +1262,10 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). チャット プロファイル経由 (デフォルト) または [接続経由](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1268,6 +1348,10 @@ 変更 No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1473,6 +1557,10 @@ ダイアログのクリアしますか? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? プライベートノートを消しますか? @@ -1492,6 +1580,10 @@ 色設定 No comment provided by engineer. + + Community guidelines violation + report reason + Compare file ファイルを比較 @@ -1701,7 +1793,11 @@ This is your own one-time link! Connection and servers status. - 接続とサーバーのステータス + 接続とサーバーのステータス。 + No comment provided by engineer. + + + Connection blocked No comment provided by engineer. @@ -1714,6 +1810,15 @@ This is your own one-time link! 接続エラー (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1723,6 +1828,10 @@ This is your own one-time link! 接続リクエストを送信しました! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1797,6 +1906,10 @@ This is your own one-time link! 連絡先はメッセージを削除対象とすることができます。あなたには閲覧可能です。 No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue 続ける @@ -1865,6 +1978,10 @@ This is your own one-time link! リンクを生成する No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 [デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻 @@ -2073,7 +2190,6 @@ This is your own one-time link! Delete 削除 alert action - chat item action swipe action @@ -2112,6 +2228,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile チャットのプロフィールを削除する @@ -2199,6 +2319,10 @@ This is your own one-time link! リンクを削除しますか? No comment provided by engineer. + + Delete list? + alert title + Delete member message? メンバーのメッセージを削除しますか? @@ -2212,7 +2336,7 @@ This is your own one-time link! Delete messages メッセージを削除 - No comment provided by engineer. + alert button Delete messages after @@ -2248,6 +2372,10 @@ This is your own one-time link! 待ち行列を削除 server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2395,6 +2523,14 @@ This is your own one-time link! SimpleXロックを無効にする authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all すべて無効 @@ -2478,9 +2614,13 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address - アドレスを作成しないでください + アドレスを作成しない No comment provided by engineer. @@ -2493,6 +2633,10 @@ This is your own one-time link! 次から表示しない No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat ダウングレードしてチャットを開く @@ -2587,7 +2731,7 @@ This is your own one-time link! Enable automatic message deletion? 自動メッセージ削除を有効にしますか? - No comment provided by engineer. + alert title Enable camera access @@ -2707,6 +2851,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode パスコードを入力 @@ -2828,6 +2976,10 @@ This is your own one-time link! グループリンク生成にエラー発生 No comment provided by engineer. + + Error creating list + alert title + Error creating member contact メンバー連絡先の作成中にエラーが発生 @@ -2842,6 +2994,10 @@ This is your own one-time link! プロフィール作成にエラー発生! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file ファイルの復号エラー @@ -2949,6 +3105,10 @@ This is your own one-time link! メンバー除名にエラー発生 No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2958,6 +3118,10 @@ This is your own one-time link! ICEサーバ保存にエラー発生 No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile グループのプロフィール保存にエラー発生 @@ -3154,15 +3318,24 @@ This is your own one-time link! お気に入り swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3498,6 +3671,10 @@ Error: %2$@ あなたにとってグループが削除されます (※元に戻せません※)! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help ヘルプ @@ -3560,6 +3737,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to 使い方 @@ -3692,6 +3873,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito シークレットモード @@ -3762,7 +3951,7 @@ More improvements are coming soon! Instant - すぐに + 即時 No comment provided by engineer. @@ -4032,6 +4221,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ライブメッセージ! @@ -4272,6 +4473,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4300,6 +4505,7 @@ This is your link for group %@! Migrate from another device + 別の端末から移行 No comment provided by engineer. @@ -4344,7 +4550,7 @@ This is your link for group %@! Migrations: - 移行 + 移行: No comment provided by engineer. @@ -4362,6 +4568,10 @@ This is your link for group %@! モデレーターによって介入済み: %@ copied message info + + More + swipe action + More improvements are coming soon! まだまだ改善してまいります! @@ -4518,6 +4728,18 @@ This is your link for group %@! アプリのパスワードはありません Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected 連絡先が選択されてません @@ -4612,6 +4834,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. 世界初のユーザーIDのないプラットフォーム|設計も元からプライベート。 @@ -4621,6 +4847,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4723,6 +4953,14 @@ VPN を有効にする必要があります。 音声メッセージを利用可能に設定できるのはグループのオーナーだけです。 No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. メッセージへのリアクションを追加できるのは、あなただけです。 @@ -4805,10 +5043,22 @@ VPN を有効にする必要があります。 Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5085,6 +5335,7 @@ Error: %@ Private notes + プライベートノート name of notes to self @@ -5471,6 +5722,38 @@ Enable in *Network & servers* settings. 返信 chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required 必須 @@ -5634,6 +5917,10 @@ Enable in *Network & servers* settings. グループプロフィールの保存 No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat パスフレーズをを保存して、チャットを開始 @@ -6042,6 +6329,10 @@ Enable in *Network & servers* settings. 1日に設定 No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… 連絡先の名前を設定… @@ -6341,6 +6632,11 @@ Enable in *Network & servers* settings. 誰か notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6420,6 +6716,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6535,7 +6835,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6663,6 +6963,10 @@ It can happen because of some bug or when the connection is compromised.プロフィールは連絡先にしか共有されません。 No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6730,6 +7034,10 @@ It can happen because of some bug or when the connection is compromised.選択中の以前の送受信したメッセージが削除されます (※元に戻せません※)。数分かかります。 No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. あなたのプロフィール、連絡先、メッセージ、ファイルが完全削除されます (※元に戻せません※)。 @@ -7425,6 +7733,10 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7539,6 +7851,7 @@ Repeat join request? You can make it visible to your SimpleX contacts via Settings. + 設定でSimpleXの連絡先に表示させることができます。 No comment provided by engineer. @@ -7920,6 +8233,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8119,7 +8436,8 @@ Repeat connection request? default (%@) デフォルト (%@) - pref value + delete after time + pref value default (no) @@ -8417,6 +8735,10 @@ Repeat connection request? %@ によってモデレートされた marked deleted chat item preview text + + moderator + member role + months @@ -8429,7 +8751,7 @@ Repeat connection request? never 一度も - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index 9aaa83afc3..d643600bc9 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -24,116 +24,144 @@ No comment provided by engineer. - + ( + ( No comment provided by engineer. - + (can be copied) + (복사 가능) No comment provided by engineer. - + !1 colored! + !1 색상! No comment provided by engineer. - + #secret# + #비밀# No comment provided by engineer. - + %@ + %@ No comment provided by engineer. - + %@ %@ + %@ %@ No comment provided by engineer. - + %@ / %@ + %@ / %@ No comment provided by engineer. - + %@ is connected! + %@이(가) 연결되었습니다! notification title - + %@ is not verified + %@은(는) 인증되지 않았습니다 No comment provided by engineer. - + %@ is verified + %@ 은(는) 인증되었습니다 No comment provided by engineer. - + %@ wants to connect! + %@ 연결을 원함! notification title - + %d days + %d 일 message ttl - + %d hours + %d 시간 message ttl - + %d min + %d 분 message ttl - + %d months + %d 개월 message ttl - + %d sec + %d 초 message ttl - + %d skipped message(s) + 건너뛰기 메시지 %d개 integrity error chat item - + %lld + %lld No comment provided by engineer. - + %lld %@ + %lld %@ No comment provided by engineer. - + %lld contact(s) selected + %lld명의 연락처 선택됨 No comment provided by engineer. - + %lld file(s) with total size of %@ + 총 크기가 %@인 파일 %lld 개 No comment provided by engineer. - + %lld members + %lld명의 멤버 No comment provided by engineer. - + %lld second(s) + %lld 초 No comment provided by engineer. - + %lldd + %lldd No comment provided by engineer. - + %lldh + %lldh No comment provided by engineer. - + %lldk + %lldk No comment provided by engineer. - + %lldm + %lldm No comment provided by engineer. - + %lldmth + %lldmth No comment provided by engineer. @@ -144,96 +172,114 @@ %lldw No comment provided by engineer. - + ( + ( No comment provided by engineer. - + ) + ) No comment provided by engineer. **Create link / QR code** for your contact to use. No comment provided by engineer. - + **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. + **비공개**: 매 20분마다 새 메시지를 확인합니다. 푸시 서버에는 장치 토큰만 공유됩니다. 연락처 수나 메세지 메타데이터가 표시되지 않습니다. No comment provided by engineer. - + **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app. + **비공개**: SimpleX 채팅 푸시 서버를 사용하지 마세요. 앱은 사용 빈도에 따라 시스템이 허용하는 백그라운드에서 메세지를 확인합니다. No comment provided by engineer. **Paste received link** or open it in the browser and tap **Open in mobile app**. No comment provided by engineer. - + **Please note**: you will NOT be able to recover or change passphrase if you lose it. + **참고**: 비밀번호를 분실하면 복구하거나 변경할 수 없습니다. No comment provided by engineer. - + **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. + **권장**: 디바이스 토큰과 종단 간 암호화 알림이 SimpleX 채팅 푸시 서버로 전송되지만 메세지 내용, 크기 또는 발신자가 표시되지 않습니다. No comment provided by engineer. **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. - + **Warning**: Instant push notifications require passphrase saved in Keychain. + **경고**: 즉각적인 푸시 알림은 암호문을 키체인에 저장해야 합니다. No comment provided by engineer. - + **e2e encrypted** audio call + **e2e** 오디오 통화 No comment provided by engineer. - + **e2e encrypted** video call + **e2e 암호화** 영상 통화 No comment provided by engineer. \*bold* No comment provided by engineer. - + , + , No comment provided by engineer. - + . + . No comment provided by engineer. - + 1 day + 1일 message ttl - + 1 hour + 1시간 message ttl - + 1 month + 1개월 message ttl - + 1 week + 1주 message ttl 2 weeks message ttl - + 6 + 6 No comment provided by engineer. - + : + : No comment provided by engineer. - + A new contact + 새로운 연결 notification title @@ -244,29 +290,34 @@ A random profile will be sent to your contact No comment provided by engineer. - + A separate TCP connection will be used **for each chat profile you have in the app**. + 앱에 있는 각 채팅 프로필**마다 별도의 TCP 연결이 사용됩니다. No comment provided by engineer. - + A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. + 각 연락처 및 그룹 구성원**마다 별도의 TCP 연결이 사용됩니다. +**참고**: 연결이 많으면 배터리와 트래픽 소비가 상당히 증가하고 일부 연결이 실패할 수 있습니다. No comment provided by engineer. About SimpleX No comment provided by engineer. - + About SimpleX Chat + SimpleX Chat에 대하여 No comment provided by engineer. Accent color No comment provided by engineer. - + Accept + 승인 accept contact request via notification accept incoming call via notification @@ -274,12 +325,14 @@ Accept contact No comment provided by engineer. - + Accept contact request from %@? + %@의 연락 요청을 수락하시겠습니까? notification body - + Accept incognito + 인정하지 않음 No comment provided by engineer. @@ -290,192 +343,233 @@ Add preset servers No comment provided by engineer. - + Add profile + 프로필 추가하기 No comment provided by engineer. - + Add servers by scanning QR codes. + QR 코드를 스캔하여 서버를 추가합니다. No comment provided by engineer. - + Add server + 서버 추가하기 No comment provided by engineer. - + Add to another device + 다른 장치에 추가하기 No comment provided by engineer. - + Add welcome message + 환영 메세지 추가하기 No comment provided by engineer. - + Admins can create the links to join groups. + 관리자는 그룹에 가입할 수 있는 링크를 만들 수 있습니다. No comment provided by engineer. - + Advanced network settings + 고급 네트워크 설정 No comment provided by engineer. - + All chats and messages will be deleted - this cannot be undone! + 모든 채팅과 메세지가 삭제됩니다. - 수정 불가능! No comment provided by engineer. - + All group members will remain connected. + 모든 그룹 구성원은 연결 상태를 유지합니다. No comment provided by engineer. - + All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. + 모든 메세지가 삭제됩니다 - 수정할 수 없습니다! 메세지는 오직 당신만을 위해 삭제될 것입니다. No comment provided by engineer. All your contacts will remain connected No comment provided by engineer. - + Allow + 승인 No comment provided by engineer. - + Allow disappearing messages only if your contact allows it to you. + 연락처가 메세지를 허용하는 경우에만 메세지 삭제를 허용합니다. No comment provided by engineer. Allow irreversible message deletion only if your contact allows it to you. No comment provided by engineer. - + Allow sending direct messages to members. + 회원에게 직접 메시지를 보낼 수 있습니다. No comment provided by engineer. - + Allow sending disappearing messages. + 사라지는 메시지를 보내는 것을 허용합니다. No comment provided by engineer. Allow to irreversibly delete sent messages. No comment provided by engineer. - + Allow to send voice messages. + 음성 메세지를 보낼 수 있습니다. No comment provided by engineer. - + Allow voice messages only if your contact allows them. + 연락처가 음성 메세지를 허용하는 경우에만 음성 메세지를 허용합니다. No comment provided by engineer. - + Allow voice messages? + 음성 메세지를 허용 하겠습니까? No comment provided by engineer. Allow your contacts to irreversibly delete sent messages. No comment provided by engineer. - + Allow your contacts to send disappearing messages. + 연락처가 사라지는 메시지를 보낼 수 있도록 허용합니다. No comment provided by engineer. - + Allow your contacts to send voice messages. + 연락처가 음성 메시지를 보낼 수 있도록 허용합니다. No comment provided by engineer. - + Already connected? + 이미 연결되었나요? No comment provided by engineer. - + Always use relay + 항상 릴레이 사용 No comment provided by engineer. - + Answer call + 응답 전화 No comment provided by engineer. - + App build: %@ + 앱 빌드: %@ No comment provided by engineer. - + App icon + 앱 아이콘 No comment provided by engineer. - + App version + 앱 버전 No comment provided by engineer. - + App version: v%@ + 앱 버전: v%@ No comment provided by engineer. - + Appearance + 출석 No comment provided by engineer. - + Attach + 첨부 No comment provided by engineer. - + Audio & video calls + 음성 & 영상 통화 No comment provided by engineer. - + Audio and video calls + 음성 및 영상 통화 No comment provided by engineer. - + Authentication failed + 인증 실패 No comment provided by engineer. - + Authentication is required before the call is connected, but you may miss calls. + 통화가 연결되기 전에 인증이 필요하지만, 통화를 놓칠 수 있습니다. No comment provided by engineer. - + Authentication unavailable + 인증 사용 불가 No comment provided by engineer. - + Auto-accept contact requests + 연락처 요청 자동 수락 No comment provided by engineer. - + Auto-accept images + 이미지 자동 수락 No comment provided by engineer. Automatically No comment provided by engineer. - + Back + 뒤로가기 No comment provided by engineer. Both you and your contact can irreversibly delete sent messages. No comment provided by engineer. - + Both you and your contact can send disappearing messages. + 당신과 당신의 연락처 모두 사라지는 메시지를 보낼 수 있습니다. No comment provided by engineer. - + Both you and your contact can send voice messages. + 당신과 당신의 연락처 모두 음성 메시지를 보낼 수 있습니다. No comment provided by engineer. - + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + 채팅 프로필(기본값) 또는 [연결](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - + Call already ended! + 통화가 이미 종료되었습니다! No comment provided by engineer. - + Calls + 통화 No comment provided by engineer. @@ -487,8 +581,9 @@ 주소를 초대할 수 없습니다. No comment provided by engineer. - + Can't invite contacts! + 연락처를 초대할 수 없습니다! No comment provided by engineer. @@ -511,8 +606,9 @@ 변경 No comment provided by engineer. - + Change database passphrase? + 데이터베이스 암호 변경? No comment provided by engineer. @@ -540,16 +636,19 @@ 채팅 기록 보관함 No comment provided by engineer. - + Chat console + 채팅 콘솔 No comment provided by engineer. - + Chat database + 채팅 데이터베이스 No comment provided by engineer. - + Chat database deleted + 채팅 데이터베이스 삭제 No comment provided by engineer. @@ -557,80 +656,98 @@ 채팅 데이터베이스를 가져옴 No comment provided by engineer. - + Chat is running + 채팅이 실행 중입니다 No comment provided by engineer. - + Chat is stopped + 채팅이 중단되었습니다 No comment provided by engineer. - + Chat preferences + 채팅 환경설정 No comment provided by engineer. - + Chats + 채팅 No comment provided by engineer. - + Check server address and try again. + 서버 주소를 확인한 후 다시 시도합니다. No comment provided by engineer. - + Chinese and Spanish interface + 중국어 및 스페인어 환경 No comment provided by engineer. - + Choose file + 파일 선택 No comment provided by engineer. - + Choose from library + 라이브러리에서 선택 No comment provided by engineer. - + Clear + 정리 No comment provided by engineer. - + Clear conversation + 대화 삭제 No comment provided by engineer. - + Clear conversation? + 대화 삭제? No comment provided by engineer. - + Clear verification + 인증 삭제 No comment provided by engineer. Colors No comment provided by engineer. - + Compare security codes with your contacts. + 보안 코드를 연락처와 비교합니다. No comment provided by engineer. - + Configure ICE servers + ICE 서버 구성 No comment provided by engineer. - + Confirm + 확인 No comment provided by engineer. - + Confirm new passphrase… + 새 암호 확인… No comment provided by engineer. - + Confirm password + 비밀번호 확인 No comment provided by engineer. - + Connect + 연결 server test step @@ -641,8 +758,9 @@ Connect via group link? No comment provided by engineer. - + Connect via link + 링크를 통해 연결 No comment provided by engineer. @@ -653,132 +771,156 @@ Connect via one-time link? No comment provided by engineer. - + Connecting to server… + 서버에 연결중… No comment provided by engineer. - + Connecting to server… (error: %@) + 서버에 연결중...(오류: %@) No comment provided by engineer. - + Connection + 연결 No comment provided by engineer. - + Connection error + 연결 오류 No comment provided by engineer. - + Connection error (AUTH) + 연결 에러 (인증) No comment provided by engineer. Connection request No comment provided by engineer. - + Connection request sent! + 연결 요청이 전송되었습니다! No comment provided by engineer. - + Connection timeout + 연결 시간초과 No comment provided by engineer. - + Contact allows + 연락 가능 No comment provided by engineer. - + Contact already exists + 연결이 이미 존재 No comment provided by engineer. Contact and all messages will be deleted - this cannot be undone! No comment provided by engineer. - + Contact hidden: + 숨겨진 연락처: notification - + Contact is connected + 연락처가 연결되었습니다 notification Contact is not connected yet! No comment provided by engineer. - + Contact name + 연락처 이름 No comment provided by engineer. - + Contact preferences + 연락처 선호도 No comment provided by engineer. Contact requests No comment provided by engineer. - + Contacts can mark messages for deletion; you will be able to view them. + 연락처는 메세지를 삭제하도록 표시할 수 있으며, 이를 확인할 수 있습니다. No comment provided by engineer. - + Copy + 복사 chat item action Core built at: %@ No comment provided by engineer. - + Core version: v%@ + 코어 버전: v%@ No comment provided by engineer. - + Create + 생성 No comment provided by engineer. Create address No comment provided by engineer. - + Create group link + 그룹 링크 생성 No comment provided by engineer. - + Create link + 링크 생성 No comment provided by engineer. Create one-time invitation link No comment provided by engineer. - + Create queue + 큐 생성 server test step - + Create secret group + 비밀 그룹 생성 No comment provided by engineer. - + Create your profile + 프로필 생성 No comment provided by engineer. Created on %@ No comment provided by engineer. - + Current passphrase… + 현재 암호… No comment provided by engineer. - + Currently maximum supported file size is %@. + 현재 지원되는 최대 파일 크기는 %@입니다. No comment provided by engineer. @@ -2009,8 +2151,9 @@ We will be adding server redundancy to prevent lost messages. Open user profiles authentication reason - + Anybody can host servers. + 누구나 서버를 호스팅할 수 있습니다. No comment provided by engineer. @@ -3784,6 +3927,1020 @@ SimpleX servers cannot see your profile. 새로운 멤버에게 최대 100개의 마지막 메시지 보내기. No comment provided by engineer. + + ## History + ## 기록 + + + ## In reply to + ## 에 대한 답변 + + + %@ downloaded + %@ 다운로드됨 + + + # %@ + # %@ + + + %@ and %@ + %@ 그리고 %@ + + + %1$@ at %2$@: + %2$@의 %1$@: + + + %@ connected + %@ 연결됨 + + + %@ (current): + %@ (현재): + + + %@ (current) + %@ (현재) + + + %@ and %@ connected + %@ 및 %@이(가) 연결되었습니다 + + + %@ server + %@서버 + + + %@ servers + %@서버들 + + + %@, %@ and %lld members + %@, %@ 과 %lld 멤버들 + + + %d file(s) are still being downloaded. + %d 개의 파일 다운로드중. + + + %d file(s) were deleted. + %d개의 파일이 삭제됨. + + + %d file(s) were not downloaded. + %d개의 파일이 다운로드 되지 않음. + + + %d weeks + %d 주 + + + %lld seconds + %lld 초 + + + **Create 1-time link**: to create and share a new invitation link. + **1회 링크 생성** : 새 초대 링크를 생성하고 공유합니다. + + + 1-time link + 일회성 링크 + + + 1-time link can be used *with one contact only* - share in person or via any messenger. + 일회용 링크는 *한 번의 연락처로만* 사용할 수 있으며, 대면 또는 메신저를 통해 공유할 수 있습니다. + + + A few more things + 몇 가지 더 + + + Accept conditions + 조건 수락 + + + Accepted conditions + 수락된 조건 + + + Active connections + 연결 활성화 + + + %@ uploaded + %@업로드됨 + + + Accept connection request? + 연결 요청을 수락하시겠습니까? + + + %lld minutes + %lld 분 + + + **Warning**: the archive will be removed. + **경고**: 보관물이 제거됩니다. + + + 5 minutes + 5 분 + + + Abort changing address + 주소 변경 중단 + + + Acknowledgement errors + 확인 오류 + + + Abort + 중단 + + + %u messages failed to decrypt. + %u개의 메세지를 번역하는데 실패함. + + + Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. + 연락처가 다른 사람과 공유할 수 있도록 프로필에 주소를 추가합니다. 프로필 업데이트가 연락처로 전송됩니다. + + + %lld messages blocked by admin + 관리자에 의해 차단된 %lld개의 메세지 + + + **Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection. + **참고**: 두 장치에서 동일한 데이터베이스를 사용하면 보안 보호를 위해 연결에서 메세지를 해독할 수 있습니다. + + + **Create group**: to create a new group. + **그룹 생성** : 새로운 그룹을 생성합니다. + + + %d file(s) failed to download. + %d개의 파일을 다운로드하는데 실패함. + + + %d messages not forwarded + %d개의 메세지가 전달되지 않음 + + + **Scan / Paste link**: to connect via a link you received. + **스캔/붙여넣기 링크**: 받은 링크를 통해 연결합니다. + + + About operators + 연산자 정보 + + + Address change will be aborted. Old receiving address will be used. + 주소 변경이 중단됩니다. 이전 수신 주소가 사용됩니다. + + + %@: + %@: + + + %lld messages blocked + %lld개의 메세지가 차단됨 + + + %lld messages marked deleted + 삭제된 메세지 %lld 개 + + + - more stable message delivery. +- a bit better groups. +- and more! + - 보다 안정적인 메세지 전달. +- 조금 더 나은 그룹. +- 그리고 더! + + + 0s + 0초 + + + 1 minute + 1분 + + + Abort changing address? + 주소 변경을 중단하시겠습니까? + + + 30 seconds + 30초 + + + - voice messages up to 5 minutes. +- custom time to disappear. +- editing history. + - 음성 메세지 최대 5분. +- 사라지는 맞춤형 시간. +- 편집 기록. + + + Add friends + 친구 추가 + + + Add team members + 팀원 추가하기 + + + Add your team members to the conversations. + 대화에 팀원을 추가하세요. + + + %u messages skipped. + 메세지 %u개를 건너뜀. + + + %@, %@ and %lld other members connected + %@, %@ 그리고 %lld 다른 멤버들이 연결됨 + + + %lld messages moderated by %@ + %@ 에 의해 중재된 %lld 개의 메세지 + + + %lld new interface languages + %lld개의 새로운 인터페이스 언어 + + + %1$@, %2$@ + %1$@, %2$@ + + + - optionally notify deleted contacts. +- profile names with spaces. +- and more! + - 선택적으로 삭제된 연락처를 통지합니다. +- 공백이 있는 프로필 이름. +- 그리고 더! + + + <p>Hi!</p> +<p><a href="%@">Connect to me via SimpleX Chat</a></p> + <p>안녕하세요!/p> +<p><a href="%@">SimpleX 채팅을 통해 저에게 연결하세요 </a></p> + + + A new random profile will be shared. + 새로운 랜덤 프로필이 공유될 것입니다. + + + Acknowledged + 인정된 + + + Additional accent 2 + 추가 악센트2 + + + Added media & file servers + 미디어 및 파일 서버 추가 + + + Added message servers + 추가된 메세지 서버 + + + Additional accent + 추가 악센트 + + + Additional secondary + 추가적 보조 + + + Address + 주소 + + + Address or 1-time link? + 주소 또는 일회성 링크? + + + Address settings + 주소 세팅 + + + Admins can block a member for all. + 관리자는 모두를 위해 회원을 차단할 수 있습니다. + + + %lld group events + %lld개의 그룹 이벤트 + + + All app data is deleted. + 모든 앱 데이터가 삭제됩니다. + + + All data is erased when it is entered. + 입력하면 모든 데이터가 삭제됩니다. + + + 0 sec + 0 초 + + + (this device v%@) + (이 장치 v%@) + + + (new) + (새로운) + + + Advanced settings + 고급 설정 + + + All data is kept private on your device. + 모든 데이터는 기기에서 비공개로 유지됩니다. + + + All profiles + 전체 프로필 + + + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + 모든 연락처, 대화 및 파일은 안전하게 암호화되어 구성된 XFTP 릴레이에 청크로 업로드됩니다. + + + Allow calls only if your contact allows them. + 허용된 연락처만 통화가 가능합니다. + + + Allow calls? + 통화 허용? + + + Allow downgrade + 강등 허용 + + + Allow irreversible message deletion only if your contact allows it to you. (24 hours) + 연락처가 허용하는 경우에만 수정 불가능한 메세지 삭제를 허용합니다. (24시간) + + + Allow to send files and media. + 파일과 미디어를 전송할 수 있습니다. + + + Archiving database + 보관된 데이터베이스 + + + Better calls + 더 나은 통화 + + + Block + 차단 + + + Conditions will be accepted for enabled operators after 30 days. + 30일 후에 활성화된 운영자에 대한 조건이 수락될 것입니다. + + + Conditions will be accepted on: %@. + 조건은 다음과 같습니다: %@. + + + Connect via one-time link + 일회성 링크를 통해 연결 + + + Connected desktop + 데스크톱과 연결됨 + + + Connected servers + 연결된 서버 + + + Connection security + 연결 보안 + + + Connection terminated + 종료된 연결 + + + Connection with desktop stopped + 데스크톱과의 연결이 중지됨 + + + Current conditions text couldn't be loaded, you can review conditions via this link: + 현재 조건 텍스트를 로드할 수 없습니다. 이 링크를 통해 조건을 검토할 수 있습니다: + + + Bad desktop address + 잘못된 데스크톱 주소 + + + Camera not available + 카메라가 사용 불가능합니다 + + + Custom time + 사용자 지정 시간 + + + Allow to irreversibly delete sent messages. (24 hours) + 보낸 메시지를 되돌릴 수 없도록 삭제합니다. (24시간) + + + Allow message reactions. + 메세지 응답 허용. + + + Allow your contacts adding message reactions. + 연락처가 메세지 응답을 추가하도록 허용합니다. + + + Already connecting! + 이미 연결 중입니다! + + + Already joining the group! + 그룹에 참가하는 중입니다! + + + Archive and upload + 기록 및 업로드 + + + Chat colors + 채팅 색깔 + + + Chat list + 채팅 목록 + + + Completed + 완료됨 + + + Copy error + 복사 오류 + + + Create SimpleX address + SimpleX 주소 생성 + + + Creating link… + 생성 링크… + + + Blocked by admin + 관리자에 의해 차단됨 + + + Connect to desktop + 데스크톱에 연결 + + + Created at + 에 생성됨 + + + Created at: %@ + 생성 위치: %@ + + + Change self-destruct passcode + 자기-파괴 비밀번호 변경 + + + Create file + 파일 생성 + + + Allow your contacts to irreversibly delete sent messages. (24 hours) + 연락처가 보낸 메세지를 되돌릴 수 없도록 삭제할 수 있도록 허용합니다. (24시간) + + + App data migration + 앱 데이터 이동 + + + Apply to + 적용 대상 + + + Block for all + 모두를 위한 차단 + + + Both you and your contact can add message reactions. + 당신과 당신의 연락처 모두 메세지 반응을 추가할 수 있습니다. + + + Calls prohibited! + 통화 금지! + + + Change self-destruct mode + 자기-파괴 모드 변경 + + + Contacts + 연락처 + + + Create group + 그룹 생성 + + + Both you and your contact can make calls. + 당신과 당신의 연락처 모두 전화를 걸 수 있습니다. + + + App passcode + 앱 비밀번호 + + + All your contacts will remain connected. Profile update will be sent to your contacts. + 당신의 모든 연락은 연결되어 있습니다. 프로필 업데이트가 모든 연락으로 전송됩니다. + + + App encrypts new local files (except videos). + 앱은 새로운 로컬 파일을 암호화합니다 (동영상 제외). + + + Chat preferences were changed. + 채팅 환경설정이 변경되었습니다. + + + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + [데스크톱 앱]에 새로운 프로필 생성(https://simplex.chat/downloads/).💻 + + + Contact is deleted. + 연락처가 삭제되었습니다. + + + Continue + 계속 + + + Current Passcode + 현재 비밀번호 + + + An empty chat profile with the provided name is created, and the app opens as usual. + 제공된 이름으로 빈 채팅 프로필이 생성되고 앱이 정상적으로 열립니다. + + + Allow your contacts to call you. + 연락처가 전화할 수 있도록 허용합니다. + + + Allow sharing + 공유 허용 + + + Always use private routing. + 항상 개인 경로를 사용합니다. + + + Better user experience + 더 나은 사용자 경험 + + + Change lock mode + 잠금 모드 변경 + + + Allow message reactions only if your contact allows them. + 연락처가 메세지 응답을 허용하는 경우에만 메세지 응답을 허용합니다. + + + Better security ✅ + 더 나은 안전✅ + + + Both you and your contact can irreversibly delete sent messages. (24 hours) + 당신과 당신의 연락처 모두 보낸 메세지를 되돌릴 수 없습니다. (24시간) + + + Confirm contact deletion? + 연락처 삭제를 확인하시겠습니까? + + + Can't call contact + 연락처에 전화할 수 없습니다 + + + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + 불가리아어, 핀란드어, 태국어, 우크라이나어 - 사용자 여러분과 [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)에 감사드립니다! + + + Capacity exceeded - recipient did not receive previously sent messages. + 용량 초과 - 수신자가 이전에 보낸 메세지를 받지 못했습니다. + + + Chat + 채팅 + + + Connect to yourself? +This is your own one-time link! + 자신에게 연결할까요? +이것은 당신만의 일회성 링크입니다! + + + Choose _Migrate from another device_ on the new device and scan QR code. + 새 기기에서 _다른 기기에서 이동_을 선택하고 QR 코드를 스캔합니다. + + + Connecting to desktop + 데스크톱에 연결중 + + + Connect with %@ + %@와 연결 + + + Archived contacts + 보관된 연락처 + + + Better message dates. + 더 나은 메세지 날짜. + + + Better networking + 더 나은 네트워킹 + + + Check messages when allowed. + 허용될 때 메시지를 확인합니다. + + + Compare file + 파일 비교 + + + Conditions will be automatically accepted for enabled operators on: %@. + 다음 조건은 활성화된 운영자에 대해 자동으로 수락됩니다: %@. + + + Confirm upload + 업로드 확인 + + + Connect incognito + 비밀 연결 + + + Connect to your friends faster. + 친구들과 더 빨리 연결하세요. + + + Connect to yourself? + 자신과 연결할까요? + + + Created + 생성됨 + + + Creating archive link + 기록 링크 생성하기 + + + Auto-accept + 자동 수락 + + + All new messages from %@ will be hidden! + %@로부터의 모든 새 메세지가 숨겨집니다! + + + Auto-accept settings + 자동-수락 설정 + + + Archive contacts to chat later. + 나중에 채팅할 연락처를 보관합니다. + + + Background + 배경 + + + Bad message hash + 잘못된 메세지 hash + + + Better groups + 더 나은 그룹 + + + Better messages + 더 나은 메세지 + + + Chunks downloaded + 다운로드된 청크 + + + Chunks deleted + 삭제된 청크 + + + Chunks uploaded + 업로드 된 청크 + + + Corner + 코너 + + + Correct name to %@? + %@의 정확한 이름은? + + + Create a group using a random profile. + 랜덤 프로필을 사용하여 그룹을 만듭니다. + + + Authentication cancelled + 인증 취소 + + + Confirm Passcode + 비밀번호 확인 + + + Confirm database upgrades + 데이터베이스 업그레이드 확인 + + + Blur media + 가려진 미디어 + + + Block group members + 그룹 구성원 차단 + + + Connected + 연결됨 + + + All messages will be deleted - this cannot be undone! + 모든 메세지가 삭제됩니다 - 수정할 수 없습니다! + + + All your contacts will remain connected. + 당신의 모든 연락은 계속 연결되어 있습니다. + + + Allow to send SimpleX links. + SinpleX 링크 전송 허용. + + + Bad message ID + 잘못된 메세지 ID + + + Black + 블랙 + + + Block member + 차단 구성원 + + + Connected to desktop + 데스크톱과 연결됨 + + + App passcode is replaced with self-destruct passcode. + 앱 비밀번호는 자체-파괴 비밀번호로 대체됩니다. + + + Apply + 적용 + + + Better notifications + 더 나은 공지 + + + Block member? + 차단 멤버? + + + Blur for better privacy. + 더 나은 개인정보를 위해 흐림. + + + Business address + 사업체 주소 + + + Business chats + 비즈니스 채팅 + + + Can't call member + 회원에게 전화할 수 없습니다 + + + Can't message member + 멤버에게 메세지를 보낼 수 없습니다 + + + Cancel migration + 이동 취소 + + + Change chat profiles + 채팅 프로필 변경 + + + Chat already exists + 채팅이 이미 존재합니다 + + + Chat already exists! + 채팅이 이미 존재합니다! + + + Chat database exported + 채팅 데이터베이스 내보내기 + + + Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. + 채팅이 중지되었습니다. 이미 다른 장치에서 이 데이터베이스를 사용하고 있다면 채팅을 시작하기 전에 다시 전송해야 합니다. + + + Chat migrated! + 채팅 이동! + + + Chat theme + 채팅 테마 + + + Chat will be deleted for all members - this cannot be undone! + 채팅은 모든 회원에게 삭제됩니다 - 이는 되돌릴 수 없습니다! + + + Chat profile + 채팅 프로필 + + + Chat will be deleted for you - this cannot be undone! + 채팅은 삭제됩니다 - 되돌릴 수 없습니다! + + + Check messages every 20 min. + 20분마다 메시지를 확인합니다. + + + Color chats with the new themes. + 새로운 테마로 채팅을 색칠하세요. + + + Color mode + 색깔 모드 + + + Clear private notes? + 개인 메모를 지우시겠습니까? + + + Conditions accepted on: %@. + 조건이 수락됨: %@. + + + Conditions are accepted for the operator(s): **%@**. + 운영자의 조건이 허용됩니다: **%@**. + + + Conditions of use + 이용 조건 + + + Conditions will be accepted for operator(s): **%@**. + 운영자 조건이 수락됩니다: **%@**. + + + Conditions will be accepted for the operator(s): **%@**. + 운영자 조건이 수락됩니다.: **%@**. + + + Confirm that you remember database passphrase to migrate it. + 이동하는데에 필요한 데이터베이스 비밀번호를 기억하는지 확인합니다. + + + Connect to yourself? +This is your own SimpleX address! + 자신과 연결할까요? +이것은 당신의 SimpleX 주소입니다! + + + Connect via contact address + 연락처 주소로 연결 + + + Connecting + 연결중 + + + Connecting to contact, please wait or check later! + 연락처에 연결 중이니 기다려 주시거나 나중에 확인해 주세요! + + + Connection and servers status. + 연결 및 서버 상태. + + + Connection notifications + 연결 공지 + + + Connections + 연결 + + + Contact will be deleted - this cannot be undone! + 연락처가 삭제됩니다 - 취소할 수 없습니다! + + + Conversation deleted! + 대화가 삭제되었습니다! + + + Create 1-time link + 일회성 링크 생성 + + + Create profile + 프로필 생성 + + + Audio/video calls + 음성/영상 통화 + + + Audio/video calls are prohibited. + 음성/영상 통화는 금지되어 있습니다. + + + App session + 앱 세션 + + + Block member for all? + 모두를 위한 차단 멤버? + + + Cannot forward message + 메세지를 전달할 수 없습니다 + + + Confirm network settings + 네트워크 설정 확인 + + + Connect automatically + 자동으로 연결 + + + Confirm files from unknown servers. + 알 수 없는 서버에서 파일을 확인합니다. + + + Conditions are already accepted for these operator(s): **%@**. + 이 운영자들에 대한 조건은 이미 받아들여지고 있습니다: **%@**. + + + Contact deleted! + 연락처 삭제! + + + Current profile + 현재 프로필 + + + Customizable message shape. + 사용자 지정 가능한 메세지 형태. + 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 ab3499a4dc..81d7ba4d98 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d sec time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d overgeslagen bericht(en) @@ -272,11 +280,6 @@ %lld nieuwe interface-talen No comment provided by engineer. - - %lld second(s) - %lld seconde(n) - No comment provided by engineer. - %lld seconds %lld seconden @@ -471,7 +474,8 @@ 1 day 1 dag - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 maand - time interval + delete after time + time interval 1 week 1 week - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -645,6 +655,10 @@ Vrienden toevoegen No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Profiel toevoegen @@ -670,6 +684,10 @@ Toevoegen aan een ander apparaat No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Welkom bericht toevoegen @@ -745,6 +763,10 @@ Geavanceerde instellingen No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Alle app-gegevens worden verwijderd. @@ -755,6 +777,10 @@ Alle chats en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Alle gegevens worden bij het invoeren gewist. @@ -950,6 +976,10 @@ Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. No comment provided by engineer. + + Another reason + report reason + Answer call Beantwoord oproep @@ -975,6 +1005,10 @@ App versleutelt nieuwe lokale bestanden (behalve video's). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon App icon @@ -1020,6 +1054,10 @@ Toepassen op No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiveren en uploaden @@ -1030,6 +1068,14 @@ Archiveer contacten om later te chatten. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Gearchiveerde contacten @@ -1040,6 +1086,10 @@ Database archiveren No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Bijvoegen @@ -1260,6 +1310,10 @@ Zakelijke chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1405,10 @@ Veranderen No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Gebruikersprofielen wijzigen @@ -1572,6 +1630,10 @@ Gesprek wissen? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Privénotities verwijderen? @@ -1592,6 +1654,10 @@ Kleur mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Bestand vergelijken @@ -1826,6 +1892,10 @@ Dit is uw eigen eenmalige link! Verbindings- en serverstatus. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Verbindingsfout @@ -1836,6 +1906,15 @@ Dit is uw eigen eenmalige link! Verbindingsfout (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Verbindingsmeldingen @@ -1846,6 +1925,10 @@ Dit is uw eigen eenmalige link! Verbindingsverzoek verzonden! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Beveiliging van de verbinding @@ -1926,6 +2009,10 @@ Dit is uw eigen eenmalige link! Contact personen kunnen berichten markeren voor verwijdering; u kunt ze wel bekijken. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Doorgaan @@ -2001,6 +2088,10 @@ Dit is uw eigen eenmalige link! Maak link No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Maak een nieuw profiel aan in [desktop-app](https://simplex.chat/downloads/). 💻 @@ -2218,7 +2309,6 @@ Dit is uw eigen eenmalige link! Delete Verwijderen alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ Dit is uw eigen eenmalige link! Chat verwijderen No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Chatprofiel verwijderen @@ -2351,6 +2445,10 @@ Dit is uw eigen eenmalige link! Link verwijderen? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Bericht van lid verwijderen? @@ -2364,7 +2462,7 @@ Dit is uw eigen eenmalige link! Delete messages Verwijder berichten - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ Dit is uw eigen eenmalige link! Wachtrij verwijderen server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Verwijder maximaal 20 berichten tegelijk. @@ -2561,6 +2663,14 @@ Dit is uw eigen eenmalige link! SimpleX Vergrendelen uitschakelen authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Uitschakelen voor iedereen @@ -2651,6 +2761,10 @@ Dit is uw eigen eenmalige link! Gebruik geen inloggegevens met proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Maak geen adres aan @@ -2666,6 +2780,10 @@ Dit is uw eigen eenmalige link! Niet meer weergeven No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Downgraden en chat openen @@ -2770,7 +2888,7 @@ Dit is uw eigen eenmalige link! Enable automatic message deletion? Automatisch verwijderen van berichten aanzetten? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3015,10 @@ Dit is uw eigen eenmalige link! Opnieuw onderhandelen over de codering is mislukt. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Voer toegangscode in @@ -3027,6 +3149,10 @@ Dit is uw eigen eenmalige link! Fout bij maken van groep link No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Fout bij aanmaken contact @@ -3042,6 +3168,10 @@ Dit is uw eigen eenmalige link! Fout bij aanmaken van profiel! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Fout bij het ontsleutelen van bestand @@ -3157,6 +3287,10 @@ Dit is uw eigen eenmalige link! Fout bij verwijderen van lid No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Fout bij het resetten van statistieken @@ -3167,6 +3301,10 @@ Dit is uw eigen eenmalige link! Fout bij opslaan van ICE servers No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Fout bij opslaan van groep profiel @@ -3377,10 +3515,14 @@ Dit is uw eigen eenmalige link! Favoriet swipe action + + Favorites + No comment provided by engineer. + File error Bestandsfout - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ Dit is uw eigen eenmalige link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Bestand niet gevonden - hoogstwaarschijnlijk is het bestand verwijderd of geannuleerd. @@ -3758,6 +3905,10 @@ Fout: %2$@ De groep wordt voor u verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Help @@ -3823,6 +3974,10 @@ Fout: %2$@ Hoe het de privacy helpt No comment provided by engineer. + + How it works + alert button + How to Hoe @@ -3965,6 +4120,14 @@ Binnenkort meer verbeteringen! Geluiden tijdens het bellen No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4185,7 +4348,7 @@ Binnenkort meer verbeteringen! Join - Word lid van + Word lid swipe action @@ -4330,6 +4493,18 @@ Dit is jouw link voor groep %@! Gelinkte desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Live bericht! @@ -4590,6 +4765,10 @@ Dit is jouw link voor groep %@! Berichten van %@ worden getoond! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Berichten ontvangen @@ -4690,6 +4869,10 @@ Dit is jouw link voor groep %@! Gemodereerd op: %@ copied message info + + More + swipe action + More improvements are coming soon! Meer verbeteringen volgen snel! @@ -4860,6 +5043,18 @@ Dit is jouw link voor groep %@! Geen app wachtwoord Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Geen contacten geselecteerd @@ -4965,6 +5160,10 @@ Dit is jouw link voor groep %@! Geen servers om bestanden te verzenden. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Geen gebruikers-ID's. @@ -4975,6 +5174,10 @@ Dit is jouw link voor groep %@! Niet compatibel! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Niets geselecteerd @@ -5083,6 +5286,14 @@ Vereist het inschakelen van VPN. Alleen groep eigenaren kunnen spraak berichten inschakelen. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Alleen jij kunt bericht reacties toevoegen. @@ -5168,11 +5379,23 @@ Vereist het inschakelen van VPN. Open groep No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Open de migratie naar een ander apparaat authentication reason + + Open web link? + No comment provided by engineer. + Opening app… App openen… @@ -5902,6 +6125,38 @@ Schakel dit in in *Netwerk en servers*-instellingen. Antwoord chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Vereist @@ -6078,9 +6333,13 @@ Schakel dit in in *Netwerk en servers*-instellingen. Groep profiel opslaan No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat - Bewaar het wachtwoord en open je chats + Wachtwoord opslaan en open je chats No comment provided by engineer. @@ -6533,6 +6792,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Stel 1 dag in No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Contactnaam instellen… @@ -6861,6 +7124,11 @@ Schakel dit in in *Netwerk en servers*-instellingen. Iemand notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Vierkant, cirkel of iets daartussenin. @@ -6946,6 +7214,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Chat stoppen No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Krachtig @@ -6953,7 +7225,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Submit - Indienen + Bevestigen No comment provided by engineer. @@ -7074,7 +7346,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Temporary file error Tijdelijke bestandsfout - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7480,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Het profiel wordt alleen gedeeld met uw contacten. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Dezelfde voorwaarden gelden voor operator **%@**. @@ -7283,6 +7559,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze actie kan niet ongedaan worden gemaakt, de berichten die eerder zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. Het kan enkele minuten duren. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Deze actie kan niet ongedaan worden gemaakt. Uw profiel, contacten, berichten en bestanden gaan definitief verloren. @@ -7300,7 +7580,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This device name - Deze apparaatnaam + Naam van dit apparaat No comment provided by engineer. @@ -7789,7 +8069,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Using SimpleX Chat servers. - SimpleX Chat servers gebruiken. + Gebruik SimpleX Chat servers. No comment provided by engineer. @@ -8057,6 +8337,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. U **mag** niet dezelfde database op twee apparaten gebruiken. @@ -8591,6 +8875,10 @@ Verbindingsverzoek herhalen? en %lld andere gebeurtenissen No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts pogingen @@ -8623,7 +8911,7 @@ Verbindingsverzoek herhalen? blocked %@ - geblokkeerd %@ + blokkeerde %@ rcv group event chat item @@ -8799,7 +9087,8 @@ Verbindingsverzoek herhalen? default (%@) standaard (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9395,10 @@ Verbindingsverzoek herhalen? gemodereerd door %@ marked deleted chat item preview text + + moderator + member role + months maanden @@ -9119,7 +9412,7 @@ Verbindingsverzoek herhalen? never nooit - No comment provided by engineer. + delete after time new message @@ -9240,7 +9533,7 @@ Verbindingsverzoek herhalen? requested to connect - gevraagd om verbinding te maken + verzocht om verbinding te maken chat list item title 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 8cfdf56f66..c9adcfcd9e 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -114,10 +118,12 @@ %@ server + %@ serwer No comment provided by engineer. %@ servers + %@ serwery/ów No comment provided by engineer. @@ -200,6 +206,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d pominięte wiadomość(i) @@ -270,11 +280,6 @@ %lld nowe języki interfejsu No comment provided by engineer. - - %lld second(s) - %lld sekund(y) - No comment provided by engineer. - %lld seconds %lld sekund @@ -382,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + **Zeskanuj / Wklej link**: aby połączyć się za pomocą otrzymanego linku. No comment provided by engineer. @@ -468,7 +474,8 @@ 1 day 1 dzień - time interval + delete after time + time interval 1 hour @@ -483,19 +490,27 @@ 1 month 1 miesiąc - time interval + delete after time + time interval 1 week 1 tydzień - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + link jednorazowy No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Link jednorazowy może być użyty *tylko z jednym kontaktem* - udostępnij go osobiście lub przez dowolny komunikator. No comment provided by engineer. @@ -574,6 +589,7 @@ About operators + O operatorach No comment provided by engineer. @@ -590,6 +606,7 @@ Accept conditions + Zaakceptuj warunki No comment provided by engineer. @@ -610,6 +627,7 @@ Accepted conditions + Zaakceptowano warunki No comment provided by engineer. @@ -634,6 +652,11 @@ Add friends + Dodaj znajomych + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -653,6 +676,7 @@ Add team members + Dodaj członków zespołu No comment provided by engineer. @@ -660,6 +684,10 @@ Dodaj do innego urządzenia No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Dodaj wiadomość powitalną @@ -667,14 +695,17 @@ Add your team members to the conversations. + Dodaj członków zespołu do konwersacji. No comment provided by engineer. Added media & file servers + Dodano serwery multimediów i plików No comment provided by engineer. Added message servers + Dodano serwery wiadomości No comment provided by engineer. @@ -704,10 +735,12 @@ Address or 1-time link? + Adres czy jednorazowy link? No comment provided by engineer. Address settings + Ustawienia adresu No comment provided by engineer. @@ -730,6 +763,10 @@ Zaawansowane ustawienia No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Wszystkie dane aplikacji są usunięte. @@ -740,6 +777,10 @@ Wszystkie czaty i wiadomości zostaną usunięte - nie można tego cofnąć! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Wszystkie dane są usuwane po jego wprowadzeniu. @@ -934,6 +975,10 @@ Tworzony jest pusty profil czatu o podanej nazwie, a aplikacja otwiera się jak zwykle. No comment provided by engineer. + + Another reason + report reason + Answer call Odbierz połączenie @@ -959,6 +1004,10 @@ Aplikacja szyfruje nowe lokalne pliki (bez filmów). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Ikona aplikacji @@ -1004,6 +1053,10 @@ Zastosuj dla No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiwizuj i prześlij @@ -1014,6 +1067,14 @@ Archiwizuj kontakty aby porozmawiać później. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Zarchiwizowane kontakty @@ -1024,6 +1085,10 @@ Archiwizowanie bazy danych No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Dołącz @@ -1116,6 +1181,7 @@ Better calls + Lepsze połączenia No comment provided by engineer. @@ -1125,6 +1191,7 @@ Better message dates. + Lepsze daty wiadomości. No comment provided by engineer. @@ -1139,14 +1206,17 @@ Better notifications + Lepsze powiadomienia No comment provided by engineer. Better security ✅ + Lepsze zabezpieczenia ✅ No comment provided by engineer. Better user experience + Lepszy interfejs użytkownika No comment provided by engineer. @@ -1231,10 +1301,16 @@ Business address + Adres firmowy No comment provided by engineer. Business chats + Czaty biznesowe + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1328,8 +1404,13 @@ Zmień No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Zmień profil czatu authentication reason @@ -1380,14 +1461,17 @@ Chat + Czat No comment provided by engineer. Chat already exists + Czat już istnieje No comment provided by engineer. Chat already exists! + Czat już istnieje! No comment provided by engineer. @@ -1412,12 +1496,12 @@ Chat database exported - Wyeksportowano bazę danych czatu + Wyeksportowano bazę danych czatów No comment provided by engineer. Chat database imported - Zaimportowano bazę danych czatu + Zaimportowano bazę danych czatów No comment provided by engineer. @@ -1467,10 +1551,12 @@ Chat will be deleted for all members - this cannot be undone! + Czat zostanie usunięty dla wszystkich członków – tej operacji nie można cofnąć! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć! No comment provided by engineer. @@ -1480,10 +1566,12 @@ Check messages every 20 min. + Sprawdzaj wiadomości co 20 min. No comment provided by engineer. Check messages when allowed. + Sprawdź wiadomości, gdy będzie to dopuszczone. No comment provided by engineer. @@ -1541,6 +1629,10 @@ Wyczyścić rozmowę? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Wyczyścić prywatne notatki? @@ -1561,6 +1653,10 @@ Tryb koloru No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Porównaj plik @@ -1578,22 +1674,27 @@ Conditions accepted on: %@. + Warunki zaakceptowane dnia: %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Warunki zostały zaakceptowane przez operatora(-ów): **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**. No comment provided by engineer. Conditions of use + Warunki użytkowania No comment provided by engineer. Conditions will be accepted for enabled operators after 30 days. + Warunki zostaną zaakceptowane dla aktywowanych operatorów po 30 dniach. No comment provided by engineer. @@ -1786,6 +1887,10 @@ To jest twój jednorazowy link! Stan połączenia i serwerów. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Błąd połączenia @@ -1796,6 +1901,15 @@ To jest twój jednorazowy link! Błąd połączenia (UWIERZYTELNIANIE) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Powiadomienia o połączeniu @@ -1806,6 +1920,10 @@ To jest twój jednorazowy link! Prośba o połączenie wysłana! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1885,6 +2003,10 @@ To jest twój jednorazowy link! Kontakty mogą oznaczać wiadomości do usunięcia; będziesz mógł je zobaczyć. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Kontynuuj @@ -1959,6 +2081,10 @@ To jest twój jednorazowy link! Utwórz link No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Utwórz nowy profil w [aplikacji desktopowej](https://simplex.chat/downloads/). 💻 @@ -2174,7 +2300,6 @@ To jest twój jednorazowy link! Delete Usuń alert action - chat item action swipe action @@ -2216,6 +2341,10 @@ To jest twój jednorazowy link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Usuń profil czatu @@ -2305,6 +2434,10 @@ To jest twój jednorazowy link! Usunąć link? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Usunąć wiadomość członka? @@ -2318,7 +2451,7 @@ To jest twój jednorazowy link! Delete messages Usuń wiadomości - No comment provided by engineer. + alert button Delete messages after @@ -2354,6 +2487,10 @@ To jest twój jednorazowy link! Usuń kolejkę server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Usuń do 20 wiadomości na raz. @@ -2512,6 +2649,14 @@ To jest twój jednorazowy link! Wyłącz blokadę SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Wyłącz dla wszystkich @@ -2602,6 +2747,10 @@ To jest twój jednorazowy link! Nie używaj danych logowania do proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Nie twórz adresu @@ -2617,6 +2766,10 @@ To jest twój jednorazowy link! Nie pokazuj ponownie No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Obniż wersję i otwórz czat @@ -2719,7 +2872,7 @@ To jest twój jednorazowy link! Enable automatic message deletion? Czy włączyć automatyczne usuwanie wiadomości? - No comment provided by engineer. + alert title Enable camera access @@ -2846,6 +2999,10 @@ To jest twój jednorazowy link! Renegocjacja szyfrowania nie powiodła się. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Wprowadź Pin @@ -2974,6 +3131,10 @@ To jest twój jednorazowy link! Błąd tworzenia linku grupy No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Błąd tworzenia kontaktu członka @@ -2989,6 +3150,10 @@ To jest twój jednorazowy link! Błąd tworzenia profilu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Błąd odszyfrowania pliku @@ -3103,6 +3268,10 @@ To jest twój jednorazowy link! Błąd usuwania członka No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Błąd resetowania statystyk @@ -3113,6 +3282,10 @@ To jest twój jednorazowy link! Błąd zapisu serwerów ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Błąd zapisu profilu grupy @@ -3320,10 +3493,14 @@ To jest twój jednorazowy link! Ulubione swipe action + + Favorites + No comment provided by engineer. + File error Błąd pliku - No comment provided by engineer. + file error alert title File errors: @@ -3332,6 +3509,11 @@ To jest twój jednorazowy link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Nie odnaleziono pliku - najprawdopodobniej plik został usunięty lub anulowany. @@ -3696,6 +3878,10 @@ Błąd: %2$@ Grupa zostanie usunięta dla Ciebie - nie można tego cofnąć! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Pomoc @@ -3759,6 +3945,10 @@ Błąd: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Jak @@ -3899,6 +4089,14 @@ More improvements are coming soon! Dźwięki w rozmowie No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4261,6 +4459,18 @@ To jest twój link do grupy %@! Połączone komputery No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Wiadomość na żywo! @@ -4519,6 +4729,10 @@ To jest twój link do grupy %@! Wiadomości od %@ zostaną pokazane! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Otrzymane wiadomości @@ -4619,6 +4833,10 @@ To jest twój link do grupy %@! Moderowany o: %@ copied message info + + More + swipe action + More improvements are coming soon! Więcej ulepszeń już wkrótce! @@ -4784,6 +5002,18 @@ To jest twój link do grupy %@! Brak hasła aplikacji Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Nie wybrano kontaktów @@ -4883,6 +5113,10 @@ To jest twój link do grupy %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Brak identyfikatorów użytkownika. @@ -4893,6 +5127,10 @@ To jest twój link do grupy %@! Nie kompatybilny! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nic nie jest zaznaczone @@ -4999,6 +5237,14 @@ Wymaga włączenia VPN. Tylko właściciele grup mogą włączyć wiadomości głosowe. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Tylko Ty możesz dodawać reakcje wiadomości. @@ -5082,11 +5328,23 @@ Wymaga włączenia VPN. Grupa otwarta No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Otwórz migrację na innym urządzeniu authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Otwieranie aplikacji… @@ -5810,6 +6068,38 @@ Włącz w ustawianiach *Sieć i serwery* . Odpowiedz chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Wymagane @@ -5984,6 +6274,10 @@ Włącz w ustawianiach *Sieć i serwery* . Zapisz profil grupy No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Zapisz hasło i otwórz czat @@ -6435,6 +6729,10 @@ Włącz w ustawianiach *Sieć i serwery* . Ustaw 1 dzień No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Ustaw nazwę kontaktu… @@ -6754,6 +7052,11 @@ Włącz w ustawianiach *Sieć i serwery* . Ktoś notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Kwadrat, okrąg lub cokolwiek pomiędzy. @@ -6839,6 +7142,10 @@ Włącz w ustawianiach *Sieć i serwery* . Zatrzymywanie czatu No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Silne @@ -6964,7 +7271,7 @@ Włącz w ustawianiach *Sieć i serwery* . Temporary file error Tymczasowy błąd pliku - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7096,6 +7403,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Profil jest udostępniany tylko Twoim kontaktom. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7166,6 +7477,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Tego działania nie można cofnąć - wiadomości wysłane i odebrane wcześniej niż wybrane zostaną usunięte. Może to potrwać kilka minut. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone. @@ -7928,6 +8243,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Serwer XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Nie możesz** używać tej samej bazy na dwóch urządzeniach. @@ -8455,6 +8774,10 @@ Powtórzyć prośbę połączenia? i %lld innych wydarzeń No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts próby @@ -8663,7 +8986,8 @@ Powtórzyć prośbę połączenia? default (%@) domyślne (%@) - pref value + delete after time + pref value default (no) @@ -8969,6 +9293,10 @@ Powtórzyć prośbę połączenia? moderowany przez %@ marked deleted chat item preview text + + moderator + member role + months miesiące @@ -8982,7 +9310,7 @@ Powtórzyć prośbę połączenia? never nigdy - No comment provided by engineer. + delete after time new message 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 5809c65216..e37d0e4534 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d сек time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропущенных сообщение(й) @@ -272,11 +280,6 @@ %lld новых языков интерфейса No comment provided by engineer. - - %lld second(s) - %lld секунд - No comment provided by engineer. - %lld seconds %lld секунд @@ -471,7 +474,8 @@ 1 day 1 день - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 месяц - time interval + delete after time + time interval 1 week 1 неделю - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -645,6 +655,10 @@ Добавить друзей No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Добавить профиль @@ -670,6 +684,10 @@ Добавить на другое устройство No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Добавить приветственное сообщение @@ -745,6 +763,10 @@ Настройки сети No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Все данные приложения будут удалены. @@ -755,6 +777,10 @@ Все чаты и сообщения будут удалены - это нельзя отменить! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Все данные удаляются при его вводе. @@ -950,6 +976,10 @@ Будет создан пустой профиль чата с указанным именем, и приложение откроется в обычном режиме. No comment provided by engineer. + + Another reason + report reason + Answer call Принять звонок @@ -975,6 +1005,10 @@ Приложение шифрует новые локальные файлы (кроме видео). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Иконка @@ -1020,6 +1054,10 @@ Применить к No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Архивировать и загрузить @@ -1030,6 +1068,14 @@ Архивируйте контакты чтобы продолжить переписку. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Архивированные контакты @@ -1040,6 +1086,10 @@ Подготовка архива No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Прикрепить @@ -1260,6 +1310,10 @@ Бизнес разговоры No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА). @@ -1351,6 +1405,10 @@ Поменять No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Поменять профили @@ -1572,6 +1630,10 @@ Очистить разговор? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Очистить личные заметки? @@ -1592,6 +1654,10 @@ Режим цветов No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Сравнение файла @@ -1826,6 +1892,10 @@ This is your own one-time link! Состояние соединения и серверов. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Ошибка соединения @@ -1836,6 +1906,15 @@ This is your own one-time link! Ошибка соединения (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Уведомления по соединениям @@ -1846,6 +1925,10 @@ This is your own one-time link! Запрос на соединение отправлен! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Безопасность соединения @@ -1926,6 +2009,10 @@ This is your own one-time link! Контакты могут помечать сообщения для удаления; Вы сможете просмотреть их. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продолжить @@ -2001,6 +2088,10 @@ This is your own one-time link! Создать ссылку No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Создайте новый профиль в [приложении для компьютера](https://simplex.chat/downloads/). 💻 @@ -2218,7 +2309,6 @@ This is your own one-time link! Delete Удалить alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ This is your own one-time link! Удалить разговор No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Удалить профиль чата @@ -2351,6 +2445,10 @@ This is your own one-time link! Удалить ссылку? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Удалить сообщение участника? @@ -2364,7 +2462,7 @@ This is your own one-time link! Delete messages Удалить сообщения - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ This is your own one-time link! Удаление очереди server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Удаляйте до 20 сообщений за раз. @@ -2561,6 +2663,14 @@ This is your own one-time link! Отключить блокировку SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Выключить для всех @@ -2651,6 +2761,10 @@ This is your own one-time link! Не использовать учетные данные с прокси. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не создавать адрес @@ -2666,6 +2780,10 @@ This is your own one-time link! Не показывать No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Откатить версию и открыть чат @@ -2770,7 +2888,7 @@ This is your own one-time link! Enable automatic message deletion? Включить автоматическое удаление сообщений? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3015,10 @@ This is your own one-time link! Ошибка нового соглашения о шифровании. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Введите Код @@ -3027,6 +3149,10 @@ This is your own one-time link! Ошибка при создании ссылки группы No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Ошибка создания контакта с членом группы @@ -3042,6 +3168,10 @@ This is your own one-time link! Ошибка создания профиля! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Ошибка расшифровки файла @@ -3157,6 +3287,10 @@ This is your own one-time link! Ошибка при удалении члена группы No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Ошибка сброса статистики @@ -3167,6 +3301,10 @@ This is your own one-time link! Ошибка при сохранении ICE серверов No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Ошибка при сохранении профиля группы @@ -3377,10 +3515,14 @@ This is your own one-time link! Избранный swipe action + + Favorites + No comment provided by engineer. + File error Ошибка файла - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Файл не найден - скорее всего, файл был удален или отменен. @@ -3758,6 +3905,10 @@ Error: %2$@ Группа будет удалена для Вас - это действие нельзя отменить! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Помощь @@ -3823,6 +3974,10 @@ Error: %2$@ Как это улучшает конфиденциальность No comment provided by engineer. + + How it works + alert button + How to Инфо @@ -3964,6 +4119,14 @@ More improvements are coming soon! Звуки во время звонков No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Инкогнито @@ -4329,6 +4492,18 @@ This is your link for group %@! Связанные компьютеры No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Живое сообщение! @@ -4589,6 +4764,10 @@ This is your link for group %@! Сообщения от %@ будут показаны! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Получено сообщений @@ -4689,6 +4868,10 @@ This is your link for group %@! Модерировано: %@ copied message info + + More + swipe action + More improvements are coming soon! Дополнительные улучшения скоро! @@ -4859,6 +5042,18 @@ This is your link for group %@! Нет кода доступа Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Контакты не выбраны @@ -4964,6 +5159,10 @@ This is your link for group %@! Нет серверов для отправки файлов. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Без идентификаторов пользователей. @@ -4974,6 +5173,10 @@ This is your link for group %@! Несовместимая версия! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Ничего не выбрано @@ -5082,6 +5285,14 @@ Requires compatible VPN. Только владельцы группы могут разрешить голосовые сообщения. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Только Вы можете добавлять реакции на сообщения. @@ -5167,11 +5378,23 @@ Requires compatible VPN. Открыть группу No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Открытие миграции на другое устройство authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Приложение отрывается… @@ -5901,6 +6124,38 @@ Enable in *Network & servers* settings. Ответить chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Обязательно @@ -6077,6 +6332,10 @@ Enable in *Network & servers* settings. Сохранить профиль группы No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Сохранить пароль и открыть чат @@ -6532,6 +6791,10 @@ Enable in *Network & servers* settings. Установить 1 день No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Имя контакта… @@ -6860,6 +7123,11 @@ Enable in *Network & servers* settings. Контакт notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Квадрат, круг и все, что между ними. @@ -6945,6 +7213,10 @@ Enable in *Network & servers* settings. Остановка чата No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Сильное @@ -7073,7 +7345,7 @@ Enable in *Network & servers* settings. Temporary file error Временная ошибка файла - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7207,6 +7479,10 @@ It can happen because of some bug or when the connection is compromised.Профиль отправляется только Вашим контактам. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Те же самые условия будут приняты для оператора **%@**. @@ -7282,6 +7558,10 @@ It can happen because of some bug or when the connection is compromised.Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. @@ -8056,6 +8336,10 @@ To connect, please ask your contact to create another connection link and check XFTP сервер No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Вы **не должны** использовать одну и ту же базу данных на двух устройствах. @@ -8590,6 +8874,10 @@ Repeat connection request? и %lld других событий No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts попытки @@ -8798,7 +9086,8 @@ Repeat connection request? default (%@) по умолчанию (%@) - pref value + delete after time + pref value default (no) @@ -9105,6 +9394,10 @@ Repeat connection request? удалено %@ marked deleted chat item preview text + + moderator + member role + months месяцев @@ -9118,7 +9411,7 @@ Repeat connection request? never никогда - No comment provided by engineer. + delete after time new message 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 4317787f67..65e294465f 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -184,6 +188,10 @@ %d วินาที time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d ข้อความที่ถูกข้าม @@ -248,11 +256,6 @@ %lld new interface languages No comment provided by engineer. - - %lld second(s) - %lld วินาที - No comment provided by engineer. - %lld seconds %lld วินาที @@ -433,7 +436,8 @@ 1 day 1 วัน - time interval + delete after time + time interval 1 hour @@ -448,12 +452,18 @@ 1 month 1 เดือน - time interval + delete after time + time interval 1 week 1 สัปดาห์ - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -595,6 +605,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile เพิ่มโปรไฟล์ @@ -619,6 +633,10 @@ เพิ่มเข้าไปในอุปกรณ์อื่น No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message เพิ่มข้อความต้อนรับ @@ -684,6 +702,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. ข้อมูลแอปทั้งหมดถูกลบแล้ว. @@ -694,6 +716,10 @@ แชทและข้อความทั้งหมดจะถูกลบ - การดำเนินการนี้ไม่สามารถยกเลิกได้! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. ข้อมูลทั้งหมดจะถูกลบเมื่อถูกป้อน @@ -876,6 +902,10 @@ โปรไฟล์แชทที่ว่างเปล่าพร้อมชื่อที่ให้ไว้ได้ถูกสร้างขึ้นและแอปจะเปิดตามปกติ No comment provided by engineer. + + Another reason + report reason + Answer call รับสาย @@ -899,6 +929,10 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon ไอคอนแอป @@ -941,6 +975,10 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -949,6 +987,14 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -957,6 +1003,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach แนบ @@ -1154,6 +1204,10 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). ตามโปรไฟล์แชท (ค่าเริ่มต้น) หรือ [โดยการเชื่อมต่อ](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (เบต้า) @@ -1236,6 +1290,10 @@ เปลี่ยน No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1438,6 +1496,10 @@ ลบการสนทนา? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1455,6 +1517,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file เปรียบเทียบไฟล์ @@ -1654,6 +1720,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error การเชื่อมต่อผิดพลาด @@ -1664,6 +1734,15 @@ This is your own one-time link! การเชื่อมต่อผิดพลาด (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1673,6 +1752,10 @@ This is your own one-time link! ส่งคําขอเชื่อมต่อแล้ว! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1746,6 +1829,10 @@ This is your own one-time link! ผู้ติดต่อสามารถทําเครื่องหมายข้อความเพื่อลบได้ คุณจะสามารถดูได้ No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue ดำเนินการต่อ @@ -1814,6 +1901,10 @@ This is your own one-time link! สร้างลิงค์ No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -2018,7 +2109,6 @@ This is your own one-time link! Delete ลบ alert action - chat item action swipe action @@ -2057,6 +2147,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile ลบโปรไฟล์แชท @@ -2144,6 +2238,10 @@ This is your own one-time link! ลบลิงค์ ไหม? No comment provided by engineer. + + Delete list? + alert title + Delete member message? ลบข้อความสมาชิก? @@ -2157,7 +2255,7 @@ This is your own one-time link! Delete messages ลบข้อความ - No comment provided by engineer. + alert button Delete messages after @@ -2193,6 +2291,10 @@ This is your own one-time link! ลบคิว server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2337,6 +2439,14 @@ This is your own one-time link! ปิดการใช้งาน SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all ปิดการใช้งานสำหรับทุกคน @@ -2419,6 +2529,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address อย่าสร้างที่อยู่ @@ -2434,6 +2548,10 @@ This is your own one-time link! ไม่ต้องแสดงอีก No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat ปรับลดรุ่นและเปิดแชท @@ -2528,7 +2646,7 @@ This is your own one-time link! Enable automatic message deletion? เปิดใช้งานการลบข้อความอัตโนมัติ? - No comment provided by engineer. + alert title Enable camera access @@ -2646,6 +2764,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode ใส่รหัสผ่าน @@ -2767,6 +2889,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการสร้างลิงก์กลุ่ม No comment provided by engineer. + + Error creating list + alert title + Error creating member contact No comment provided by engineer. @@ -2780,6 +2906,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการสร้างโปรไฟล์! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file No comment provided by engineer. @@ -2887,6 +3017,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการลบสมาชิก No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2896,6 +3030,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile เกิดข้อผิดพลาดในการบันทึกโปรไฟล์กลุ่ม @@ -3092,15 +3230,24 @@ This is your own one-time link! ที่ชอบ swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3436,6 +3583,10 @@ Error: %2$@ กลุ่มจะถูกลบสำหรับคุณ - ไม่สามารถยกเลิกได้! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help ความช่วยเหลือ @@ -3498,6 +3649,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to วิธี @@ -3630,6 +3785,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito ไม่ระบุตัวตน @@ -3968,6 +4131,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ข้อความสด! @@ -4209,6 +4384,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4297,6 +4476,10 @@ This is your link for group %@! กลั่นกรองที่: %@ copied message info + + More + swipe action + More improvements are coming soon! การปรับปรุงเพิ่มเติมกำลังจะมาเร็ว ๆ นี้! @@ -4451,6 +4634,18 @@ This is your link for group %@! ไม่มีรหัสผ่านสำหรับแอป Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected ไม่ได้เลือกผู้ติดต่อ @@ -4544,6 +4739,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. แพลตฟอร์มแรกที่ไม่มีตัวระบุผู้ใช้ - ถูกออกแบบให้เป็นส่วนตัว @@ -4553,6 +4752,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4653,6 +4856,14 @@ Requires compatible VPN. เฉพาะเจ้าของกลุ่มเท่านั้นที่สามารถเปิดใช้งานข้อความเสียงได้ No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. มีเพียงคุณเท่านั้นที่สามารถแสดงปฏิกิริยาต่อข้อความได้ @@ -4734,10 +4945,22 @@ Requires compatible VPN. Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5399,6 +5622,38 @@ Enable in *Network & servers* settings. ตอบ chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required ที่จำเป็น @@ -5562,6 +5817,10 @@ Enable in *Network & servers* settings. บันทึกโปรไฟล์กลุ่ม No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat บันทึกรหัสผ่านและเปิดแชท @@ -5975,6 +6234,10 @@ Enable in *Network & servers* settings. ตั้ง 1 วัน No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… ตั้งชื่อผู้ติดต่อ… @@ -6271,6 +6534,11 @@ Enable in *Network & servers* settings. ใครบางคน notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6350,6 +6618,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6465,7 +6737,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6594,6 +6866,10 @@ It can happen because of some bug or when the connection is compromised.โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6660,6 +6936,10 @@ It can happen because of some bug or when the connection is compromised.การดำเนินการนี้ไม่สามารถเลิกทำได้ - ข้อความที่ส่งและรับก่อนหน้าที่เลือกไว้จะถูกลบ อาจใช้เวลาหลายนาที No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. การดำเนินการนี้ไม่สามารถยกเลิกได้ - โปรไฟล์ ผู้ติดต่อ ข้อความ และไฟล์ของคุณจะสูญหายไปอย่างถาวร @@ -7353,6 +7633,10 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7846,6 +8130,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8045,7 +8333,8 @@ Repeat connection request? default (%@) ค่าเริ่มต้น (%@) - pref value + delete after time + pref value default (no) @@ -8341,6 +8630,10 @@ Repeat connection request? กลั่นกรองโดย %@ marked deleted chat item preview text + + moderator + member role + months เดือน @@ -8353,7 +8646,7 @@ Repeat connection request? never ไม่เคย - No comment provided by engineer. + delete after time new message 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 261752aefc..0625825006 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -114,10 +118,12 @@ %@ server + %@ sunucu No comment provided by engineer. %@ servers + %@ sunucular No comment provided by engineer. @@ -200,6 +206,10 @@ %d saniye time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d okunmamış mesaj(lar) @@ -270,11 +280,6 @@ %lld yeni arayüz dilleri No comment provided by engineer. - - %lld second(s) - %lld saniye - No comment provided by engineer. - %lld seconds %lld saniye @@ -382,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + edindiğiniz bağlantı aracılığıyla bağlanmak için **Linki tarayın/yapıştırın**. No comment provided by engineer. @@ -468,7 +474,8 @@ 1 day 1 gün - time interval + delete after time + time interval 1 hour @@ -483,19 +490,27 @@ 1 month 1 ay - time interval + delete after time + time interval 1 week 1 hafta - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + tek kullanımlık bağlantı No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Tek kullanımlık bağlantı *sadece bir kişi ile* kullanılabilir - kişiyle veya uygulama içinden paylaş. No comment provided by engineer. @@ -574,6 +589,7 @@ About operators + Operatörler hakkında No comment provided by engineer. @@ -590,6 +606,7 @@ Accept conditions + Koşulları kabul et No comment provided by engineer. @@ -610,6 +627,7 @@ Accepted conditions + Kabul edilmiş koşullar No comment provided by engineer. @@ -634,6 +652,11 @@ Add friends + Arkadaş ekle + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -653,6 +676,7 @@ Add team members + Takım üyesi ekle No comment provided by engineer. @@ -660,6 +684,10 @@ Başka bir cihaza ekle No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Karşılama mesajı ekleyin @@ -667,14 +695,17 @@ Add your team members to the conversations. + Takım üyelerini konuşmalara ekle. No comment provided by engineer. Added media & file servers + medya ve dosya sunucuları eklendi No comment provided by engineer. Added message servers + Mesaj sunucuları eklendi No comment provided by engineer. @@ -704,10 +735,12 @@ Address or 1-time link? + adres mi yoksa tek kullanımlık bağlantı mı? No comment provided by engineer. Address settings + Adres seçenekleri No comment provided by engineer. @@ -730,6 +763,10 @@ Gelişmiş ayarlar No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Tüm uygulama verileri silinir. @@ -740,6 +777,10 @@ Tüm konuşmalar ve mesajlar silinecektir. Bu, geri alınamaz! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Kullanıldığında bütün veriler silinir. @@ -757,6 +798,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte. No comment provided by engineer. @@ -934,6 +976,10 @@ Verilen adla boş bir sohbet profili oluşturulur ve uygulama her zamanki gibi açılır. No comment provided by engineer. + + Another reason + report reason + Answer call Aramayı cevapla @@ -959,6 +1005,10 @@ Uygulama yerel dosyaları şifreler (videolar dışında). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Uygulama simgesi @@ -1004,6 +1054,10 @@ Şuna uygula No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Arşivle ve yükle @@ -1014,6 +1068,14 @@ Daha sonra görüşmek için kişileri arşivleyin. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Arşivli kişiler @@ -1024,6 +1086,10 @@ Veritabanı arşivleniyor No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Ekle @@ -1236,10 +1302,16 @@ Business address + İş adresi No comment provided by engineer. Business chats + İş konuşmaları + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1333,8 +1405,13 @@ Değiştir No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Sohbet profillerini değiştir authentication reason @@ -1385,14 +1462,17 @@ Chat + Sohbet No comment provided by engineer. Chat already exists + Sohbet zaten mevcut No comment provided by engineer. Chat already exists! + Sohbet zaten mevcut! No comment provided by engineer. @@ -1472,10 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! + Sohbet bütün üyeler için silinecek - bu geri alınamaz! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Sohbet senden silinecek - bu geri alınamaz! No comment provided by engineer. @@ -1485,10 +1567,12 @@ Check messages every 20 min. + Her 20 dakikada mesajları kontrol et. No comment provided by engineer. Check messages when allowed. + İzin verildiğinde mesajları kontrol et. No comment provided by engineer. @@ -1546,6 +1630,10 @@ Sohbet temizlensin mi? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Gizli notlar temizlensin mi? @@ -1566,6 +1654,10 @@ Renk modu No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Dosya karşılaştır @@ -1583,38 +1675,47 @@ Conditions accepted on: %@. + Şuradaki koşullar kabul edildi: %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Koşullar operatör(ler) için kabul edildi: **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**. No comment provided by engineer. Conditions of use + Kullanım koşulları No comment provided by engineer. Conditions will be accepted for enabled operators after 30 days. + Koşullar 30 gün sonra etkin operatörler tarafından kabul edilecektir. No comment provided by engineer. Conditions will be accepted for operator(s): **%@**. + Koşullar operatör(ler) için kabul edilecektir: **%@**. No comment provided by engineer. Conditions will be accepted for the operator(s): **%@**. + Koşullar bu operatör(ler) için kabul edilecektir: **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Koşullar şu tarihte kabul edilecektir: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Koşullar etkin operatörler için şu tarihte otomatik olarak kabul edilecektir: %@. No comment provided by engineer. @@ -1791,6 +1892,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı ve sunucuların durumu. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Bağlantı hatası @@ -1801,6 +1906,15 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı hatası (DOĞRULAMA) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Bağlantı bildirimleri @@ -1811,8 +1925,13 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı daveti gönderildi! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security + Bağlantı güvenliği No comment provided by engineer. @@ -1890,6 +2009,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kişiler silinmesi için mesajları işaretleyebilir; onları görüntüleyebilirsin. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Devam et @@ -1932,6 +2055,7 @@ Bu senin kendi tek kullanımlık bağlantın! Create 1-time link + Tek kullanımlık bağlantı oluştur No comment provided by engineer. @@ -1964,6 +2088,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı oluştur No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 [bilgisayar uygulaması] nda yeni bir profil oluştur(https://simplex.chat/downloads/). 💻 @@ -2021,6 +2149,7 @@ Bu senin kendi tek kullanımlık bağlantın! Current conditions text couldn't be loaded, you can review conditions via this link: + Şu anki koşulların yazısı yüklenemiyor, bu bağlantıdan koşullara inceleyebilirsin: No comment provided by engineer. @@ -2180,7 +2309,6 @@ Bu senin kendi tek kullanımlık bağlantın! Delete Sil alert action - chat item action swipe action @@ -2220,6 +2348,11 @@ Bu senin kendi tek kullanımlık bağlantın! Delete chat + Sohbeti sil + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2234,6 +2367,7 @@ Bu senin kendi tek kullanımlık bağlantın! Delete chat? + Sohbet silinsin mi? No comment provided by engineer. @@ -2311,6 +2445,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı silinsin mi? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Kişinin mesajı silinsin mi? @@ -2324,7 +2462,7 @@ Bu senin kendi tek kullanımlık bağlantın! Delete messages Mesajları sil - No comment provided by engineer. + alert button Delete messages after @@ -2361,6 +2499,10 @@ Bu senin kendi tek kullanımlık bağlantın! Sırayı sil server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Tek seferde en fazla 20 mesaj silin. @@ -2502,6 +2644,7 @@ Bu senin kendi tek kullanımlık bağlantın! Direct messages between members are prohibited in this chat. + Üyeler arası doğrudan mesajlar bu sohbette yasaktır. No comment provided by engineer. @@ -2519,6 +2662,14 @@ Bu senin kendi tek kullanımlık bağlantın! SimpleX Kilidini devre dışı bırak authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Herkes için devre dışı bırak @@ -2609,6 +2760,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kimlik bilgilerini proxy ile kullanmayın. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Adres oluşturma @@ -2624,6 +2779,10 @@ Bu senin kendi tek kullanımlık bağlantın! Yeniden gösterme No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Sürüm düşür ve sohbeti aç @@ -2687,6 +2846,7 @@ Bu senin kendi tek kullanımlık bağlantın! E2E encrypted notifications. + Uçtan uca şifrelenmiş bildirimler. No comment provided by engineer. @@ -2711,6 +2871,7 @@ Bu senin kendi tek kullanımlık bağlantın! Enable Flux + Flux'u Etkinleştir No comment provided by engineer. @@ -2726,7 +2887,7 @@ Bu senin kendi tek kullanımlık bağlantın! Enable automatic message deletion? Otomatik mesaj silme etkinleştirilsin mi? - No comment provided by engineer. + alert title Enable camera access @@ -2853,6 +3014,10 @@ Bu senin kendi tek kullanımlık bağlantın! Şifreleme yeniden anlaşma başarısız oldu. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Şifre gir @@ -2920,6 +3085,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error accepting conditions + Koşulları kabul ederken hata oluştu alert title @@ -2934,6 +3100,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error adding server + Sunucu eklenirken hata oluştu alert title @@ -2981,6 +3148,10 @@ Bu senin kendi tek kullanımlık bağlantın! Grup bağlantısı oluşturulurken hata oluştu No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Kişi iletişimi oluşturulurken hata oluştu @@ -2996,6 +3167,10 @@ Bu senin kendi tek kullanımlık bağlantın! Profil oluşturulurken hata oluştu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Dosya şifresi çözülürken hata oluştu @@ -3078,6 +3253,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error loading servers + Sunucular yüklenirken hata oluştu alert title @@ -3110,6 +3286,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kişiyi silerken sorun oluştu No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Hata istatistikler sıfırlanıyor @@ -3120,6 +3300,10 @@ Bu senin kendi tek kullanımlık bağlantın! ICE sunucularını kaydedirken sorun oluştu No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Grup profili kaydedilirken sorun oluştu @@ -3137,6 +3321,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error saving servers + Sunucular kaydedilirken hata oluştu alert title @@ -3211,6 +3396,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error updating server + Sunucu güncellenirken hata oluştu alert title @@ -3260,6 +3446,7 @@ Bu senin kendi tek kullanımlık bağlantın! Errors in servers configuration. + Sunucular yapılandırılırken hatalar oluştu. servers error @@ -3327,10 +3514,14 @@ Bu senin kendi tek kullanımlık bağlantın! Favori swipe action + + Favorites + No comment provided by engineer. + File error Dosya hatası - No comment provided by engineer. + file error alert title File errors: @@ -3339,6 +3530,11 @@ Bu senin kendi tek kullanımlık bağlantın! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Dosya bulunamadı - muhtemelen dosya silindi veya göderim iptal edildi. @@ -3466,6 +3662,7 @@ Bu senin kendi tek kullanımlık bağlantın! For chat profile %@: + Sohbet profili için %@: servers error @@ -3475,14 +3672,17 @@ Bu senin kendi tek kullanımlık bağlantın! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir. No comment provided by engineer. For private routing + Gizli yönlendirme için No comment provided by engineer. For social media + Sosyal medya için No comment provided by engineer. @@ -3704,6 +3904,10 @@ Hata: %2$@ Grup senden silinecektir - bu geri alınamaz! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Yardım @@ -3761,12 +3965,18 @@ Hata: %2$@ How it affects privacy + Gizliliğinizi nasıl etkiler No comment provided by engineer. How it helps privacy + Gizliliğinizi nasıl arttırır No comment provided by engineer. + + How it works + alert button + How to Nasıl yapılır @@ -3909,6 +4119,14 @@ Daha fazla iyileştirme yakında geliyor! Arama içi sesler No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Gizli @@ -4063,6 +4281,7 @@ Daha fazla iyileştirme yakında geliyor! Invite to chat + Sohbete davet et No comment provided by engineer. @@ -4225,10 +4444,12 @@ Bu senin grup için bağlantın %@! Leave chat + Sohbetten ayrıl No comment provided by engineer. Leave chat? + Sohbetten ayrılsın mı? No comment provided by engineer. @@ -4271,6 +4492,18 @@ Bu senin grup için bağlantın %@! Bağlanmış bilgisayarlar No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Canlı mesaj! @@ -4363,6 +4596,7 @@ Bu senin grup için bağlantın %@! Member role will be changed to "%@". All chat members will be notified. + Üye rolü "%@" olarak değiştirilecektir. Tüm sohbet üyeleri bilgilendirilecektir. No comment provided by engineer. @@ -4529,6 +4763,10 @@ Bu senin grup için bağlantın %@! %@ den gelen mesajlar gösterilecektir! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Mesajlar alındı @@ -4629,6 +4867,10 @@ Bu senin grup için bağlantın %@! %@ de yönetildi copied message info + + More + swipe action + More improvements are coming soon! Daha fazla geliştirmeler yakında geliyor! @@ -4794,6 +5036,18 @@ Bu senin grup için bağlantın %@! Uygulama şifresi yok Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Hiçbir kişi seçilmedi @@ -4893,6 +5147,10 @@ Bu senin grup için bağlantın %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Herhangi bir kullanıcı tanımlayıcısı yok. @@ -4903,6 +5161,10 @@ Bu senin grup için bağlantın %@! Uyumlu değil! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Hiçbir şey seçilmedi @@ -5009,6 +5271,14 @@ VPN'nin etkinleştirilmesi gerekir. Yalnızca grup sahipleri sesli mesajları etkinleştirebilir. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Sadece siz mesaj tepkileri ekleyebilirsiniz. @@ -5092,11 +5362,23 @@ VPN'nin etkinleştirilmesi gerekir. Grubu aç No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Başka bir cihaza açık geçiş authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Uygulama açılıyor… @@ -5820,6 +6102,38 @@ Enable in *Network & servers* settings. Yanıtla chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Gerekli @@ -5994,6 +6308,10 @@ Enable in *Network & servers* settings. Grup profilini kaydet No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Parolayı kaydet ve sohbeti aç @@ -6445,6 +6763,10 @@ Enable in *Network & servers* settings. 1 günlüğüne ayarla No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Kişi adı gir… @@ -6765,6 +7087,11 @@ Enable in *Network & servers* settings. Biri notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Kare,daire, veya aralarında herhangi bir şey. @@ -6850,6 +7177,10 @@ Enable in *Network & servers* settings. Sohbeti durdurma No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Güçlü @@ -6977,7 +7308,7 @@ Enable in *Network & servers* settings. Temporary file error Geçici dosya hatası - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7109,6 +7440,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Profil sadece kişilerinle paylaşılacak. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7179,6 +7514,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Bu işlem geri alınamaz - seçilenden daha önce gönderilen ve alınan mesajlar silinecektir. Bu işlem birkaç dakika sürebilir. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Bu işlem geri alınamaz - profiliniz, kişileriniz, mesajlarınız ve dosyalarınız geri döndürülemez şekilde kaybolacaktır. @@ -7941,6 +8280,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste XFTP sunucusu No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Aynı veritabanını iki cihazda **kullanmamalısınız**. @@ -8468,6 +8811,10 @@ Bağlantı isteği tekrarlansın mı? ve %lld diğer etkinlikler No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts denemeler @@ -8676,7 +9023,8 @@ Bağlantı isteği tekrarlansın mı? default (%@) varsayılan (%@) - pref value + delete after time + pref value default (no) @@ -8982,6 +9330,10 @@ Bağlantı isteği tekrarlansın mı? %@ tarafından yönetilmekte marked deleted chat item preview text + + moderator + member role + months aylar @@ -8995,7 +9347,7 @@ Bağlantı isteği tekrarlansın mı? never asla - No comment provided by engineer. + delete after time new message 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 d7dcc58dcd..500fa0c87b 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d сек time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропущено повідомлення(ь) @@ -272,11 +280,6 @@ %lld нові мови інтерфейсу No comment provided by engineer. - - %lld second(s) - %lld секунд(и) - No comment provided by engineer. - %lld seconds %lld секунд @@ -471,7 +474,8 @@ 1 day 1 день - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 місяць - time interval + delete after time + time interval 1 week 1 тиждень - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -579,6 +589,7 @@ About operators + Про операторів No comment provided by engineer. @@ -641,6 +652,11 @@ Add friends + Додайте друзів + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -660,6 +676,7 @@ Add team members + Додайте учасників команди No comment provided by engineer. @@ -667,6 +684,10 @@ Додати до іншого пристрою No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Додати вітальне повідомлення @@ -674,6 +695,7 @@ Add your team members to the conversations. + Додайте членів своєї команди до розмов. No comment provided by engineer. @@ -741,6 +763,10 @@ Додаткові налаштування No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Всі дані програми видаляються. @@ -751,6 +777,10 @@ Всі чати та повідомлення будуть видалені - це неможливо скасувати! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Всі дані стираються при введенні. @@ -946,6 +976,10 @@ Створюється порожній профіль чату з вказаним ім'ям, і додаток відкривається у звичайному режимі. No comment provided by engineer. + + Another reason + report reason + Answer call Відповісти на дзвінок @@ -971,6 +1005,10 @@ Додаток шифрує нові локальні файли (крім відео). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Іконка програми @@ -1016,6 +1054,10 @@ Звертатися до No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Архівування та завантаження @@ -1026,6 +1068,14 @@ Архівуйте контакти, щоб поспілкуватися пізніше. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Архівні контакти @@ -1036,6 +1086,10 @@ Архівування бази даних No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Прикріпити @@ -1248,10 +1302,16 @@ Business address + Адреса підприємства No comment provided by engineer. Business chats + Ділові чати + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1345,6 +1405,10 @@ Зміна No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Зміна профілів користувачів @@ -1398,14 +1462,17 @@ Chat + Чат No comment provided by engineer. Chat already exists + Чат вже існує No comment provided by engineer. Chat already exists! + Чат вже існує! No comment provided by engineer. @@ -1485,10 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! + Чат буде видалено для всіх учасників - цю дію неможливо скасувати! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Чат буде видалено для вас - цю дію неможливо скасувати! No comment provided by engineer. @@ -1561,6 +1630,10 @@ Відверта розмова? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Чисті приватні нотатки? @@ -1581,6 +1654,10 @@ Колірний режим No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Порівняти файл @@ -1815,6 +1892,10 @@ This is your own one-time link! Стан з'єднання та серверів. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Помилка підключення @@ -1825,6 +1906,15 @@ This is your own one-time link! Помилка підключення (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Сповіщення про підключення @@ -1835,6 +1925,10 @@ This is your own one-time link! Запит на підключення відправлено! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Безпека з'єднання @@ -1915,6 +2009,10 @@ This is your own one-time link! Контакти можуть позначати повідомлення для видалення; ви зможете їх переглянути. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продовжуйте @@ -1990,6 +2088,10 @@ This is your own one-time link! Створити посилання No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Створіть новий профіль у [desktop app](https://simplex.chat/downloads/). 💻 @@ -2207,7 +2309,6 @@ This is your own one-time link! Delete Видалити alert action - chat item action swipe action @@ -2247,6 +2348,11 @@ This is your own one-time link! Delete chat + Видалити чат + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2261,6 +2367,7 @@ This is your own one-time link! Delete chat? + Видалити чат? No comment provided by engineer. @@ -2338,6 +2445,10 @@ This is your own one-time link! Видалити посилання? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Видалити повідомлення учасника? @@ -2351,7 +2462,7 @@ This is your own one-time link! Delete messages Видалити повідомлення - No comment provided by engineer. + alert button Delete messages after @@ -2388,6 +2499,10 @@ This is your own one-time link! Видалити чергу server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Видаляйте до 20 повідомлень одночасно. @@ -2530,6 +2645,7 @@ This is your own one-time link! Direct messages between members are prohibited in this chat. + У цьому чаті заборонені прямі повідомлення між учасниками. No comment provided by engineer. @@ -2547,6 +2663,14 @@ This is your own one-time link! Вимкнути SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Вимкнути для всіх @@ -2637,6 +2761,10 @@ This is your own one-time link! Не використовуйте облікові дані з проксі. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не створювати адресу @@ -2652,6 +2780,10 @@ This is your own one-time link! Більше не показувати No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Пониження та відкритий чат @@ -2756,7 +2888,7 @@ This is your own one-time link! Enable automatic message deletion? Увімкнути автоматичне видалення повідомлень? - No comment provided by engineer. + alert title Enable camera access @@ -2883,6 +3015,10 @@ This is your own one-time link! Повторне узгодження шифрування не вдалося. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Введіть пароль @@ -3013,6 +3149,10 @@ This is your own one-time link! Помилка створення посилання на групу No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Помилка при створенні контакту користувача @@ -3028,6 +3168,10 @@ This is your own one-time link! Помилка створення профілю! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Помилка розшифрування файлу @@ -3143,6 +3287,10 @@ This is your own one-time link! Помилка видалення учасника No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Статистика скидання помилок @@ -3153,6 +3301,10 @@ This is your own one-time link! Помилка збереження серверів ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Помилка збереження профілю групи @@ -3363,10 +3515,14 @@ This is your own one-time link! Улюблений swipe action + + Favorites + No comment provided by engineer. + File error Помилка файлу - No comment provided by engineer. + file error alert title File errors: @@ -3375,6 +3531,11 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Файл не знайдено - найімовірніше, файл було видалено або скасовано. @@ -3744,6 +3905,10 @@ Error: %2$@ Група буде видалена для вас - це не може бути скасовано! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Довідка @@ -3809,6 +3974,10 @@ Error: %2$@ Як це захищає приватність No comment provided by engineer. + + How it works + alert button + How to Як зробити @@ -3951,6 +4120,14 @@ More improvements are coming soon! Звуки вхідного дзвінка No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Інкогніто @@ -4105,6 +4282,7 @@ More improvements are coming soon! Invite to chat + Запросити в чат No comment provided by engineer. @@ -4267,10 +4445,12 @@ This is your link for group %@! Leave chat + Вийти з чату No comment provided by engineer. Leave chat? + Залишити чат? No comment provided by engineer. @@ -4313,6 +4493,18 @@ This is your link for group %@! Пов'язані робочі столи No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Живе повідомлення! @@ -4405,6 +4597,7 @@ This is your link for group %@! Member role will be changed to "%@". All chat members will be notified. + Роль учасника буде змінено на "%@". Усі учасники чату отримають сповіщення. No comment provided by engineer. @@ -4419,6 +4612,7 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! + Учасника буде видалено з чату – це неможливо скасувати! No comment provided by engineer. @@ -4571,6 +4765,10 @@ This is your link for group %@! Повідомлення від %@ будуть показані! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Отримані повідомлення @@ -4671,6 +4869,10 @@ This is your link for group %@! Модерується за: %@ copied message info + + More + swipe action + More improvements are coming soon! Незабаром буде ще більше покращень! @@ -4841,6 +5043,18 @@ This is your link for group %@! Немає пароля програми Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Не вибрано жодного контакту @@ -4946,6 +5160,10 @@ This is your link for group %@! Немає серверів для надсилання файлів. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Ніяких ідентифікаторів користувачів. @@ -4956,6 +5174,10 @@ This is your link for group %@! Не сумісні! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Нічого не вибрано @@ -5036,6 +5258,7 @@ Requires compatible VPN. Only chat owners can change preferences. + Лише власники чату можуть змінювати налаштування. No comment provided by engineer. @@ -5063,6 +5286,14 @@ Requires compatible VPN. Тільки власники груп можуть вмикати голосові повідомлення. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Тільки ви можете додавати реакції на повідомлення. @@ -5148,11 +5379,23 @@ Requires compatible VPN. Відкрита група No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Відкрита міграція на інший пристрій authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Відкриваємо програму… @@ -5170,6 +5413,7 @@ Requires compatible VPN. Or import archive file + Або імпортуйте архівний файл No comment provided by engineer. @@ -5435,6 +5679,7 @@ Error: %@ Privacy for your customers. + Конфіденційність для ваших клієнтів. No comment provided by engineer. @@ -5880,6 +6125,38 @@ Enable in *Network & servers* settings. Відповісти chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Потрібно @@ -6056,6 +6333,10 @@ Enable in *Network & servers* settings. Зберегти профіль групи No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Збережіть пароль і відкрийте чат @@ -6511,6 +6792,10 @@ Enable in *Network & servers* settings. Встановити 1 день No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Встановити ім'я контакту… @@ -6694,6 +6979,7 @@ Enable in *Network & servers* settings. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat і Flux уклали угоду про включення серверів, керованих Flux, у додаток. No comment provided by engineer. @@ -6838,6 +7124,11 @@ Enable in *Network & servers* settings. Хтось notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Квадрат, коло або щось середнє між ними. @@ -6923,6 +7214,10 @@ Enable in *Network & servers* settings. Зупинка чату No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Сильний @@ -7010,6 +7305,7 @@ Enable in *Network & servers* settings. Tap Create SimpleX address in the menu to create it later. + Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше. No comment provided by engineer. @@ -7050,7 +7346,7 @@ Enable in *Network & servers* settings. Temporary file error Тимчасова помилка файлу - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7184,6 +7480,10 @@ It can happen because of some bug or when the connection is compromised.Профіль доступний лише вашим контактам. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Такі ж умови діятимуть і для оператора **%@**. @@ -7259,6 +7559,10 @@ It can happen because of some bug or when the connection is compromised.Цю дію неможливо скасувати - повідомлення, надіслані та отримані раніше, ніж вибрані, будуть видалені. Це може зайняти кілька хвилин. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Цю дію неможливо скасувати - ваш профіль, контакти, повідомлення та файли будуть безповоротно втрачені. @@ -8033,6 +8337,10 @@ To connect, please ask your contact to create another connection link and check XFTP-сервер No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Ви **не повинні використовувати** одну і ту ж базу даних на двох пристроях. @@ -8060,6 +8368,7 @@ To connect, please ask your contact to create another connection link and check You are already connected with %@. + Ви вже підключені до %@. No comment provided by engineer. @@ -8338,6 +8647,7 @@ Repeat connection request? You will stop receiving messages from this chat. Chat history will be preserved. + Ви більше не будете отримувати повідомлення з цього чату. Історія чату буде збережена. No comment provided by engineer. @@ -8527,6 +8837,7 @@ Repeat connection request? accepted invitation + прийняте запрошення chat list item title @@ -8564,6 +8875,10 @@ Repeat connection request? та %lld інших подій No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts спроби @@ -8772,7 +9087,8 @@ Repeat connection request? default (%@) за замовчуванням (%@) - pref value + delete after time + pref value default (no) @@ -9079,6 +9395,10 @@ Repeat connection request? модерується %@ marked deleted chat item preview text + + moderator + member role + months місяців @@ -9092,7 +9412,7 @@ Repeat connection request? never ніколи - No comment provided by engineer. + delete after time new message @@ -9213,6 +9533,7 @@ Repeat connection request? requested to connect + запит на підключення chat list item title 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 d6e548c6be..8ebdfadeea 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 @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -194,6 +198,10 @@ %d 秒 time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d 跳过消息 @@ -264,11 +272,6 @@ %lld 种新的界面语言 No comment provided by engineer. - - %lld second(s) - %lld 秒 - No comment provided by engineer. - %lld seconds %lld 秒 @@ -462,7 +465,8 @@ 1 day 1天 - time interval + delete after time + time interval 1 hour @@ -477,12 +481,18 @@ 1 month 1月 - time interval + delete after time + time interval 1 week 1周 - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -630,6 +640,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile 添加个人资料 @@ -654,6 +668,10 @@ 添加另一设备 No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message 添加欢迎信息 @@ -724,6 +742,10 @@ 高级设置 No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. 已删除所有应用程序数据。 @@ -734,6 +756,10 @@ 所有聊天记录和消息将被删除——这一行为无法撤销! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. 所有数据在输入后将被删除。 @@ -928,6 +954,10 @@ 已创建一个包含所提供名字的空白聊天资料,应用程序照常打开。 No comment provided by engineer. + + Another reason + report reason + Answer call 接听来电 @@ -953,6 +983,10 @@ 应用程序为新的本地文件(视频除外)加密。 No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon 应用程序图标 @@ -997,6 +1031,10 @@ 应用于 No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload 存档和上传 @@ -1007,6 +1045,14 @@ 存档联系人以便稍后聊天. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts 已存档的联系人 @@ -1017,6 +1063,10 @@ 正在存档数据库 No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach 附件 @@ -1229,6 +1279,10 @@ Business chats No comment provided by engineer. + + Businesses + No comment provided by engineer. + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). 通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。 @@ -1320,6 +1374,10 @@ 更改 No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1532,6 +1590,10 @@ 清除对话吗? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? 清除私密笔记? @@ -1552,6 +1614,10 @@ 颜色模式 No comment provided by engineer. + + Community guidelines violation + report reason + Compare file 对比文件 @@ -1777,6 +1843,10 @@ This is your own one-time link! 连接和服务器状态。 No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error 连接错误 @@ -1787,6 +1857,15 @@ This is your own one-time link! 连接错误(AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications 连接通知 @@ -1797,6 +1876,10 @@ This is your own one-time link! 已发送连接请求! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1876,6 +1959,10 @@ This is your own one-time link! 联系人可以将信息标记为删除;您将可以查看这些信息。 No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue 继续 @@ -1949,6 +2036,10 @@ This is your own one-time link! 创建链接 No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻 @@ -2164,7 +2255,6 @@ This is your own one-time link! Delete 删除 alert action - chat item action swipe action @@ -2206,6 +2296,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile 删除聊天资料 @@ -2295,6 +2389,10 @@ This is your own one-time link! 删除链接? No comment provided by engineer. + + Delete list? + alert title + Delete member message? 删除成员消息? @@ -2308,7 +2406,7 @@ This is your own one-time link! Delete messages 删除消息 - No comment provided by engineer. + alert button Delete messages after @@ -2344,6 +2442,10 @@ This is your own one-time link! 删除队列 server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. 一次最多删除 20 条信息。 @@ -2502,6 +2604,14 @@ This is your own one-time link! 禁用 SimpleX 锁定 authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all 全部禁用 @@ -2591,6 +2701,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address 不创建地址 @@ -2606,6 +2720,10 @@ This is your own one-time link! 不再显示 No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat 降级并打开聊天 @@ -2707,7 +2825,7 @@ This is your own one-time link! Enable automatic message deletion? 启用自动删除消息? - No comment provided by engineer. + alert title Enable camera access @@ -2834,6 +2952,10 @@ This is your own one-time link! 加密重协商失败了。 No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode 输入密码 @@ -2960,6 +3082,10 @@ This is your own one-time link! 创建群组链接错误 No comment provided by engineer. + + Error creating list + alert title + Error creating member contact 创建成员联系人时出错 @@ -2975,6 +3101,10 @@ This is your own one-time link! 创建资料错误! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file 解密文件时出错 @@ -3088,6 +3218,10 @@ This is your own one-time link! 删除成员错误 No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics 重置统计信息时出错 @@ -3098,6 +3232,10 @@ This is your own one-time link! 保存 ICE 服务器错误 No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile 保存群组资料错误 @@ -3304,16 +3442,25 @@ This is your own one-time link! 最喜欢 swipe action + + Favorites + No comment provided by engineer. + File error 文件错误 - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. 找不到文件 - 很可能文件已被删除或取消。 @@ -3674,6 +3821,10 @@ Error: %2$@ 将为您删除群组——此操作无法撤消! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help 帮助 @@ -3737,6 +3888,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to 如何 @@ -3876,6 +4031,14 @@ More improvements are coming soon! 通话声音 No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito 隐身聊天 @@ -4238,6 +4401,18 @@ This is your link for group %@! 已链接桌面 No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! 实时消息! @@ -4495,6 +4670,10 @@ This is your link for group %@! 将显示来自 %@ 的消息! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received 收到的消息 @@ -4594,6 +4773,10 @@ This is your link for group %@! 已被管理员移除于:%@ copied message info + + More + swipe action + More improvements are coming soon! 更多改进即将推出! @@ -4757,6 +4940,18 @@ This is your link for group %@! 没有应用程序密码 Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected 未选择联系人 @@ -4854,6 +5049,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. 没有用户标识符。 @@ -4864,6 +5063,10 @@ This is your link for group %@! 不兼容! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected 未选中任何内容 @@ -4969,6 +5172,14 @@ Requires compatible VPN. 只有群主可以启用语音信息。 No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. 只有您可以添加消息回应。 @@ -5052,11 +5263,23 @@ Requires compatible VPN. 打开群 No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device 打开迁移到另一台设备 authentication reason + + Open web link? + No comment provided by engineer. + Opening app… 正在打开应用程序… @@ -5774,6 +5997,38 @@ Enable in *Network & servers* settings. 回复 chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required 必须 @@ -5947,6 +6202,10 @@ Enable in *Network & servers* settings. 保存群组资料 No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat 保存密码并打开聊天 @@ -6394,6 +6653,10 @@ Enable in *Network & servers* settings. 设定1天 No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… 设置联系人姓名…… @@ -6710,6 +6973,11 @@ Enable in *Network & servers* settings. 某人 notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. 方形、圆形、或两者之间的任意形状. @@ -6795,6 +7063,10 @@ Enable in *Network & servers* settings. 正在停止聊天 No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong 加粗 @@ -6919,7 +7191,7 @@ Enable in *Network & servers* settings. Temporary file error 临时文件错误 - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7051,6 +7323,10 @@ It can happen because of some bug or when the connection is compromised.该资料仅与您的联系人共享。 No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7120,6 +7396,10 @@ It can happen because of some bug or when the connection is compromised.此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。 No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. 此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。 @@ -7878,6 +8158,10 @@ To connect, please ask your contact to create another connection link and check XFTP 服务器 No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. 您 **不得** 在两台设备上使用相同的数据库。 @@ -8401,6 +8685,10 @@ Repeat connection request? 和 %lld 其他事件 No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts 尝试 @@ -8609,7 +8897,8 @@ Repeat connection request? default (%@) 默认 (%@) - pref value + delete after time + pref value default (no) @@ -8915,6 +9204,10 @@ Repeat connection request? 由 %@ 审核 marked deleted chat item preview text + + moderator + member role + months @@ -8928,7 +9221,7 @@ Repeat connection request? never 从不 - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings index 5ef592ec70..a57961c934 100644 --- a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings @@ -1,7 +1,15 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"%d new events" = "%d nouveaux événements"; + +/* notification body */ +"From: %@" = "De : %@"; + +/* notification */ +"New events" = "Nouveaux événements"; + +/* notification */ +"New messages" = "Nouveaux messages"; + +/* notification body */ +"New messages in %d chats" = "Nouveaux messages dans %d chats"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings b/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings index 48f774742e..4a387a4361 100644 --- a/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings +++ b/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings @@ -5,5 +5,5 @@ "CFBundleName" = "SimpleX SE"; /* Copyright (human-readable) */ -"NSHumanReadableCopyright" = "Copyright © 2024 SimpleX Chat. Alle Rechte vorbehalten."; +"NSHumanReadableCopyright" = "Copyright © 2025 SimpleX Chat. Alle Rechte vorbehalten."; diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index 081d7f8c66..4c10694986 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -17,13 +17,13 @@ "Comment" = "Kommentieren"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Die maximale erlaubte Dateigröße beträgt aktuell %@."; +"Currently maximum supported file size is %@." = "Die maximal erlaubte Dateigröße beträgt aktuell %@."; /* No comment provided by engineer. */ -"Database downgrade required" = "Datenbank-Herabstufung erforderlich"; +"Database downgrade required" = "Datenbank-Herunterstufung ist erforderlich"; /* No comment provided by engineer. */ -"Database encrypted!" = "Datenbank verschlüsselt!"; +"Database encrypted!" = "Datenbank ist verschlüsselt!"; /* No comment provided by engineer. */ "Database error" = "Datenbankfehler"; @@ -32,7 +32,7 @@ "Database passphrase is different from saved in the keychain." = "Das Datenbank-Passwort unterscheidet sich vom im Schlüsselbund gespeicherten."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "Ein Datenbank-Passwort ist erforderlich, um den Chat zu öffnen."; +"Database passphrase is required to open chat." = "Um den Chat zu öffnen, ist ein Datenbank-Passwort ist erforderlich."; /* No comment provided by engineer. */ "Database upgrade required" = "Datenbank-Aktualisierung erforderlich"; @@ -47,7 +47,7 @@ "Error: %@" = "Fehler: %@"; /* No comment provided by engineer. */ -"File error" = "Dateifehler"; +"File error" = "Datei-Fehler"; /* No comment provided by engineer. */ "Incompatible database version" = "Datenbank-Version nicht kompatibel"; @@ -68,16 +68,16 @@ "Ok" = "OK"; /* No comment provided by engineer. */ -"Open the app to downgrade the database." = "Öffne die App, um die Datenbank herabzustufen."; +"Open the app to downgrade the database." = "Öffnen Sie die App, um die Datenbank herunterzustufen."; /* No comment provided by engineer. */ -"Open the app to upgrade the database." = "Öffne die App, um die Datenbank zu aktualisieren."; +"Open the app to upgrade the database." = "Öffnen Sie die App, um die Datenbank zu aktualisieren."; /* No comment provided by engineer. */ "Passphrase" = "Passwort"; /* No comment provided by engineer. */ -"Please create a profile in the SimpleX app" = "Bitte erstelle ein Profil in der SimpleX-App"; +"Please create a profile in the SimpleX app" = "Bitte erstellen Sie ein Profil in der SimpleX-App"; /* No comment provided by engineer. */ "Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt."; @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Falsches Datenbank-Passwort"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Du kannst das Teilen in den Einstellungen zu Datenschutz & Sicherheit - SimpleX-Sperre erlauben."; +"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben."; diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 94f18db853..0ae5c52efa 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -2,7 +2,7 @@ "%@" = "%@"; /* No comment provided by engineer. */ -"App is locked!" = "Az alkalmazás zárolva!"; +"App is locked!" = "Az alkalmazás zárolva van!"; /* No comment provided by engineer. */ "Cancel" = "Mégse"; @@ -29,10 +29,10 @@ "Database error" = "Adatbázishiba"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban lévőtől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "Adatbázis-jelmondat szükséges a csevegés megnyitásához."; +"Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; /* No comment provided by engineer. */ "Database upgrade required" = "Adatbázis fejlesztése szükséges"; @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Hozzon létre egy profilt a SimpleX alkalmazásban"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Az üzenet elküldése a vártnál tovább tart."; @@ -104,7 +104,7 @@ "Wait" = "Várjon"; /* No comment provided by engineer. */ -"Wrong database passphrase" = "Hibás adatbázis-jelmondat"; +"Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* No comment provided by engineer. */ "You can allow sharing in Privacy & Security / SimpleX Lock settings." = "A megosztást az Adatvédelem és biztonság / SimpleX-zár menüben engedélyezheti."; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index f2059d5627..bfc4b50f5d 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -148,6 +148,9 @@ /* notification title */ "%@ wants to connect!" = "%@ иска да се свърже!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@, %@ и %lld членове"; @@ -214,9 +217,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld нови езици на интерфейса"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунда(и)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунди"; @@ -262,7 +262,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 ден"; /* time interval */ @@ -271,12 +272,17 @@ /* No comment provided by engineer. */ "1 minute" = "1 минута"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 месец"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 седмица"; +/* No comment provided by engineer. */ +"1-time link" = "Еднократен линк"; + /* No comment provided by engineer. */ "5 minutes" = "5 минути"; @@ -310,17 +316,26 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Откажи смяна на адрес?"; +/* No comment provided by engineer. */ +"About operators" = "За операторите"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "За SimpleX Chat"; /* No comment provided by engineer. */ "above, then choose:" = "по-горе, след това избери:"; +/* No comment provided by engineer. */ +"Accent" = "Акцент"; + /* accept contact request via notification accept incoming call via notification swipe action */ "Accept" = "Приеми"; +/* No comment provided by engineer. */ +"Accept conditions" = "Приеми условията"; + /* No comment provided by engineer. */ "Accept connection request?" = "Приемане на заявка за връзка?"; @@ -334,9 +349,24 @@ /* call status */ "accepted call" = "обаждането прието"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Приети условия"; + +/* No comment provided by engineer. */ +"Acknowledged" = "Потвърден"; + +/* No comment provided by engineer. */ +"Acknowledgement errors" = "Грешки при потвърждението"; + +/* No comment provided by engineer. */ +"Active connections" = "Активни връзки"; + /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти."; +/* No comment provided by engineer. */ +"Add friends" = "Добави приятели"; + /* No comment provided by engineer. */ "Add profile" = "Добави профил"; @@ -346,18 +376,45 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Добави сървъри чрез сканиране на QR кодове."; +/* No comment provided by engineer. */ +"Add team members" = "Добави членове на екипа"; + /* No comment provided by engineer. */ "Add to another device" = "Добави към друго устройство"; /* No comment provided by engineer. */ "Add welcome message" = "Добави съобщение при посрещане"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Добавете членовете на вашия екип към разговорите."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Добавени медийни и файлови сървъри"; + +/* No comment provided by engineer. */ +"Added message servers" = "Добавени сървъри за съобщения"; + +/* No comment provided by engineer. */ +"Additional accent" = "Допълнителен акцент"; + +/* No comment provided by engineer. */ +"Additional accent 2" = "Допълнителен акцент 2"; + +/* No comment provided by engineer. */ +"Additional secondary" = "Допълнителен вторичен"; + /* No comment provided by engineer. */ "Address" = "Адрес"; /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Промяната на адреса ще бъде прекъсната. Ще се използва старият адрес за получаване."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Адрес или еднократен линк?"; + +/* No comment provided by engineer. */ +"Address settings" = "Настройки на адреса"; + /* member role */ "admin" = "админ"; @@ -373,6 +430,9 @@ /* No comment provided by engineer. */ "Advanced network settings" = "Разширени мрежови настройки"; +/* No comment provided by engineer. */ +"Advanced settings" = "Разширени настройки"; + /* chat item text */ "agreeing encryption for %@…" = "съгласуване на криптиране за %@…"; @@ -388,6 +448,9 @@ /* No comment provided by engineer. */ "All data is erased when it is entered." = "Всички данни се изтриват при въвеждане."; +/* No comment provided by engineer. */ +"All data is kept private on your device." = "Всички данни се съхраняват поверително на вашето устройство."; + /* No comment provided by engineer. */ "All group members will remain connected." = "Всички членове на групата ще останат свързани."; @@ -403,6 +466,9 @@ /* No comment provided by engineer. */ "All new messages from %@ will be hidden!" = "Всички нови съобщения от %@ ще бъдат скрити!"; +/* profile dropdown */ +"All profiles" = "Всички профили"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Всички ваши контакти ще останат свързани."; @@ -418,9 +484,15 @@ /* No comment provided by engineer. */ "Allow calls only if your contact allows them." = "Позволи обаждания само ако вашият контакт ги разрешава."; +/* No comment provided by engineer. */ +"Allow calls?" = "Позволи обаждания?"; + /* No comment provided by engineer. */ "Allow disappearing messages only if your contact allows it to you." = "Позволи изчезващи съобщения само ако вашият контакт ги разрешава."; +/* No comment provided by engineer. */ +"Allow downgrade" = "Позволи понижаване"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Позволи необратимо изтриване на съобщение само ако вашият контакт го рарешава. (24 часа)"; @@ -436,6 +508,9 @@ /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Разреши изпращането на изчезващи съобщения."; +/* No comment provided by engineer. */ +"Allow sharing" = "Позволи споделяне"; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Позволи необратимо изтриване на изпратените съобщения. (24 часа)"; @@ -481,6 +556,9 @@ /* pref value */ "always" = "винаги"; +/* No comment provided by engineer. */ +"Always use private routing." = "Винаги използвай поверително рутиране."; + /* No comment provided by engineer. */ "Always use relay" = "Винаги използвай реле"; @@ -514,6 +592,9 @@ /* No comment provided by engineer. */ "App passcode is replaced with self-destruct passcode." = "Кода за достъп до приложение се заменя с код за самоунищожение."; +/* No comment provided by engineer. */ +"App session" = "Сесия на приложението"; + /* No comment provided by engineer. */ "App version" = "Версия на приложението"; @@ -526,9 +607,18 @@ /* No comment provided by engineer. */ "Apply" = "Приложи"; +/* No comment provided by engineer. */ +"Apply to" = "Приложи към"; + /* No comment provided by engineer. */ "Archive and upload" = "Архивиране и качване"; +/* No comment provided by engineer. */ +"Archive contacts to chat later." = "Архивирайте контактите, за да разговаряте по-късно."; + +/* No comment provided by engineer. */ +"Archived contacts" = "Архивирани контакти"; + /* No comment provided by engineer. */ "Archiving database" = "Архивиране на база данни"; @@ -574,9 +664,15 @@ /* No comment provided by engineer. */ "Auto-accept images" = "Автоматично приемане на изображения"; +/* alert title */ +"Auto-accept settings" = "Автоматично приемане на настройки"; + /* No comment provided by engineer. */ "Back" = "Назад"; +/* No comment provided by engineer. */ +"Background" = "Фон"; + /* No comment provided by engineer. */ "Bad desktop address" = "Грешен адрес на настолното устройство"; @@ -592,12 +688,33 @@ /* No comment provided by engineer. */ "Bad message ID" = "Лошо ID на съобщението"; +/* No comment provided by engineer. */ +"Better calls" = "По-добри обаждания"; + /* No comment provided by engineer. */ "Better groups" = "По-добри групи"; +/* No comment provided by engineer. */ +"Better message dates." = "По-добри дати на съобщението."; + /* No comment provided by engineer. */ "Better messages" = "По-добри съобщения"; +/* No comment provided by engineer. */ +"Better networking" = "Подобрена мрежа"; + +/* No comment provided by engineer. */ +"Better notifications" = "Подобрени известия"; + +/* No comment provided by engineer. */ +"Better security ✅" = "По-добра сигурност ✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "Подобрен интерфейс"; + +/* No comment provided by engineer. */ +"Black" = "Черна"; + /* No comment provided by engineer. */ "Block" = "Блокирай"; @@ -628,6 +745,12 @@ /* No comment provided by engineer. */ "Blocked by admin" = "Блокиран от админ"; +/* No comment provided by engineer. */ +"Blur for better privacy." = "Размазване за по-добра поверителност."; + +/* No comment provided by engineer. */ +"Blur media" = "Размазване на медия"; + /* No comment provided by engineer. */ "bold" = "удебелен"; @@ -649,6 +772,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Бизнес адрес"; + +/* No comment provided by engineer. */ +"Business chats" = "Бизнес чатове"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; @@ -667,9 +796,18 @@ /* No comment provided by engineer. */ "Calls" = "Обаждания"; +/* No comment provided by engineer. */ +"Calls prohibited!" = "Обажданията са забранени!"; + /* No comment provided by engineer. */ "Camera not available" = "Камерата е неодстъпна"; +/* No comment provided by engineer. */ +"Can't call contact" = "Обаждането на контакта не е позволено"; + +/* No comment provided by engineer. */ +"Can't call member" = "Обаждането на члена не е позволено"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Не може да покани контакта!"; @@ -692,12 +830,18 @@ /* alert title */ "Cannot receive file" = "Файлът не може да бъде получен"; +/* snd error text */ +"Capacity exceeded - recipient did not receive previously sent messages." = "Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения."; + /* No comment provided by engineer. */ "Cellular" = "Мобилна мрежа"; /* No comment provided by engineer. */ "Change" = "Промени"; +/* authentication reason */ +"Change chat profiles" = "Промени чат профилите"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Промяна на паролата на базата данни?"; @@ -745,13 +889,13 @@ "Chat console" = "Конзола"; /* No comment provided by engineer. */ -"Chat database" = "База данни за чата"; +"Chat database" = "База данни"; /* No comment provided by engineer. */ "Chat database deleted" = "Базата данни на чата е изтрита"; /* No comment provided by engineer. */ -"Chat database imported" = "Базата данни на чат е импортирана"; +"Chat database imported" = "Базата данни на е импортирана"; /* No comment provided by engineer. */ "Chat is running" = "Чатът работи"; @@ -1125,7 +1269,8 @@ /* message decrypt error item */ "Decryption error" = "Грешка при декриптиране"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "по подразбиране (%@)"; /* No comment provided by engineer. */ @@ -1135,7 +1280,6 @@ "default (yes)" = "по подразбиране (да)"; /* alert action - chat item action swipe action */ "Delete" = "Изтрий"; @@ -1211,7 +1355,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Изтрий съобщението?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Изтрий съобщенията"; /* No comment provided by engineer. */ @@ -1248,7 +1392,7 @@ "deleted contact" = "изтрит контакт"; /* rcv group event chat item */ -"deleted group" = "групата изтрита"; +"deleted group" = "групата е изтрита"; /* No comment provided by engineer. */ "Delivery" = "Доставка"; @@ -1404,7 +1548,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Активиране (запазване на промените)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Активиране на автоматично изтриване на съобщения?"; /* No comment provided by engineer. */ @@ -1603,7 +1747,7 @@ "Error decrypting file" = "Грешка при декриптирането на файла"; /* No comment provided by engineer. */ -"Error deleting chat database" = "Грешка при изтриване на чат базата данни"; +"Error deleting chat database" = "Грешка при изтриване на базата данни"; /* No comment provided by engineer. */ "Error deleting chat!" = "Грешка при изтриването на чата!"; @@ -1636,10 +1780,10 @@ "Error encrypting database" = "Грешка при криптиране на базата данни"; /* No comment provided by engineer. */ -"Error exporting chat database" = "Грешка при експортиране на чат базата данни"; +"Error exporting chat database" = "Грешка при експортиране на базата данни"; /* No comment provided by engineer. */ -"Error importing chat database" = "Грешка при импортиране на чат базата данни"; +"Error importing chat database" = "Грешка при импортиране на базата данни"; /* No comment provided by engineer. */ "Error joining group" = "Грешка при присъединяване към група"; @@ -2014,7 +2158,7 @@ "Import" = "Импортиране"; /* No comment provided by engineer. */ -"Import chat database?" = "Импортиране на чат база данни?"; +"Import chat database?" = "Импортиране на база данни?"; /* No comment provided by engineer. */ "Import database" = "Импортиране на база данни"; @@ -2517,7 +2661,7 @@ /* No comment provided by engineer. */ "Network status" = "Състояние на мрежата"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "никога"; /* No comment provided by engineer. */ @@ -4281,10 +4425,10 @@ "Your calls" = "Вашите обаждания"; /* No comment provided by engineer. */ -"Your chat database" = "Вашата чат база данни"; +"Your chat database" = "Вашата база данни"; /* No comment provided by engineer. */ -"Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата чат база данни не е криптирана - задайте парола, за да я криптирате."; +"Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата база данни не е криптирана - задайте парола, за да я криптирате."; /* No comment provided by engineer. */ "Your chat profiles" = "Вашите чат профили"; @@ -4299,7 +4443,7 @@ "Your contacts will remain connected." = "Вашите контакти ще останат свързани."; /* No comment provided by engineer. */ -"Your current chat database will be DELETED and REPLACED with the imported one." = "Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната."; +"Your current chat database will be DELETED and REPLACED with the imported one." = "Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната."; /* No comment provided by engineer. */ "Your current profile" = "Вашият текущ профил"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 837e76ebbf..478311cef1 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -34,6 +34,9 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(this device v%@)" = "(toto zařízení v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -109,6 +112,9 @@ /* No comment provided by engineer. */ "%@ connected" = "%@ připojen"; +/* No comment provided by engineer. */ +"%@ downloaded" = "%@ staženo"; + /* notification title */ "%@ is connected!" = "%@ je připojen!"; @@ -118,9 +124,18 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ je ověřený"; +/* No comment provided by engineer. */ +"%@ server" = "%@ server"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ servery"; + /* notification title */ "%@ wants to connect!" = "%@ se chce připojit!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld other members connected" = "%@, %@ a %lld ostatní členové připojeni"; @@ -163,15 +178,21 @@ /* No comment provided by engineer. */ "%lld members" = "%lld členové"; +/* No comment provided by engineer. */ +"%lld messages blocked" = "%lld zprávy blokovaný"; + +/* No comment provided by engineer. */ +"%lld messages blocked by admin" = "%lld zprávy blokovaný adminem"; + +/* No comment provided by engineer. */ +"%lld messages marked deleted" = "%lld zprávy označeno jako smazáno"; + /* No comment provided by engineer. */ "%lld minutes" = "%lld minut"; /* No comment provided by engineer. */ "%lld new interface languages" = "%d nové jazyky rozhraní"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld vteřin"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld vteřin"; @@ -214,7 +235,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 den"; /* time interval */ @@ -223,10 +245,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minutu"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 měsíc"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 týden"; /* No comment provided by engineer. */ @@ -906,7 +930,8 @@ /* message decrypt error item */ "Decryption error" = "Chyba dešifrování"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "výchozí (%@)"; /* No comment provided by engineer. */ @@ -916,7 +941,6 @@ "default (yes)" = "výchozí (ano)"; /* alert action - chat item action swipe action */ "Delete" = "Smazat"; @@ -983,7 +1007,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Smazat zprávu?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Smazat zprávy"; /* No comment provided by engineer. */ @@ -1142,7 +1166,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Povolit (zachovat přepsání)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Povolit automatické mazání zpráv?"; /* No comment provided by engineer. */ @@ -2030,7 +2054,7 @@ /* No comment provided by engineer. */ "Network status" = "Stav sítě"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nikdy"; /* notification */ diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index cad89ed29a..13c07528a7 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld neue Sprachen für die Bedienoberfläche"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld Sekunde(n)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld Sekunden"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "täglich"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 Minute"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "monatlich"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "wöchentlich"; /* No comment provided by engineer. */ @@ -344,7 +344,7 @@ "Abort changing address?" = "Wechsel der Empfängeradresse beenden?"; /* No comment provided by engineer. */ -"About operators" = "Über Betreiber"; +"About operators" = "Über die Betreiber"; /* No comment provided by engineer. */ "About SimpleX Chat" = "Über SimpleX Chat"; @@ -380,7 +380,7 @@ "Accepted conditions" = "Akzeptierte Nutzungsbedingungen"; /* chat list item title */ -"accepted invitation" = "Einladung akzeptiert"; +"accepted invitation" = "Einladung angenommen"; /* No comment provided by engineer. */ "Acknowledged" = "Bestätigt"; @@ -785,7 +785,7 @@ "Blur for better privacy." = "Für bessere Privatsphäre verpixeln."; /* No comment provided by engineer. */ -"Blur media" = "Medium unscharf machen"; +"Blur media" = "Medium verpixeln"; /* No comment provided by engineer. */ "bold" = "fett"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Entschlüsselungs-Fehler"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "Voreinstellung (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "Voreinstellung (Ja)"; /* alert action - chat item action swipe action */ "Delete" = "Löschen"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Die Nachricht löschen?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Nachrichten löschen"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Aktivieren (vorgenommene Einstellungen bleiben erhalten)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatisches Löschen von Nachrichten aktivieren?"; /* No comment provided by engineer. */ @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favorit"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Datei-Fehler"; /* alert message */ @@ -2983,13 +2983,13 @@ "Members can send direct messages." = "Gruppenmitglieder können Direktnachrichten versenden."; /* No comment provided by engineer. */ -"Members can send disappearing messages." = "Gruppenmitglieder können verschwindende Nachrichten senden."; +"Members can send disappearing messages." = "Gruppenmitglieder können verschwindende Nachrichten versenden."; /* No comment provided by engineer. */ -"Members can send files and media." = "Gruppenmitglieder können Dateien und Medien senden."; +"Members can send files and media." = "Gruppenmitglieder können Dateien und Medien versenden."; /* No comment provided by engineer. */ -"Members can send SimpleX links." = "Gruppenmitglieder können SimpleX-Links senden."; +"Members can send SimpleX links." = "Gruppenmitglieder können SimpleX-Links versenden."; /* No comment provided by engineer. */ "Members can send voice messages." = "Gruppenmitglieder können Sprachnachrichten versenden."; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Netzwerkstatus"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nie"; /* No comment provided by engineer. */ @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Temporärer Datei-Fehler"; /* server test failure */ @@ -4803,13 +4803,13 @@ "They can be overridden in contact and group settings." = "Sie können in den Kontakteinstellungen überschrieben werden."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden!"; /* E2EE info chat item */ "This chat is protected by end-to-end encryption." = "Dieser Chat ist durch Ende-zu-Ende-Verschlüsselung geschützt."; @@ -5559,7 +5559,7 @@ "You will still receive calls and notifications from muted profiles when they are active." = "Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten."; +"You will stop receiving messages from this chat. Chat history will be preserved." = "Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten."; /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Sie werden von dieser Gruppe keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten."; @@ -5619,7 +5619,7 @@ "Your preferences" = "Ihre Präferenzen"; /* No comment provided by engineer. */ -"Your privacy" = "Ihre Privatsphäre"; +"Your privacy" = "Privatsphäre"; /* No comment provided by engineer. */ "Your profile" = "Mein Profil"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index e7570f177e..39496a182f 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld idiomas de interfaz nuevos"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld segundo(s)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld segundos"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "un dia"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuto"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "un mes"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "una semana"; /* No comment provided by engineer. */ @@ -332,7 +332,7 @@ "A separate TCP connection will be used **for each chat profile you have in the app**." = "Se usará una conexión TCP independiente **por cada perfil que tengas en la aplicación**."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**.\n**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**.\n**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar."; /* No comment provided by engineer. */ "Abort" = "Cancelar"; @@ -416,7 +416,7 @@ "Add welcome message" = "Añadir mensaje de bienvenida"; /* No comment provided by engineer. */ -"Add your team members to the conversations." = "Añade a los miembros de tu equipo a las conversaciones."; +"Add your team members to the conversations." = "Añade a miembros de tu equipo a las conversaciones."; /* No comment provided by engineer. */ "Added media & file servers" = "Servidores de archivos y multimedia añadidos"; @@ -443,7 +443,7 @@ "Address or 1-time link?" = "¿Dirección o enlace de un uso?"; /* No comment provided by engineer. */ -"Address settings" = "Configuración de dirección"; +"Address settings" = "Configurar dirección"; /* member role */ "admin" = "administrador"; @@ -626,7 +626,7 @@ "App passcode is replaced with self-destruct passcode." = "El código de acceso será reemplazado por código de autodestrucción."; /* No comment provided by engineer. */ -"App session" = "Sesión de aplicación"; +"App session" = "por sesión"; /* No comment provided by engineer. */ "App version" = "Versión de la aplicación"; @@ -1033,7 +1033,7 @@ "Clear conversation?" = "¿Vaciar conversación?"; /* No comment provided by engineer. */ -"Clear private notes?" = "¿Borrar notas privadas?"; +"Clear private notes?" = "¿Eliminar notas privadas?"; /* No comment provided by engineer. */ "Clear verification" = "Eliminar verificación"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "errores de descifrado"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "predeterminado (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "predeterminado (sí)"; /* alert action - chat item action swipe action */ "Delete" = "Eliminar"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "¿Eliminar mensaje?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Eliminar mensaje"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Activar (conservar anulaciones)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "¿Activar eliminación automática de mensajes?"; /* No comment provided by engineer. */ @@ -1897,7 +1897,7 @@ "Encrypt database?" = "¿Cifrar base de datos?"; /* No comment provided by engineer. */ -"Encrypt local files" = "Cifra archivos locales"; +"Encrypt local files" = "Cifrar archivos locales"; /* No comment provided by engineer. */ "Encrypt stored files & media" = "Cifra archivos almacenados y multimedia"; @@ -1981,7 +1981,7 @@ "Enter password above to show!" = "¡Introduce la contraseña arriba para mostrar!"; /* No comment provided by engineer. */ -"Enter server manually" = "Introduce el servidor manualmente"; +"Enter server manually" = "Añadir manualmente"; /* No comment provided by engineer. */ "Enter this device name…" = "Nombre de este dispositivo…"; @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favoritos"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Error de archivo"; /* alert message */ @@ -2353,7 +2353,7 @@ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Si por ejemplo tu contacto recibe los mensajes a través de un servidor de SimpleX Chat, tu aplicación los entregará a través de un servidor de Flux."; /* No comment provided by engineer. */ -"For private routing" = "Para el enrutamiento privado"; +"For private routing" = "Para enrutamiento privado"; /* No comment provided by engineer. */ "For social media" = "Para redes sociales"; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Estado de la red"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nunca"; /* No comment provided by engineer. */ @@ -3244,7 +3244,7 @@ "New SOCKS credentials will be used every time you start the app." = "Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación."; /* No comment provided by engineer. */ -"New SOCKS credentials will be used for each server." = "Se usarán credenciales SOCKS nuevas por cada servidor."; +"New SOCKS credentials will be used for each server." = "Se usarán credenciales SOCKS nuevas para cada servidor."; /* pref value */ "no" = "no"; @@ -3286,10 +3286,10 @@ "No info, try to reload" = "No hay información, intenta recargar"; /* servers error */ -"No media & file servers." = "Ningún servidor de archivos y multimedia."; +"No media & file servers." = "Sin servidores para archivos y multimedia."; /* servers error */ -"No message servers." = "Ningún servidor de mensajes."; +"No message servers." = "Sin servidores para mensajes."; /* No comment provided by engineer. */ "No network connection" = "Sin conexión de red"; @@ -3304,22 +3304,22 @@ "No permission to record voice message" = "Sin permiso para grabar mensajes de voz"; /* No comment provided by engineer. */ -"No push server" = "Ningún servidor push"; +"No push server" = "Sin servidores push"; /* No comment provided by engineer. */ "No received or sent files" = "Sin archivos recibidos o enviados"; /* servers error */ -"No servers for private message routing." = "Ningún servidor para enrutamiento privado."; +"No servers for private message routing." = "Sin servidores para enrutamiento privado."; /* servers error */ -"No servers to receive files." = "Ningún servidor para recibir archivos."; +"No servers to receive files." = "Sin servidores para recibir archivos."; /* servers error */ -"No servers to receive messages." = "Ningún servidor para recibir mensajes."; +"No servers to receive messages." = "Sin servidores para recibir mensajes."; /* servers error */ -"No servers to send files." = "Ningún servidor para enviar archivos."; +"No servers to send files." = "Sin servidores para enviar archivos."; /* copied message info in history */ "no text" = "sin texto"; @@ -3396,7 +3396,7 @@ "Only client devices store user profiles, contacts, groups, and messages." = "Sólo los dispositivos cliente almacenan perfiles de usuario, contactos, grupos y mensajes enviados con **cifrado de extremo a extremo de 2 capas**."; /* No comment provided by engineer. */ -"Only delete conversation" = "Sólo borrar la conversación"; +"Only delete conversation" = "Eliminar sólo la conversación"; /* No comment provided by engineer. */ "Only group owners can change group preferences." = "Sólo los propietarios pueden modificar las preferencias del grupo."; @@ -3483,7 +3483,7 @@ "Or securely share this file link" = "O comparte de forma segura este enlace al archivo"; /* No comment provided by engineer. */ -"Or show this code" = "O muestra este código QR"; +"Or show this code" = "O muestra el código QR"; /* No comment provided by engineer. */ "Or to share privately" = "O para compartir en privado"; @@ -3753,7 +3753,7 @@ "Rate the app" = "Valora la aplicación"; /* No comment provided by engineer. */ -"Reachable chat toolbar" = "Barra de herramientas accesible"; +"Reachable chat toolbar" = "Barra de chat accesible"; /* chat item menu */ "React…" = "Reacciona…"; @@ -3840,7 +3840,7 @@ "Reconnect all servers?" = "¿Reconectar todos los servidores?"; /* No comment provided by engineer. */ -"Reconnect server to force message delivery. It uses additional traffic." = "Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional."; +"Reconnect server to force message delivery. It uses additional traffic." = "Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional."; /* No comment provided by engineer. */ "Reconnect server?" = "¿Reconectar servidor?"; @@ -4103,7 +4103,7 @@ "Scan security code from your contact's app." = "Escanea el código de seguridad desde la aplicación de tu contacto."; /* No comment provided by engineer. */ -"Scan server QR code" = "Escanear código QR del servidor"; +"Scan server QR code" = "Escanear código QR"; /* No comment provided by engineer. */ "search" = "buscar"; @@ -4407,7 +4407,7 @@ "Share link" = "Compartir enlace"; /* No comment provided by engineer. */ -"Share profile" = "Comparte perfil"; +"Share profile" = "Perfil a compartir"; /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Comparte tu dirección SimpleX en redes sociales."; @@ -4464,7 +4464,7 @@ "SimpleX address or 1-time link?" = "¿Dirección SimpleX o enlace de un uso?"; /* No comment provided by engineer. */ -"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación"; +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux."; /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La seguridad de SimpleX Chat ha sido auditada por Trail of Bits."; @@ -4578,7 +4578,7 @@ "Stop chat" = "Parar SimpleX"; /* No comment provided by engineer. */ -"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para poder exportar, importar o eliminar la base de datos primero debes parar SimpleX. Mientras tanto no podrás recibir ni enviar mensajes."; +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes."; /* No comment provided by engineer. */ "Stop chat?" = "¿Parar Chat?"; @@ -4614,7 +4614,7 @@ "Submit" = "Enviar"; /* No comment provided by engineer. */ -"Subscribed" = "Suscrito"; +"Subscribed" = "Suscritas"; /* No comment provided by engineer. */ "Subscription errors" = "Errores de suscripción"; @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Error en archivo temporal"; /* server test failure */ @@ -4779,10 +4779,10 @@ "The sender will NOT be notified" = "El remitente NO será notificado"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "Lista de servidores para las conexiones nuevas del perfil **%@**."; +"The servers for new connections of your current chat profile **%@**." = "Servidores para conexiones nuevas en tu perfil **%@**."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Los servidores para archivos nuevos en tu perfil actual **%@**."; +"The servers for new files of your current chat profile **%@**." = "Servidores para archivos nuevos en tu perfil **%@**."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "El texto pegado no es un enlace SimpleX."; @@ -4896,7 +4896,7 @@ "To support instant push notifications the chat database has to be migrated." = "Para permitir las notificaciones automáticas instantáneas, la base de datos se debe migrar."; /* No comment provided by engineer. */ -"To use the servers of **%@**, accept conditions of use." = "Para usar los servidores de **%@**, acepta las condiciones de uso."; +"To use the servers of **%@**, accept conditions of use." = "Para usar los servidores de **%@**, debes aceptar las condiciones de uso."; /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos."; @@ -5001,7 +5001,7 @@ "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "A menos que utilices la interfaz de llamadas de iOS, activa el modo No molestar para evitar interrupciones."; /* No comment provided by engineer. */ -"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo.\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red."; +"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo.\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red."; /* No comment provided by engineer. */ "Unlink" = "Desenlazar"; @@ -5085,13 +5085,13 @@ "Use current profile" = "Usar perfil actual"; /* No comment provided by engineer. */ -"Use for files" = "Usar para archivos"; +"Use for files" = "Uso para archivos"; /* No comment provided by engineer. */ -"Use for messages" = "Usar para mensajes"; +"Use for messages" = "Uso para mensajes"; /* No comment provided by engineer. */ -"Use for new connections" = "Usar para conexiones nuevas"; +"Use for new connections" = "Para conexiones nuevas"; /* No comment provided by engineer. */ "Use from desktop" = "Usar desde ordenador"; @@ -5109,7 +5109,7 @@ "Use private routing with unknown servers when IP address is not protected." = "Usar enrutamiento privado con servidores desconocidos cuando tu dirección IP no está protegida."; /* No comment provided by engineer. */ -"Use private routing with unknown servers." = "Usar enrutamiento privado con servidores de retransmisión desconocidos."; +"Use private routing with unknown servers." = "Usar enrutamiento privado con servidores de mensaje desconocidos."; /* No comment provided by engineer. */ "Use server" = "Usar servidor"; @@ -5391,7 +5391,7 @@ "You are invited to group" = "Has sido invitado a un grupo"; /* No comment provided by engineer. */ -"You are not connected to these servers. Private routing is used to deliver messages to them." = "No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado."; +"You are not connected to these servers. Private routing is used to deliver messages to them." = "No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado."; /* No comment provided by engineer. */ "you are observer" = "Tu rol es observador"; @@ -5514,7 +5514,7 @@ "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Debes usar la versión más reciente de tu base de datos ÚNICAMENTE en un dispositivo, de lo contrario podrías dejar de recibir mensajes de algunos contactos."; /* No comment provided by engineer. */ -"You need to allow your contact to call to be able to call them." = "Necesitas permitir que tus contacto llamen para poder llamarles."; +"You need to allow your contact to call to be able to call them." = "Debes permitir que tus contacto te llamen para poder llamarles."; /* No comment provided by engineer. */ "You need to allow your contact to send voice messages to be able to send them." = "Para poder enviar mensajes de voz antes debes permitir que tu contacto pueda enviarlos."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index f0987f3e1b..05198685cd 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -160,9 +160,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld uutta käyttöliittymän kieltä"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld sekunti(a)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld sekuntia"; @@ -205,7 +202,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 päivä"; /* time interval */ @@ -214,10 +212,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuutti"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 kuukausi"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 viikko"; /* No comment provided by engineer. */ @@ -888,7 +888,8 @@ /* message decrypt error item */ "Decryption error" = "Salauksen purkuvirhe"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "oletusarvo (%@)"; /* No comment provided by engineer. */ @@ -898,7 +899,6 @@ "default (yes)" = "oletusarvo (kyllä)"; /* alert action - chat item action swipe action */ "Delete" = "Poista"; @@ -965,7 +965,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Poista viesti?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Poista viestit"; /* No comment provided by engineer. */ @@ -1124,7 +1124,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Salli (pidä ohitukset)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Ota automaattinen viestien poisto käyttöön?"; /* No comment provided by engineer. */ @@ -2006,7 +2006,7 @@ /* No comment provided by engineer. */ "Network status" = "Verkon tila"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ei koskaan"; /* notification */ diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 6b973e75d0..c378667a9d 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Recommandé** : le token de l'appareil et les notifications sont envoyés au serveur de notifications SimpleX, mais pas le contenu du message, sa taille ou son auteur."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**Scanner / Coller** : pour vous connecter via un lien que vous avez reçu."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Avertissement** : les notifications push instantanées nécessitent une phrase secrète enregistrée dans la keychain."; @@ -142,6 +145,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ est vérifié·e"; +/* No comment provided by engineer. */ +"%@ server" = "Serveur %@"; + +/* No comment provided by engineer. */ +"%@ servers" = "Serveurs %@"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ envoyé"; @@ -232,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nouvelles langues d'interface"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld seconde·s"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld secondes"; @@ -280,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 jour"; /* time interval */ @@ -289,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1 minute"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 mois"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 semaine"; +/* No comment provided by engineer. */ +"1-time link" = "Lien unique"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Le lien unique peut être utilisé *avec un seul contact* - partagez le en personne ou via n'importe quelle messagerie."; + /* No comment provided by engineer. */ "5 minutes" = "5 minutes"; @@ -328,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Abandonner le changement d'adresse ?"; +/* No comment provided by engineer. */ +"About operators" = "À propos des opérateurs"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "À propos de SimpleX Chat"; @@ -342,6 +360,9 @@ swipe action */ "Accept" = "Accepter"; +/* No comment provided by engineer. */ +"Accept conditions" = "Accepter les conditions"; + /* No comment provided by engineer. */ "Accept connection request?" = "Accepter la demande de connexion ?"; @@ -355,6 +376,12 @@ /* call status */ "accepted call" = "appel accepté"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Conditions acceptées"; + +/* chat list item title */ +"accepted invitation" = "invitation acceptée"; + /* No comment provided by engineer. */ "Acknowledged" = "Reçu avec accusé de réception"; @@ -367,6 +394,9 @@ /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d'autres personnes. La mise à jour du profil sera envoyée à vos contacts."; +/* No comment provided by engineer. */ +"Add friends" = "Ajouter des amis"; + /* No comment provided by engineer. */ "Add profile" = "Ajouter un profil"; @@ -376,12 +406,24 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Ajoutez des serveurs en scannant des codes QR."; +/* No comment provided by engineer. */ +"Add team members" = "Ajouter des membres à l'équipe"; + /* No comment provided by engineer. */ "Add to another device" = "Ajouter à un autre appareil"; /* No comment provided by engineer. */ "Add welcome message" = "Ajouter un message d'accueil"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Ajoutez les membres de votre équipe aux conversations."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Ajout de serveurs de médias et de fichiers"; + +/* No comment provided by engineer. */ +"Added message servers" = "Ajout de serveurs de messages"; + /* No comment provided by engineer. */ "Additional accent" = "Accent additionnel"; @@ -397,6 +439,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Le changement d'adresse sera annulé. L'ancienne adresse de réception sera utilisée."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Adresse ou lien unique ?"; + +/* No comment provided by engineer. */ +"Address settings" = "Paramètres de l'adresse"; + /* member role */ "admin" = "admin"; @@ -439,6 +487,9 @@ /* feature role */ "all members" = "tous les membres"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Tous les messages seront supprimés - il n'est pas possible de revenir en arrière !"; @@ -757,6 +808,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgare, finnois, thaïlandais et ukrainien - grâce aux utilisateurs et à [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) !"; +/* No comment provided by engineer. */ +"Business address" = "Adresse professionnelle"; + +/* No comment provided by engineer. */ +"Business chats" = "Discussions professionnelles"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -827,6 +884,9 @@ /* No comment provided by engineer. */ "Change" = "Changer"; +/* authentication reason */ +"Change chat profiles" = "Changer de profil de discussion"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Changer la phrase secrète de la base de données ?"; @@ -870,6 +930,15 @@ /* chat item text */ "changing address…" = "changement d'adresse…"; +/* No comment provided by engineer. */ +"Chat" = "Discussions"; + +/* No comment provided by engineer. */ +"Chat already exists" = "La discussion existe déjà"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "La discussion existe déjà !"; + /* No comment provided by engineer. */ "Chat colors" = "Couleurs de chat"; @@ -915,9 +984,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Thème de chat"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé !"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière !"; + /* No comment provided by engineer. */ "Chats" = "Discussions"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Consulter les messages toutes les 20 minutes."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "Consulter les messages quand c'est possible."; + /* alert title */ "Check server address and try again." = "Vérifiez l'adresse du serveur et réessayez."; @@ -978,6 +1059,33 @@ /* No comment provided by engineer. */ "Completed" = "Complétées"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Conditions acceptées le : %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Les conditions sont acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Les conditions sont déjà acceptées pour ces opérateurs : **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Conditions d'utilisation"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for enabled operators after 30 days." = "Les conditions seront acceptées pour les opérateurs activés après 30 jours."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for operator(s): **%@**." = "Les conditions seront acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Les conditions seront acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Les conditions seront acceptées le : %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Les conditions seront automatiquement acceptées pour les opérateurs activés le : %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Configurer les serveurs ICE"; @@ -1125,6 +1233,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Demande de connexion envoyée !"; +/* No comment provided by engineer. */ +"Connection security" = "Sécurité des connexions"; + /* No comment provided by engineer. */ "Connection terminated" = "Connexion terminée"; @@ -1206,6 +1317,9 @@ /* No comment provided by engineer. */ "Create" = "Créer"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Créer un lien unique"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Création de groupes via un profil aléatoire."; @@ -1257,6 +1371,9 @@ /* No comment provided by engineer. */ "creator" = "créateur"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Le texte sur les conditions actuelles n'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien :"; + /* No comment provided by engineer. */ "Current Passcode" = "Code d'accès actuel"; @@ -1359,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Erreurs de déchiffrement"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "défaut (%@)"; /* No comment provided by engineer. */ @@ -1369,7 +1487,6 @@ "default (yes)" = "par défaut (oui)"; /* alert action - chat item action swipe action */ "Delete" = "Supprimer"; @@ -1394,12 +1511,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Supprimer et en informer le contact"; +/* No comment provided by engineer. */ +"Delete chat" = "Supprimer la discussion"; + /* No comment provided by engineer. */ "Delete chat profile" = "Supprimer le profil de chat"; /* No comment provided by engineer. */ "Delete chat profile?" = "Supprimer le profil du chat ?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Supprimer la discussion ?"; + /* No comment provided by engineer. */ "Delete connection" = "Supprimer la connexion"; @@ -1451,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Supprimer le message ?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Supprimer les messages"; /* No comment provided by engineer. */ @@ -1505,6 +1628,9 @@ /* No comment provided by engineer. */ "Deletion errors" = "Erreurs de suppression"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "Distribués même quand Apple les oublie."; + /* No comment provided by engineer. */ "Delivery" = "Distribution"; @@ -1571,6 +1697,9 @@ /* chat feature */ "Direct messages" = "Messages directs"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "Les messages directs entre membres sont interdits dans cette discussion."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Les messages directs entre membres sont interdits dans ce groupe."; @@ -1692,6 +1821,9 @@ /* No comment provided by engineer. */ "e2e encrypted" = "chiffré de bout en bout"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Notifications chiffrées E2E."; + /* chat item action */ "Edit" = "Modifier"; @@ -1704,12 +1836,15 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Activer (conserver les remplacements)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Activer la suppression automatique des messages ?"; /* No comment provided by engineer. */ "Enable camera access" = "Autoriser l'accès à la caméra"; +/* No comment provided by engineer. */ +"Enable Flux" = "Activer Flux"; + /* No comment provided by engineer. */ "Enable for all" = "Activer pour tous"; @@ -1869,12 +2004,18 @@ /* No comment provided by engineer. */ "Error aborting address change" = "Erreur lors de l'annulation du changement d'adresse"; +/* alert title */ +"Error accepting conditions" = "Erreur lors de la validation des conditions"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Erreur de validation de la demande de contact"; /* No comment provided by engineer. */ "Error adding member(s)" = "Erreur lors de l'ajout de membre·s"; +/* alert title */ +"Error adding server" = "Erreur lors de l'ajout du serveur"; + /* No comment provided by engineer. */ "Error changing address" = "Erreur de changement d'adresse"; @@ -1959,6 +2100,9 @@ /* No comment provided by engineer. */ "Error joining group" = "Erreur lors de la liaison avec le groupe"; +/* alert title */ +"Error loading servers" = "Erreur de chargement des serveurs"; + /* No comment provided by engineer. */ "Error migrating settings" = "Erreur lors de la migration des paramètres"; @@ -1992,6 +2136,9 @@ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Erreur lors de l'enregistrement de la phrase de passe dans la keychain"; +/* alert title */ +"Error saving servers" = "Erreur d'enregistrement des serveurs"; + /* when migrating */ "Error saving settings" = "Erreur lors de l'enregistrement des paramètres"; @@ -2034,6 +2181,9 @@ /* No comment provided by engineer. */ "Error updating message" = "Erreur lors de la mise à jour du message"; +/* alert title */ +"Error updating server" = "Erreur de mise à jour du serveur"; + /* No comment provided by engineer. */ "Error updating settings" = "Erreur lors de la mise à jour des paramètres"; @@ -2061,6 +2211,9 @@ /* No comment provided by engineer. */ "Errors" = "Erreurs"; +/* servers error */ +"Errors in servers configuration." = "Erreurs dans la configuration des serveurs."; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Même s'il est désactivé dans la conversation."; @@ -2106,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favoris"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Erreur de fichier"; /* alert message */ @@ -2187,9 +2340,24 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Correction non prise en charge par un membre du groupe"; +/* No comment provided by engineer. */ +"for better metadata privacy." = "pour une meilleure protection des métadonnées."; + +/* servers error */ +"For chat profile %@:" = "Pour le profil de discussion %@ :"; + /* No comment provided by engineer. */ "For console" = "Pour la console"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux."; + +/* No comment provided by engineer. */ +"For private routing" = "Pour le routage privé"; + +/* No comment provided by engineer. */ +"For social media" = "Pour les réseaux sociaux"; + /* chat item action */ "Forward" = "Transférer"; @@ -2361,6 +2529,12 @@ /* time unit */ "hours" = "heures"; +/* No comment provided by engineer. */ +"How it affects privacy" = "L'impact sur la vie privée"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Comment il contribue à la protection de la vie privée"; + /* No comment provided by engineer. */ "How SimpleX works" = "Comment SimpleX fonctionne"; @@ -2565,6 +2739,9 @@ /* No comment provided by engineer. */ "Invite members" = "Inviter des membres"; +/* No comment provided by engineer. */ +"Invite to chat" = "Inviter à discuter"; + /* No comment provided by engineer. */ "Invite to group" = "Inviter au groupe"; @@ -2679,6 +2856,12 @@ /* swipe action */ "Leave" = "Quitter"; +/* No comment provided by engineer. */ +"Leave chat" = "Quitter la discussion"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Quitter la discussion ?"; + /* No comment provided by engineer. */ "Leave group" = "Quitter le groupe"; @@ -2775,12 +2958,18 @@ /* item status text */ "Member inactive" = "Membre inactif"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Le rôle du membre sera changé pour \"%@\". Tous les membres du groupe en seront informés."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Le rôle du membre sera changé pour \"%@\". Ce membre recevra une nouvelle invitation."; +/* No comment provided by engineer. */ +"Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la discussion - cela ne peut pas être annulé !"; + /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; @@ -2958,6 +3147,9 @@ /* No comment provided by engineer. */ "More reliable network connection." = "Connexion réseau plus fiable."; +/* No comment provided by engineer. */ +"More reliable notifications" = "Notifications plus fiables"; + /* item status description */ "Most likely this connection is deleted." = "Connexion probablement supprimée."; @@ -2982,19 +3174,25 @@ /* No comment provided by engineer. */ "Network connection" = "Connexion au réseau"; +/* No comment provided by engineer. */ +"Network decentralization" = "Décentralisation du réseau"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Problèmes de réseau - le message a expiré après plusieurs tentatives d'envoi."; /* No comment provided by engineer. */ "Network management" = "Gestion du réseau"; +/* No comment provided by engineer. */ +"Network operator" = "Opérateur de réseau"; + /* No comment provided by engineer. */ "Network settings" = "Paramètres réseau"; /* No comment provided by engineer. */ "Network status" = "État du réseau"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "jamais"; /* No comment provided by engineer. */ @@ -3015,6 +3213,9 @@ /* No comment provided by engineer. */ "New display name" = "Nouveau nom d'affichage"; +/* notification */ +"New events" = "Nouveaux événements"; + /* No comment provided by engineer. */ "New in %@" = "Nouveautés de la %@"; @@ -3036,6 +3237,9 @@ /* No comment provided by engineer. */ "New passphrase…" = "Nouvelle phrase secrète…"; +/* No comment provided by engineer. */ +"New server" = "Nouveau serveur"; + /* No comment provided by engineer. */ "New SOCKS credentials will be used every time you start the app." = "De nouveaux identifiants SOCKS seront utilisés chaque fois que vous démarrerez l'application."; @@ -3081,6 +3285,12 @@ /* No comment provided by engineer. */ "No info, try to reload" = "Pas d'info, essayez de recharger"; +/* servers error */ +"No media & file servers." = "Pas de serveurs de médias et de fichiers."; + +/* servers error */ +"No message servers." = "Pas de serveurs de messages."; + /* No comment provided by engineer. */ "No network connection" = "Pas de connexion au réseau"; @@ -3099,6 +3309,18 @@ /* No comment provided by engineer. */ "No received or sent files" = "Aucun fichier reçu ou envoyé"; +/* servers error */ +"No servers for private message routing." = "Pas de serveurs pour le routage privé des messages."; + +/* servers error */ +"No servers to receive files." = "Pas de serveurs pour recevoir des fichiers."; + +/* servers error */ +"No servers to receive messages." = "Pas de serveurs pour recevoir des messages."; + +/* servers error */ +"No servers to send files." = "Pas de serveurs pour envoyer des fichiers."; + /* copied message info in history */ "no text" = "aucun texte"; @@ -3120,6 +3342,9 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Les notifications sont désactivées !"; +/* No comment provided by engineer. */ +"Notifications privacy" = "Notifications sécurisées"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Désormais, les administrateurs peuvent :\n- supprimer les messages des membres.\n- désactiver des membres (rôle \"observateur\")"; @@ -3164,6 +3389,9 @@ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Les hôtes .onion ne seront pas utilisés."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Seuls les propriétaires peuvent modifier les préférences."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un **chiffrement de bout en bout à deux couches**."; @@ -3212,12 +3440,18 @@ /* No comment provided by engineer. */ "Open" = "Ouvrir"; +/* No comment provided by engineer. */ +"Open changes" = "Ouvrir les modifications"; + /* No comment provided by engineer. */ "Open chat" = "Ouvrir le chat"; /* authentication reason */ "Open chat console" = "Ouvrir la console du chat"; +/* No comment provided by engineer. */ +"Open conditions" = "Ouvrir les conditions"; + /* No comment provided by engineer. */ "Open group" = "Ouvrir le groupe"; @@ -3230,6 +3464,15 @@ /* No comment provided by engineer. */ "Opening app…" = "Ouverture de l'app…"; +/* No comment provided by engineer. */ +"Operator" = "Opérateur"; + +/* alert title */ +"Operator server" = "Serveur de l'opérateur"; + +/* No comment provided by engineer. */ +"Or import archive file" = "Ou importer un fichier d'archive"; + /* No comment provided by engineer. */ "Or paste archive link" = "Ou coller le lien de l'archive"; @@ -3242,6 +3485,9 @@ /* No comment provided by engineer. */ "Or show this code" = "Ou montrez ce code"; +/* No comment provided by engineer. */ +"Or to share privately" = "Ou à partager en privé"; + /* No comment provided by engineer. */ "other" = "autre"; @@ -3383,6 +3629,9 @@ /* No comment provided by engineer. */ "Preset server address" = "Adresse du serveur prédéfinie"; +/* No comment provided by engineer. */ +"Preset servers" = "Serveurs prédéfinis"; + /* No comment provided by engineer. */ "Preview" = "Aperçu"; @@ -3392,6 +3641,9 @@ /* No comment provided by engineer. */ "Privacy & security" = "Vie privée et sécurité"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Respect de la vie privée de vos clients."; + /* No comment provided by engineer. */ "Privacy redefined" = "La vie privée redéfinie"; @@ -3684,6 +3936,9 @@ /* chat item action */ "Reply" = "Répondre"; +/* chat list item title */ +"requested to connect" = "demande à se connecter"; + /* No comment provided by engineer. */ "Required" = "Requis"; @@ -3735,6 +3990,12 @@ /* chat item action */ "Reveal" = "Révéler"; +/* No comment provided by engineer. */ +"Review conditions" = "Vérifier les conditions"; + +/* No comment provided by engineer. */ +"Review later" = "Vérifier plus tard"; + /* No comment provided by engineer. */ "Revoke" = "Révoquer"; @@ -4021,6 +4282,9 @@ /* No comment provided by engineer. */ "Server" = "Serveur"; +/* alert message */ +"Server added to operator %@." = "Serveur ajouté à l'opérateur %@."; + /* No comment provided by engineer. */ "Server address" = "Adresse du serveur"; @@ -4030,6 +4294,15 @@ /* srv error text. */ "Server address is incompatible with network settings." = "L'adresse du serveur est incompatible avec les paramètres du réseau."; +/* alert title */ +"Server operator changed." = "L'opérateur du serveur a changé."; + +/* No comment provided by engineer. */ +"Server operators" = "Opérateurs de serveur"; + +/* alert title */ +"Server protocol changed." = "Le protocole du serveur a été modifié."; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "info sur la file d'attente du serveur : %1$@\n\ndernier message reçu : %2$@"; @@ -4115,9 +4388,15 @@ /* No comment provided by engineer. */ "Share 1-time link" = "Partager un lien unique"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "Partager un lien unique avec un ami"; + /* No comment provided by engineer. */ "Share address" = "Partager l'adresse"; +/* No comment provided by engineer. */ +"Share address publicly" = "Partager publiquement votre adresse"; + /* alert title */ "Share address with contacts?" = "Partager l'adresse avec vos contacts ?"; @@ -4130,6 +4409,9 @@ /* No comment provided by engineer. */ "Share profile" = "Partager le profil"; +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "Partagez votre adresse SimpleX sur les réseaux sociaux."; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "Partagez ce lien d'invitation unique"; @@ -4175,6 +4457,15 @@ /* No comment provided by engineer. */ "SimpleX Address" = "Adresse SimpleX"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n'importe quelle messagerie."; + +/* No comment provided by engineer. */ +"SimpleX address or 1-time link?" = "Adresse SimpleX ou lien unique ?"; + +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La sécurité de SimpleX Chat a été auditée par Trail of Bits."; @@ -4250,6 +4541,9 @@ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "L'importation a entraîné des erreurs non fatales :"; +/* alert message */ +"Some servers failed the test:\n%@" = "Certains serveurs ont échoué le test :\n%@"; + /* notification title */ "Somebody" = "Quelqu'un"; @@ -4352,6 +4646,9 @@ /* No comment provided by engineer. */ "Tap button " = "Appuyez sur le bouton "; +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement."; + /* No comment provided by engineer. */ "Tap to activate profile." = "Appuyez pour activer un profil."; @@ -4385,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Erreur de fichier temporaire"; /* server test failure */ @@ -4412,6 +4709,9 @@ /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "L'application peut vous avertir lorsque vous recevez des messages ou des demandes de contact - veuillez ouvrir les paramètres pour les activer."; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "L'application demandera de confirmer les téléchargements à partir de serveurs de fichiers inconnus (sauf .onion)."; @@ -4421,6 +4721,9 @@ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Le code scanné n'est pas un code QR de lien SimpleX."; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne."; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "La connexion que vous avez acceptée sera annulée !"; @@ -4460,6 +4763,15 @@ /* No comment provided by engineer. */ "The profile is 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 **%@**."; + +/* No comment provided by engineer. */ +"The same conditions will apply to operator(s): **%@**." = "Les mêmes conditions s'appliquent à(aux) l'opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "Le deuxième opérateur prédéfini de l'application !"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Le deuxième coche que nous avons manqué ! ✅"; @@ -4469,6 +4781,9 @@ /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "Les serveurs pour les nouvelles connexions de votre profil de chat actuel **%@**."; +/* No comment provided by engineer. */ +"The servers for new files of your current chat profile **%@**." = "Les serveurs pour les nouveaux fichiers de votre profil de chat actuel **%@**."; + /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Le texte collé n'est pas un lien SimpleX."; @@ -4478,6 +4793,9 @@ /* No comment provided by engineer. */ "Themes" = "Thèmes"; +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "Ces conditions s'appliquent également aux : **%@**."; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "Ces paramètres s'appliquent à votre profil actuel **%@**."; @@ -4541,6 +4859,9 @@ /* No comment provided by engineer. */ "To make a new connection" = "Pour établir une nouvelle connexion"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts."; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Pour préserver le fuseau horaire, les fichiers image/voix utilisent le système UTC."; @@ -4553,6 +4874,9 @@ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Pour protéger votre vie privée, au lieu d’IDs utilisés par toutes les autres plateformes, SimpleX a des IDs pour les queues de messages, distinctes pour chacun de vos contacts."; +/* No comment provided by engineer. */ +"To receive" = "Pour recevoir"; + /* No comment provided by engineer. */ "To record speech please grant permission to use Microphone." = "Si vous souhaitez enregistrer une conversation, veuillez autoriser l'utilisation du microphone."; @@ -4565,9 +4889,15 @@ /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page **Vos profils de chat**."; +/* No comment provided by engineer. */ +"To send" = "Pour envoyer"; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Pour prendre en charge les notifications push instantanées, la base de données du chat doit être migrée."; +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "Pour utiliser les serveurs de **%@**, acceptez les conditions d'utilisation."; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils."; @@ -4625,6 +4955,9 @@ /* rcv group event chat item */ "unblocked %@" = "%@ débloqué"; +/* No comment provided by engineer. */ +"Undelivered messages" = "Messages non distribués"; + /* No comment provided by engineer. */ "Unexpected migration state" = "État de la migration inattendu"; @@ -4742,12 +5075,21 @@ /* No comment provided by engineer. */ "Use .onion hosts" = "Utiliser les hôtes .onions"; +/* No comment provided by engineer. */ +"Use %@" = "Utiliser %@"; + /* No comment provided by engineer. */ "Use chat" = "Utiliser le chat"; /* No comment provided by engineer. */ "Use current profile" = "Utiliser le profil actuel"; +/* No comment provided by engineer. */ +"Use for files" = "Utiliser pour les fichiers"; + +/* No comment provided by engineer. */ +"Use for messages" = "Utiliser pour les messages"; + /* No comment provided by engineer. */ "Use for new connections" = "Utiliser pour les nouvelles connexions"; @@ -4772,6 +5114,9 @@ /* No comment provided by engineer. */ "Use server" = "Utiliser ce serveur"; +/* No comment provided by engineer. */ +"Use servers" = "Utiliser les serveurs"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Utiliser les serveurs SimpleX Chat ?"; @@ -4856,9 +5201,15 @@ /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Vidéos et fichiers jusqu'à 1Go"; +/* No comment provided by engineer. */ +"View conditions" = "Voir les conditions"; + /* No comment provided by engineer. */ "View security code" = "Afficher le code de sécurité"; +/* No comment provided by engineer. */ +"View updated conditions" = "Voir les conditions mises à jour"; + /* chat feature */ "Visible history" = "Historique visible"; @@ -4940,6 +5291,9 @@ /* No comment provided by engineer. */ "when IP hidden" = "lorsque l'IP est masquée"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Lorsque plusieurs opérateurs sont activés, aucun d'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui."; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Lorsque vous partagez un profil incognito avec quelqu'un, ce profil sera utilisé pour les groupes auxquels il vous invite."; @@ -5003,6 +5357,9 @@ /* No comment provided by engineer. */ "You are already connected to %@." = "Vous êtes déjà connecté·e à %@ via ce lien."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Vous êtes déjà connecté avec %@."; + /* No comment provided by engineer. */ "You are already connecting to %@." = "Vous êtes déjà en train de vous connecter à %@."; @@ -5048,6 +5405,12 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Vous pouvez choisir de le modifier dans les paramètres d'apparence."; +/* No comment provided by engineer. */ +"You can configure operators in Network & servers settings." = "Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs."; + +/* No comment provided by engineer. */ +"You can configure servers via settings." = "Vous pouvez configurer les serveurs via les paramètres."; + /* No comment provided by engineer. */ "You can create it later" = "Vous pouvez la créer plus tard"; @@ -5072,6 +5435,9 @@ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Vous pouvez envoyer des messages à %@ à partir des contacts archivés."; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé."; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Vous pouvez configurer l'aperçu des notifications sur l'écran de verrouillage via les paramètres."; @@ -5192,6 +5558,9 @@ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu'ils sont actifs."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Vous ne recevrez plus de messages de ce groupe. L'historique du chat sera conservé."; @@ -5273,6 +5642,9 @@ /* No comment provided by engineer. */ "Your server address" = "Votre adresse de serveur"; +/* No comment provided by engineer. */ +"Your servers" = "Vos serveurs"; + /* No comment provided by engineer. */ "Your settings" = "Vos paramètres"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 2ba51d1e13..1c330a0c1d 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -68,7 +68,7 @@ "**e2e encrypted** video call" = "**e2e titkosított** videóhívás"; /* No comment provided by engineer. */ -"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken megosztásra kerül a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van."; +"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van."; /* No comment provided by engineer. */ "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." = "**Legprivátabb:** ne használja a SimpleX Chat értesítési kiszolgálót, rendszeresen ellenőrizze az üzeneteket a háttérben (attól függően, hogy milyen gyakran használja az alkalmazást)."; @@ -77,10 +77,10 @@ "**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM tudja visszaállítani vagy megváltoztatni jelmondatát, ha elveszíti azt."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti."; /* No comment provided by engineer. */ -"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések elküldésre kerülnek a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; +"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; /* No comment provided by engineer. */ "**Scan / Paste link**: to connect via a link you received." = "**Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz."; @@ -89,7 +89,7 @@ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; /* No comment provided by engineer. */ -"**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum eltávolításra kerül."; +"**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum el lesz távolítva."; /* No comment provided by engineer. */ "*bold*" = "\\*félkövér*"; @@ -212,7 +212,7 @@ "%lld %@" = "%lld %@"; /* No comment provided by engineer. */ -"%lld contact(s) selected" = "%lld ismerős kiválasztva"; +"%lld contact(s) selected" = "%lld ismerős kijelölve"; /* No comment provided by engineer. */ "%lld file(s) with total size of %@" = "%lld fájl, amely(ek)nek teljes mérete: %@"; @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld új kezelőfelületi nyelv"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld másodperc"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld másodperc"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 nap"; /* time interval */ @@ -298,17 +296,19 @@ /* No comment provided by engineer. */ "1 minute" = "1 perc"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 hónap"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 hét"; /* No comment provided by engineer. */ "1-time link" = "Egyszer használható meghívó-hivatkozás"; /* No comment provided by engineer. */ -"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* - személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; /* No comment provided by engineer. */ "5 minutes" = "5 perc"; @@ -320,13 +320,13 @@ "30 seconds" = "30 másodperc"; /* No comment provided by engineer. */ -"A few more things" = "Még néhány dolog"; +"A few more things" = "Néhány további dolog"; /* notification title */ "A new contact" = "Egy új ismerős"; /* No comment provided by engineer. */ -"A new random profile will be shared." = "Egy új, véletlenszerű profil kerül megosztásra."; +"A new random profile will be shared." = "Egy új, véletlenszerű profil lesz megosztva."; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "**Az összes csevegési profiljához az alkalmazásban** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva."; @@ -383,16 +383,16 @@ "accepted invitation" = "elfogadott meghívó"; /* No comment provided by engineer. */ -"Acknowledged" = "Nyugtázva"; +"Acknowledged" = "Visszaigazolt"; /* No comment provided by engineer. */ -"Acknowledgement errors" = "Nyugtázott hibák"; +"Acknowledgement errors" = "Visszaigazolási hibák"; /* No comment provided by engineer. */ "Active connections" = "Aktív kapcsolatok száma"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés elküldésre kerül az ismerősei számára."; +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ "Add friends" = "Barátok hozzáadása"; @@ -407,7 +407,7 @@ "Add servers by scanning QR codes." = "Kiszolgáló hozzáadása QR-kód beolvasásával."; /* No comment provided by engineer. */ -"Add team members" = "Csapattagok hozzáadása"; +"Add team members" = "Munkatársak hozzáadása"; /* No comment provided by engineer. */ "Add to another device" = "Hozzáadás egy másik eszközhöz"; @@ -416,7 +416,7 @@ "Add welcome message" = "Üdvözlőüzenet hozzáadása"; /* No comment provided by engineer. */ -"Add your team members to the conversations." = "Adja hozzá csapattagjait a beszélgetésekhez."; +"Add your team members to the conversations." = "Adja hozzá a munkatársait a beszélgetésekhez."; /* No comment provided by engineer. */ "Added media & file servers" = "Hozzáadott média- és fájlkiszolgálók"; @@ -437,7 +437,7 @@ "Address" = "Cím"; /* No comment provided by engineer. */ -"Address change will be aborted. Old receiving address will be used." = "A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra."; +"Address change will be aborted. Old receiving address will be used." = "A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva."; /* No comment provided by engineer. */ "Address or 1-time link?" = "Cím vagy egyszer használható meghívó-hivatkozás?"; @@ -473,13 +473,13 @@ "All app data is deleted." = "Az összes alkalmazásadat törölve."; /* No comment provided by engineer. */ -"All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza!"; +"All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"All data is erased when it is entered." = "A jelkód megadása után az összes adat törlésre kerül."; +"All data is erased when it is entered." = "A jelkód megadása után az összes adat törölve lesz."; /* No comment provided by engineer. */ -"All data is kept private on your device." = "Az összes adat biztonságban van az eszközén."; +"All data is kept private on your device." = "Az összes adat privát módon van tárolva az Ön eszközén."; /* No comment provided by engineer. */ "All group members will remain connected." = "Az összes csoporttag kapcsolatban marad."; @@ -488,16 +488,16 @@ "all members" = "összes tag"; /* No comment provided by engineer. */ -"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenetet és fájlt **végpontok közötti titkosítással** küldi, a közvetlen üzenetekben pedig kvantumrezisztens biztonsággal."; +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek."; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone!" = "Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza!"; +"All messages will be deleted - this cannot be undone!" = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek."; +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek."; /* No comment provided by engineer. */ -"All new messages from %@ will be hidden!" = "Az összes új üzenet elrejtésre kerül tőle: %@!"; +"All new messages from %@ will be hidden!" = "Az összes %@ által írt új üzenet el lesz rejtve!"; /* profile dropdown */ "All profiles" = "Összes profil"; @@ -506,10 +506,10 @@ "All your contacts will remain connected." = "Az összes ismerősével kapcsolatban marad."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ -"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra."; +"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra."; /* No comment provided by engineer. */ "Allow" = "Engedélyezés"; @@ -593,7 +593,7 @@ "Always use private routing." = "Mindig használjon privát útválasztást."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig használjon közvetítő-kiszolgálót"; +"Always use relay" = "Mindig használjon továbbító-kiszolgálót"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; @@ -608,7 +608,7 @@ "Anybody can host servers." = "Bárki üzemeltethet kiszolgálókat."; /* No comment provided by engineer. */ -"App build: %@" = "Az alkalmazás build száma: %@"; +"App build: %@" = "Az alkalmazás összeállítási száma: %@"; /* No comment provided by engineer. */ "App data migration" = "Alkalmazásadatok átköltöztetése"; @@ -623,7 +623,7 @@ "App passcode" = "Alkalmazás jelkód"; /* No comment provided by engineer. */ -"App passcode is replaced with self-destruct passcode." = "Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal."; +"App passcode is replaced with self-destruct passcode." = "Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal."; /* No comment provided by engineer. */ "App session" = "Alkalmazás munkamenete"; @@ -710,13 +710,13 @@ "Background" = "Háttér"; /* No comment provided by engineer. */ -"Bad desktop address" = "Hibás számítógép cím"; +"Bad desktop address" = "Érvénytelen számítógépcím"; /* integrity error chat item */ -"bad message hash" = "hibás az üzenet hasító értéke"; +"bad message hash" = "érvénytelen az üzenet hasító-értéke"; /* No comment provided by engineer. */ -"Bad message hash" = "Hibás az üzenet hasító értéke"; +"Bad message hash" = "Érvénytelen az üzenet hasító-értéke"; /* integrity error chat item */ "bad message ID" = "téves üzenet ID"; @@ -876,19 +876,19 @@ "Cannot receive file" = "Nem lehet fogadni a fájlt"; /* snd error text */ -"Capacity exceeded - recipient did not receive previously sent messages." = "Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket."; +"Capacity exceeded - recipient did not receive previously sent messages." = "Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket."; /* No comment provided by engineer. */ "Cellular" = "Mobilhálózat"; /* No comment provided by engineer. */ -"Change" = "Változtatás"; +"Change" = "Megváltoztatás"; /* authentication reason */ "Change chat profiles" = "Csevegési profilok megváltoztatása"; /* No comment provided by engineer. */ -"Change database passphrase?" = "Adatbázis-jelmondat megváltoztatása?"; +"Change database passphrase?" = "Az adatbázis jelmondatának megváltoztatása?"; /* authentication reason */ "Change lock mode" = "Zárolási mód megváltoztatása"; @@ -900,32 +900,32 @@ "Change passcode" = "Jelkód megváltoztatása"; /* No comment provided by engineer. */ -"Change receiving address" = "A fogadó cím megváltoztatása"; +"Change receiving address" = "A fogadócím megváltoztatása"; /* No comment provided by engineer. */ -"Change receiving address?" = "Megváltoztatja a fogadó címet?"; +"Change receiving address?" = "Megváltoztatja a fogadócímet?"; /* No comment provided by engineer. */ "Change role" = "Szerepkör megváltoztatása"; /* authentication reason */ -"Change self-destruct mode" = "Önmegsemmisítő mód megváltoztatása"; +"Change self-destruct mode" = "Önmegsemmisítő-mód megváltoztatása"; /* authentication reason set passcode view */ -"Change self-destruct passcode" = "Önmegsemmisító jelkód megváltoztatása"; +"Change self-destruct passcode" = "Önmegsemmisítő-jelkód megváltoztatása"; /* chat item text */ -"changed address for you" = "cím megváltoztatva"; +"changed address for you" = "megváltoztatta a címet az Ön számára"; /* rcv group event chat item */ -"changed role of %@ to %@" = "%1$@ szerepkörét megváltoztatta erre: %2$@"; +"changed role of %@ to %@" = "%1$@ szerepkörét megváltoztatta a következőre: %2$@"; /* rcv group event chat item */ -"changed your role to %@" = "megváltoztatta az Ön szerepkörét erre: %@"; +"changed your role to %@" = "megváltoztatta az Ön szerepkörét a következőre: %@"; /* chat item text */ -"changing address for %@…" = "cím megváltoztatása nála: %@…"; +"changing address for %@…" = "cím megváltoztatása %@ számára…"; /* chat item text */ "changing address…" = "cím megváltoztatása…"; @@ -961,10 +961,10 @@ "Chat is running" = "A csevegés fut"; /* No comment provided by engineer. */ -"Chat is stopped" = "A csevegés leállt"; +"Chat is stopped" = "A csevegés megállt"; /* No comment provided by engineer. */ -"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt."; +"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt."; /* No comment provided by engineer. */ "Chat list" = "Csevegőlista"; @@ -985,10 +985,10 @@ "Chat theme" = "Csevegés témája"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni!"; +"Chat will be deleted for all members - this cannot be undone!" = "A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni!"; +"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!"; /* No comment provided by engineer. */ "Chats" = "Csevegések"; @@ -1015,13 +1015,13 @@ "Choose from library" = "Választás a könyvtárból"; /* No comment provided by engineer. */ -"Chunks deleted" = "Törölt fájltöredékek"; +"Chunks deleted" = "Törölt töredékek"; /* No comment provided by engineer. */ -"Chunks downloaded" = "Letöltött fájltöredékek"; +"Chunks downloaded" = "Letöltött töredékek"; /* No comment provided by engineer. */ -"Chunks uploaded" = "Feltöltött fájltöredékek"; +"Chunks uploaded" = "Feltöltött töredékek"; /* swipe action */ "Clear" = "Kiürítés"; @@ -1084,7 +1084,7 @@ "Conditions will be accepted on: %@." = "A feltételek ekkor lesznek elfogadva: %@."; /* No comment provided by engineer. */ -"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltető számára: %@."; +"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %@."; /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-kiszolgálók beállítása"; @@ -1252,7 +1252,7 @@ "Connections" = "Kapcsolatok"; /* profile update event chat item */ -"contact %@ changed to %@" = "%1$@ megváltoztatta a nevét erre: %2$@"; +"contact %@ changed to %@" = "%1$@ megváltoztatta a nevét a következőre: %2$@"; /* No comment provided by engineer. */ "Contact allows" = "Ismerős engedélyezi"; @@ -1285,7 +1285,7 @@ "Contact preferences" = "Ismerős beállításai"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza!"; +"Contact will be deleted - this cannot be undone!" = "Az ismerős törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Contacts" = "Ismerősök"; @@ -1312,7 +1312,7 @@ "Corner" = "Sarok"; /* No comment provided by engineer. */ -"Correct name to %@?" = "Név javítása erre: %@?"; +"Correct name to %@?" = "Név helyesbítése a következőre: %@?"; /* No comment provided by engineer. */ "Create" = "Létrehozás"; @@ -1372,7 +1372,7 @@ "creator" = "készítő"; /* No comment provided by engineer. */ -"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül:"; +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; /* No comment provided by engineer. */ "Current Passcode" = "Jelenlegi jelkód"; @@ -1411,10 +1411,10 @@ "Database encrypted!" = "Adatbázis titkosítva!"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated and stored in the keychain.\n" = "Az adatbázis titkosítási jelmondata frissítve lesz és a kulcstartóban kerül tárolásra.\n"; +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Az adatbázis titkosítási jelmondata frissülni fog és a kulcstartóban lesz tárolva.\n"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated.\n" = "Az datbázis titkosítási jelmondata frissítve lesz.\n"; +"Database encryption passphrase will be updated.\n" = "Az datbázis titkosítási jelmondata frissülni fog.\n"; /* No comment provided by engineer. */ "Database error" = "Adatbázishiba"; @@ -1441,7 +1441,7 @@ "Database passphrase & export" = "Adatbázis-jelmondat és -exportálás"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban mentettől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; /* No comment provided by engineer. */ "Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; @@ -1450,16 +1450,16 @@ "Database upgrade" = "Adatbázis fejlesztése"; /* No comment provided by engineer. */ -"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés ehhez: %@"; +"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés a következőhöz: %@"; /* No comment provided by engineer. */ -"Database will be encrypted and the passphrase stored in the keychain.\n" = "Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban kerül tárolásra.\n"; +"Database will be encrypted and the passphrase stored in the keychain.\n" = "Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban lesz tárolva.\n"; /* No comment provided by engineer. */ -"Database will be encrypted.\n" = "Az adatbázis titkosításra kerül.\n"; +"Database will be encrypted.\n" = "Az adatbázis titkosítva lesz.\n"; /* No comment provided by engineer. */ -"Database will be migrated when the app restarts" = "Az adatbázis az alkalmazás újraindításakor átköltöztetésre kerül"; +"Database will be migrated when the app restarts" = "Az adatbázis az alkalmazás újraindításakor lesz átköltöztetve"; /* time unit */ "days" = "nap"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "visszafejtési hibák"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "alapértelmezett (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "alapértelmezett (igen)"; /* alert action - chat item action swipe action */ "Delete" = "Törlés"; @@ -1503,7 +1503,7 @@ "Delete address?" = "Cím törlése?"; /* No comment provided by engineer. */ -"Delete after" = "Törlés ennyi idő után"; +"Delete after" = "Törlés ennyi idő után:"; /* No comment provided by engineer. */ "Delete all files" = "Az összes fájl törlése"; @@ -1574,11 +1574,11 @@ /* No comment provided by engineer. */ "Delete message?" = "Üzenet törlése?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Üzenetek törlése"; /* No comment provided by engineer. */ -"Delete messages after" = "Üzenetek törlése ennyi idő után"; +"Delete messages after" = "Üzenetek törlése ennyi idő után:"; /* No comment provided by engineer. */ "Delete old database" = "Régi adatbázis törlése"; @@ -1752,16 +1752,16 @@ "Do it later" = "Befejezés később"; /* No comment provided by engineer. */ -"Do not send history to new members." = "Az előzmények ne kerüljenek elküldésre az új tagok számára."; +"Do not send history to new members." = "Az előzmények ne legyenek elküldve az új tagok számára."; /* No comment provided by engineer. */ -"Do NOT send messages directly, even if your or destination server does not support private routing." = "Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Do NOT send messages directly, even if your or destination server does not support private routing." = "NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; /* No comment provided by engineer. */ "Do not use credentials with proxy." = "Ne használja a hitelesítőadatokat proxyval."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "Ne használjon privát útválasztást."; +"Do NOT use private routing." = "NE használjon privát útválasztást."; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NE használja a SimpleX-et segélyhívásokhoz."; @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Engedélyezés (felülírások megtartásával)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatikus üzenet törlés engedélyezése?"; /* No comment provided by engineer. */ @@ -1867,7 +1867,7 @@ "Enable self-destruct" = "Önmegsemmisítés engedélyezése"; /* set passcode view */ -"Enable self-destruct passcode" = "Önmegsemmisítő jelkód engedélyezése"; +"Enable self-destruct passcode" = "Önmegsemmisítő-jelkód engedélyezése"; /* authentication reason */ "Enable SimpleX Lock" = "SimpleX-zár bekapcsolása"; @@ -1909,7 +1909,7 @@ "Encrypted message or another event" = "Titkosított üzenet vagy más esemény"; /* notification */ -"Encrypted message: app is stopped" = "Titkosított üzenet: az alkalmazás leállt"; +"Encrypted message: app is stopped" = "Titkosított üzenet: az alkalmazás megállt"; /* notification */ "Encrypted message: database error" = "Titkosított üzenet: adatbázishiba"; @@ -1936,25 +1936,25 @@ "encryption ok" = "titkosítás rendben"; /* chat item text */ -"encryption ok for %@" = "titkosítás rendben vele: %@"; +"encryption ok for %@" = "titkosítás rendben van %@ számára"; /* chat item text */ -"encryption re-negotiation allowed" = "titkosítás újraegyeztetés engedélyezve"; +"encryption re-negotiation allowed" = "titkosítás-újraegyeztetés engedélyezve"; /* chat item text */ -"encryption re-negotiation allowed for %@" = "titkosítás újraegyeztetés engedélyezve vele: %@"; +"encryption re-negotiation allowed for %@" = "a titkosítás újraegyeztetése engedélyezve van %@ számára"; /* message decrypt error item */ -"Encryption re-negotiation error" = "Titkosítás újraegyeztetési hiba"; +"Encryption re-negotiation error" = "Titkosítás-újraegyeztetési hiba"; /* No comment provided by engineer. */ "Encryption re-negotiation failed." = "Sikertelen titkosítás-újraegyeztetés."; /* chat item text */ -"encryption re-negotiation required" = "titkosítás újraegyeztetés szükséges"; +"encryption re-negotiation required" = "titkosítás-újraegyeztetés szükséges"; /* chat item text */ -"encryption re-negotiation required for %@" = "titkosítás újraegyeztetés szükséges %@ számára"; +"encryption re-negotiation required for %@" = "a titkosítás újraegyeztetése szükséges %@ számára"; /* No comment provided by engineer. */ "ended" = "befejeződött"; @@ -1966,31 +1966,31 @@ "Enter correct passphrase." = "Adja meg a helyes jelmondatot."; /* No comment provided by engineer. */ -"Enter group name…" = "Csoportnév megadása…"; +"Enter group name…" = "Adja meg a csoport nevét…"; /* No comment provided by engineer. */ -"Enter Passcode" = "Jelkód megadása"; +"Enter Passcode" = "Adja meg a jelkódot"; /* No comment provided by engineer. */ -"Enter passphrase" = "Jelmondat megadása"; +"Enter passphrase" = "Adja meg a jelmondatot"; /* No comment provided by engineer. */ -"Enter passphrase…" = "Jelmondat megadása…"; +"Enter passphrase…" = "Adja meg a jelmondatot…"; /* No comment provided by engineer. */ -"Enter password above to show!" = "Jelszó megadása a megjelenítéshez!"; +"Enter password above to show!" = "Adja meg a jelszót fentebb a megjelenítéshez!"; /* No comment provided by engineer. */ -"Enter server manually" = "Kiszolgáló megadása kézzel"; +"Enter server manually" = "Adja meg a kiszolgálót kézzel"; /* No comment provided by engineer. */ "Enter this device name…" = "Adja meg ennek az eszköznek a nevét…"; /* placeholder */ -"Enter welcome message…" = "Üdvözlőüzenet megadása…"; +"Enter welcome message…" = "Adja meg az üdvözlőüzenetet…"; /* placeholder */ -"Enter welcome message… (optional)" = "Üdvözlőüzenet megadása… (nem kötelező)"; +"Enter welcome message… (optional)" = "Adja meg az üdvözlőüzenetet… (nem kötelező)"; /* No comment provided by engineer. */ "Enter your name…" = "Adjon meg egy nevet…"; @@ -2259,14 +2259,14 @@ /* swipe action */ "Favorite" = "Kedvenc"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Fájlhiba"; /* alert message */ "File errors:\n%@" = "Fájlhiba:\n%@"; /* file error text */ -"File not found - most likely file was deleted or cancelled." = "A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták."; +"File not found - most likely file was deleted or cancelled." = "A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták."; /* file error text */ "File server error: %@" = "Fájlkiszolgáló hiba: %@"; @@ -2416,10 +2416,10 @@ "Full name (optional)" = "Teljes név (nem kötelező)"; /* No comment provided by engineer. */ -"Fully decentralized – visible only to members." = "Teljesen decentralizált - csak a tagok számára látható."; +"Fully decentralized – visible only to members." = "Teljesen decentralizált – csak a tagok számára látható."; /* No comment provided by engineer. */ -"Fully re-implemented - work in background!" = "Teljesen újra implementálva - háttérben történő működés!"; +"Fully re-implemented - work in background!" = "Teljesen újra implementálva – háttérben történő működés!"; /* No comment provided by engineer. */ "Further reduced battery usage" = "Tovább csökkentett akkumulátor-használat"; @@ -2449,7 +2449,7 @@ "Group display name" = "A csoport megjelenített neve"; /* No comment provided by engineer. */ -"Group full name (optional)" = "Csoport teljes neve (nem kötelező)"; +"Group full name (optional)" = "A csoport teljes neve (nem kötelező)"; /* No comment provided by engineer. */ "Group image" = "Csoport profilképe"; @@ -2491,10 +2491,10 @@ "Group welcome message" = "A csoport üdvözlőüzenete"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza!"; +"Group will be deleted for all members - this cannot be undone!" = "A csoport törölve lesz az összes tag számára – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Group will be deleted for you - this cannot be undone!" = "A csoport törlésre kerül az Ön számára - ez a művelet nem vonható vissza!"; +"Group will be deleted for you - this cannot be undone!" = "A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Help" = "Súgó"; @@ -2524,7 +2524,7 @@ "History" = "Előzmények"; /* No comment provided by engineer. */ -"History is not sent to new members." = "Az előzmények nem kerülnek elküldésre az új tagok számára."; +"History is not sent to new members." = "Az előzmények nem lesznek elküldve az új tagok számára."; /* time unit */ "hours" = "óra"; @@ -2557,13 +2557,13 @@ "If you can't meet in person, show QR code in a video call, or share the link." = "Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást."; /* No comment provided by engineer. */ -"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül!"; +"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva!"; /* No comment provided by engineer. */ -"If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot:"; +"If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot:"; /* No comment provided by engineer. */ -"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor felajánlásra kerül az adatbázis átköltöztetése)."; +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; /* No comment provided by engineer. */ "Ignore" = "Mellőzés"; @@ -2680,7 +2680,7 @@ "Instant" = "Azonnal"; /* No comment provided by engineer. */ -"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések elrejtésre kerülnek!\n"; +"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések el lesznek rejtve!\n"; /* No comment provided by engineer. */ "Interface" = "Kezelőfelület"; @@ -2758,10 +2758,10 @@ "invited via your group link" = "meghíva az Ön csoporthivatkozásán keresztül"; /* No comment provided by engineer. */ -"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó az alkalmazás újraindítása, vagy a jelmondat módosítása után a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi a push-értesítések fogadását."; /* No comment provided by engineer. */ "IP address" = "IP-cím"; @@ -2950,7 +2950,7 @@ "Member" = "Tag"; /* profile update event chat item */ -"member %@ changed to %@" = "%1$@ megváltoztatta a nevét erre: %2$@"; +"member %@ changed to %@" = "%1$@ megváltoztatta a nevét a következőre: %2$@"; /* rcv group event chat item */ "member connected" = "kapcsolódott"; @@ -2962,16 +2962,16 @@ "Member role will be changed to \"%@\". All chat members will be notified." = "A tag szerepeköre meg fog változni a következőre: \"%@\". A csevegés tagjai értesítést fognak kapni."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre meg fog változni erre: „%@”. A csoportban az összes tag értesítve lesz."; +"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre meg fog változni a következőre: „%@”. A csoportban az összes tag értesítve lesz."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre meg fog változni erre: „%@”. A tag új meghívást fog kapni."; +"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre meg fog változni a következőre: „%@”. A tag új meghívást fog kapni."; /* No comment provided by engineer. */ -"Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni!"; +"Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Member will be removed from group - this cannot be undone!" = "A tag eltávolítása a csoportból - ez a művelet nem vonható vissza!"; +"Member will be removed from group - this cannot be undone!" = "A tag eltávolítása a csoportból – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Members can add message reactions." = "Csoporttagok üzenetreakciókat adhatnak hozzá."; @@ -3073,13 +3073,13 @@ "Messages sent" = "Elküldött üzenetek"; /* alert message */ -"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kiválasztotta őket."; +"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 üzeneteket, fájlokat és hívásokat **végpontok közötti titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi."; /* 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 üzeneteket, fájlokat és hívásokat **végpontok közötti kvantumrezisztens titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi."; +"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, fájlok és hívások **végpontok közötti kvantumálló titkosítással** sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve."; /* No comment provided by engineer. */ "Migrate device" = "Eszköz átköltöztetése"; @@ -3151,7 +3151,7 @@ "More reliable notifications" = "Megbízhatóbb értesítések"; /* item status description */ -"Most likely this connection is deleted." = "Valószínűleg ez a kapcsolat törlésre került."; +"Most likely this connection is deleted." = "Valószínűleg ez a kapcsolat törölve lett."; /* No comment provided by engineer. */ "Multiple chat profiles" = "Több csevegőprofil"; @@ -3178,7 +3178,7 @@ "Network decentralization" = "Hálózati decentralizáció"; /* snd error text */ -"Network issues - message expired after many attempts to send it." = "Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt."; +"Network issues - message expired after many attempts to send it." = "Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt."; /* No comment provided by engineer. */ "Network management" = "Hálózatkezelés"; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Hálózat állapota"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "soha"; /* No comment provided by engineer. */ @@ -3256,7 +3256,7 @@ "No app password" = "Nincs alkalmazás jelszó"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs kiválasztva ismerős"; +"No contacts selected" = "Nincs kijelölve ismerős"; /* No comment provided by engineer. */ "No contacts to add" = "Nincs hozzáadandó ismerős"; @@ -3331,7 +3331,7 @@ "Not compatible!" = "Nem kompatibilis!"; /* No comment provided by engineer. */ -"Nothing selected" = "Nincs kiválasztva semmi"; +"Nothing selected" = "Nincs kijelölve semmi"; /* alert title */ "Nothing to forward!" = "Nincs mit továbbítani!"; @@ -3390,16 +3390,16 @@ "Onion hosts will not be used." = "Onion-kiszolgálók nem lesznek használva."; /* No comment provided by engineer. */ -"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a beállításokat."; +"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai változtathatják meg a beállításokat."; /* No comment provided by engineer. */ -"Only client devices store user profiles, contacts, groups, and messages." = "Csak az eszközök alkalmazásai tárolják a felhasználó-profilokat, névjegyeket, csoportokat és a **2 rétegű végpontok közötti titkosítással** küldött üzeneteket."; +"Only client devices store user profiles, contacts, groups, and messages." = "A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül."; /* No comment provided by engineer. */ "Only delete conversation" = "Csak a beszélgetés törlése"; /* No comment provided by engineer. */ -"Only group owners can change group preferences." = "Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat."; +"Only group owners can change group preferences." = "Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat."; /* No comment provided by engineer. */ "Only group owners can enable files and media." = "Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését."; @@ -3456,7 +3456,7 @@ "Open group" = "Csoport megnyitása"; /* authentication reason */ -"Open migration to another device" = "Átköltöztetés megkezdése egy másik eszközre"; +"Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; /* No comment provided by engineer. */ "Open Settings" = "Beállítások megnyitása"; @@ -3516,7 +3516,7 @@ "Passcode entry" = "Jelkód bevitele"; /* No comment provided by engineer. */ -"Passcode not changed!" = "A jelkód nem változott!"; +"Passcode not changed!" = "A jelkód nem változott meg!"; /* No comment provided by engineer. */ "Passcode set!" = "A jelkód beállítva!"; @@ -3594,13 +3594,13 @@ "Please contact group admin." = "Lépjen kapcsolatba a csoport adminisztrátorával."; /* No comment provided by engineer. */ -"Please enter correct current passphrase." = "Adja meg a helyes, jelenlegi jelmondatát."; +"Please enter correct current passphrase." = "Adja meg a helyes, jelenlegi jelmondatot."; /* No comment provided by engineer. */ -"Please enter the previous password after restoring database backup. This action can not be undone." = "Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza."; +"Please enter the previous password after restoring database backup. This action can not be undone." = "Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza."; /* No comment provided by engineer. */ -"Please remember or store it securely - there is no way to recover a lost passcode!" = "Jegyezze fel vagy tárolja el biztonságosan - az elveszett jelkódot nem lehet visszaállítani!"; +"Please remember or store it securely - there is no way to recover a lost passcode!" = "Jegyezze fel vagy tárolja el biztonságosan – az elveszett jelkódot nem lehet visszaállítani!"; /* No comment provided by engineer. */ "Please report it to the developers." = "Jelentse a fejlesztőknek."; @@ -3681,7 +3681,7 @@ "Profile theme" = "Profiltéma"; /* alert message */ -"Profile update will be sent to your contacts." = "A profilfrissítés elküldésre került az ismerősök számára."; +"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "A hívások kezdeményezése le van tiltva."; @@ -3720,7 +3720,7 @@ "Protect your chat profiles with a password!" = "Védje meg a csevegési profiljait egy jelszóval!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; /* No comment provided by engineer. */ "Protocol timeout" = "Protokoll időtúllépése"; @@ -3744,10 +3744,10 @@ "Push server" = "Push-kiszolgáló"; /* chat item text */ -"quantum resistant e2e encryption" = "végpontok közötti kvantumrezisztens titkosítás"; +"quantum resistant e2e encryption" = "végpontok közötti kvantumálló titkosítás"; /* No comment provided by engineer. */ -"Quantum resistant encryption" = "Kvantumrezisztens titkosítás"; +"Quantum resistant encryption" = "Kvantumálló titkosítás"; /* No comment provided by engineer. */ "Rate the app" = "Értékelje az alkalmazást"; @@ -3810,7 +3810,7 @@ "Received total" = "Összes fogadott üzenet"; /* No comment provided by engineer. */ -"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "A fogadó cím egy másik kiszolgálóra változik. A címváltoztatás a feladó online állapotba kerülése után fejeződik be."; +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "A fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be."; /* No comment provided by engineer. */ "Receiving file will be stopped." = "A fájl fogadása le fog állni."; @@ -3871,10 +3871,10 @@ "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 közvetítő-kiszolgáló csak szükség esetén kerül használatra. 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ímet."; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "A közvetítő-kiszolgáló megvédi az IP-címet, 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ímet, de megfigyelheti a hívás időtartamát."; /* No comment provided by engineer. */ "Remove" = "Eltávolítás"; @@ -4079,7 +4079,7 @@ "Saved message" = "Mentett üzenet"; /* No comment provided by engineer. */ -"Saved WebRTC ICE servers will be removed" = "A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek"; +"Saved WebRTC ICE servers will be removed" = "A mentett WebRTC ICE-kiszolgálók el lesznek távolítva"; /* No comment provided by engineer. */ "Saving %lld messages" = "%lld üzenet mentése"; @@ -4145,34 +4145,34 @@ "security code changed" = "a biztonsági kód megváltozott"; /* chat item action */ -"Select" = "Kiválasztás"; +"Select" = "Kijelölés"; /* No comment provided by engineer. */ -"Select chat profile" = "Csevegési profil kiválasztása"; +"Select chat profile" = "Csevegési profil kijelölése"; /* No comment provided by engineer. */ -"Selected %lld" = "%lld kiválasztva"; +"Selected %lld" = "%lld kijelölve"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; /* No comment provided by engineer. */ -"Self-destruct passcode" = "Önmegsemmisítési jelkód"; +"Self-destruct passcode" = "Önmegsemmisítő-jelkód"; /* No comment provided by engineer. */ -"Self-destruct passcode changed!" = "Az önmegsemmisítési jelkód megváltozott!"; +"Self-destruct passcode changed!" = "Az önmegsemmisítő-jelkód megváltozott!"; /* No comment provided by engineer. */ -"Self-destruct passcode enabled!" = "Az önmegsemmisítési jelkód engedélyezve!"; +"Self-destruct passcode enabled!" = "Az önmegsemmisítő-jelkód engedélyezve!"; /* No comment provided by engineer. */ "Send" = "Küldés"; /* No comment provided by engineer. */ -"Send a live message - it will update for the recipient(s) as you type it" = "Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja"; +"Send a live message - it will update for the recipient(s) as you type it" = "Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet"; /* No comment provided by engineer. */ "Send delivery receipts to" = "A kézbesítési jelentéseket a következő címre kell küldeni"; @@ -4226,10 +4226,10 @@ "Sender may have deleted the connection request." = "A küldő törölhette a kapcsolatkérést."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára."; +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül."; +"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz."; /* No comment provided by engineer. */ "Sending file will be stopped." = "A fájl küldése le fog állni."; @@ -4247,7 +4247,7 @@ "Sending receipts is enabled for %lld groups" = "A kézbesítési jelentések engedélyezve vannak %lld csoportban"; /* No comment provided by engineer. */ -"Sending via" = "Küldés ezen keresztül"; +"Sending via" = "Küldés a következőn keresztül:"; /* No comment provided by engineer. */ "Sent at" = "Elküldve ekkor:"; @@ -4268,7 +4268,7 @@ "Sent messages" = "Elküldött üzenetek"; /* No comment provided by engineer. */ -"Sent messages will be deleted after set time." = "Az elküldött üzenetek törlésre kerülnek a beállított idő után."; +"Sent messages will be deleted after set time." = "Az elküldött üzenetek törölve lesznek a beállított idő után."; /* No comment provided by engineer. */ "Sent reply" = "Válaszüzenet-buborék színe"; @@ -4331,7 +4331,7 @@ "Servers info" = "Információk a kiszolgálókról"; /* No comment provided by engineer. */ -"Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza!"; +"Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Session code" = "Munkamenet kód"; @@ -4379,7 +4379,7 @@ "Settings were changed." = "A beállítások megváltoztak."; /* No comment provided by engineer. */ -"Shape profile images" = "Profilkép alakzat"; +"Shape profile images" = "Profilkép alakzata"; /* alert action chat item action */ @@ -4431,7 +4431,7 @@ "Show developer options" = "Fejlesztői beállítások megjelenítése"; /* No comment provided by engineer. */ -"Show last messages" = "Szobák utolsó üzeneteinek megjelenítése a listanézetben"; +"Show last messages" = "Legutóbbi üzenet előnézetének megjelenítése"; /* No comment provided by engineer. */ "Show message status" = "Üzenetállapot megjelenítése"; @@ -4524,7 +4524,7 @@ "SMP server" = "SMP-kiszolgáló"; /* No comment provided by engineer. */ -"SOCKS proxy" = "SOCKS proxy"; +"SOCKS proxy" = "SOCKS-proxy"; /* blur media */ "Soft" = "Enyhe"; @@ -4533,7 +4533,7 @@ "Some app settings were not migrated." = "Egyes alkalmazásbeállítások nem lettek átköltöztetve."; /* No comment provided by engineer. */ -"Some file(s) were not exported:" = "Néhány fájl nem került exportálásra:"; +"Some file(s) were not exported:" = "Néhány fájl nem lett exportálva:"; /* No comment provided by engineer. */ "Some non-fatal errors occurred during import - you may see Chat console for more details." = "Néhány nem végzetes hiba történt az importáláskor – további részleteket a csevegési konzolban olvashat."; @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Ideiglenesfájl-hiba"; /* server test failure */ @@ -4704,7 +4704,7 @@ "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Köszönet a felhasználóknak – [hozzájárulás a Weblate-en keresztül](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; /* No comment provided by engineer. */ -"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak - hozzájárulás a Weblate-en!"; +"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak – hozzájárulás a Weblate-en!"; /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – beállítások megnyitása az engedélyezéshez."; @@ -4743,25 +4743,25 @@ "The hash of the previous message is different." = "Az előző üzenet hasító értéke különbözik."; /* 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." = "A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő."; +"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." = "A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő."; /* No comment provided by engineer. */ -"The message will be deleted for all members." = "Az üzenet az összes tag számára törlésre kerül."; +"The message will be deleted for all members." = "Az üzenet az összes tag számára törölve lesz."; /* No comment provided by engineer. */ "The message will be marked as moderated for all members." = "Az üzenet az összes tag számára moderáltként lesz megjelölve."; /* No comment provided by engineer. */ -"The messages will be deleted for all members." = "Az üzenetek az összes tag számára törlésre kerülnek."; +"The messages will be deleted for all members." = "Az üzenetek az összes tag számára törölve lesznek."; /* No comment provided by engineer. */ "The messages will be marked as moderated for all members." = "Az üzenetek az összes tag számára moderáltként lesznek megjelölve."; /* No comment provided by engineer. */ -"The old database was not removed during the migration, it can be deleted." = "A régi adatbázis nem került eltávolításra az átköltöztetéskor, így törölhető."; +"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. */ -"The profile is only shared with your contacts." = "A profilja csak az ismerőseivel kerül megosztásra."; +"The profile is only shared with your contacts." = "A profilja csak az ismerőseivel van megosztva."; /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**."; @@ -4779,16 +4779,16 @@ "The sender will NOT be notified" = "A feladó NEM fog értesítést kapni"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói **%@**."; +"The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegő-profiljához tartozó új kapcsolatok kiszolgálói."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Az Ön jelenlegi **%@** nevű csevegőprofiljához tartozó új fájlok kiszolgálói."; +"The servers for new files of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegő-profiljához tartozó új fájlok kiszolgálói."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "A beillesztett szöveg nem egy SimpleX-hivatkozás."; /* No comment provided by engineer. */ -"The uploaded database archive will be permanently removed from the servers." = "A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról."; +"The uploaded database archive will be permanently removed from the servers." = "A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról."; /* No comment provided by engineer. */ "Themes" = "Témák"; @@ -4797,25 +4797,25 @@ "These conditions will also apply for: **%@**." = "Ezek a feltételek lesznek elfogadva a következő számára is: **%@**."; /* No comment provided by engineer. */ -"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi (**%@**) profiljára vonatkoznak."; +"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi **%@** nevű csevegő-profiljára vonatkoznak."; /* No comment provided by engineer. */ "They can be overridden in contact and group settings." = "Ezek felülbírálhatók az ismerős- és csoportbeállításokban."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerül. Az alacsony felbontású képek viszont megmaradnak."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai visszafordíthatatlanul törlésre kerülnek."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek."; /* E2EE info chat item */ "This chat is protected by end-to-end encryption." = "Ez a csevegés végpontok közötti titkosítással védett."; /* E2EE info chat item */ -"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett."; +"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumálló tikosítással védett."; /* notification title */ "this contact" = "ez az ismerős"; @@ -4824,10 +4824,10 @@ "This device name" = "Ennek az eszköznek a neve"; /* No comment provided by engineer. */ -"This display name is invalid. Please choose another name." = "Ez a megjelenített név érvénytelen. Válasszon egy másik nevet."; +"This display name is invalid. Please choose another name." = "Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet."; /* No comment provided by engineer. */ -"This group has over %lld members, delivery receipts are not sent." = "Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem kerülnek elküldésre."; +"This group has over %lld members, delivery receipts are not sent." = "Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem lesznek elküldve."; /* No comment provided by engineer. */ "This group no longer exists." = "Ez a csoport már nem létezik."; @@ -4842,7 +4842,7 @@ "This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén."; /* No comment provided by engineer. */ -"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak a jelenlegi (**%@**) profiljában lévő üzenetekre vonatkozik."; +"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegő-profiljában lévő üzenetekre vonatkozik."; /* No comment provided by engineer. */ "Title" = "Cím"; @@ -4887,7 +4887,7 @@ "To record voice message please grant permission to use Microphone." = "Hangüzenet rögzítéséhez adjon engedélyt a mikrofon használathoz."; /* No comment provided by engineer. */ -"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja megjelenítéséhez írja be a teljes jelszavát a keresőmezőbe a **Csevegési profilok** menüben."; +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja megjelenítéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben."; /* No comment provided by engineer. */ "To send" = "A küldéshez"; @@ -4905,7 +4905,7 @@ "Toggle chat list:" = "Csevegőlista átváltása:"; /* No comment provided by engineer. */ -"Toggle incognito when connecting." = "Inkognitómód használata kapcsolódáskor."; +"Toggle incognito when connecting." = "Inkognitóra váltás kapcsolódáskor."; /* No comment provided by engineer. */ "Toolbar opacity" = "Eszköztár átlátszatlansága"; @@ -5028,13 +5028,13 @@ "Unread" = "Olvasatlan"; /* No comment provided by engineer. */ -"Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára."; +"Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára."; /* No comment provided by engineer. */ "Update" = "Frissítés"; /* No comment provided by engineer. */ -"Update database passphrase" = "Adatbázis-jelmondat megváltoztatása"; +"Update database passphrase" = "Az adatbázis jelmondatának megváltoztatása"; /* No comment provided by engineer. */ "Update network settings?" = "Hálózati beállítások megváltoztatása?"; @@ -5121,7 +5121,7 @@ "Use SimpleX Chat servers?" = "SimpleX Chat-kiszolgálók használata?"; /* No comment provided by engineer. */ -"Use SOCKS proxy" = "SOCKS proxy használata"; +"Use SOCKS proxy" = "SOCKS-proxy használata"; /* No comment provided by engineer. */ "Use the app while in the call." = "Használja az alkalmazást hívás közben."; @@ -5130,7 +5130,7 @@ "Use the app with one hand." = "Használja az alkalmazást egy kézzel."; /* No comment provided by engineer. */ -"User selection" = "Felhasználó kiválasztása"; +"User selection" = "Felhasználó kijelölése"; /* No comment provided by engineer. */ "Username" = "Felhasználónév"; @@ -5169,19 +5169,19 @@ "Via browser" = "Böngészőn keresztül"; /* chat list item description */ -"via contact address link" = "kapcsolattartási cím-hivatkozáson keresztül"; +"via contact address link" = "a kapcsolattartási cím-hivatkozáson keresztül"; /* chat list item description */ "via group link" = "a csoporthivatkozáson keresztül"; /* chat list item description */ -"via one-time link" = "egyszer használható meghívó-hivatkozáson keresztül"; +"via one-time link" = "egy egyszer használható meghívó-hivatkozáson keresztül"; /* No comment provided by engineer. */ -"via relay" = "közvetítő-kiszolgálón keresztül"; +"via relay" = "egy továbbító-kiszolgálón keresztül"; /* No comment provided by engineer. */ -"Via secure quantum resistant protocol." = "Biztonságos kvantumrezisztens-protokollon keresztül."; +"Via secure quantum resistant protocol." = "Biztonságos kvantumálló-protokollon keresztül."; /* No comment provided by engineer. */ "video" = "videó"; @@ -5250,7 +5250,7 @@ "Waiting for video" = "Várakozás a videóra"; /* No comment provided by engineer. */ -"Wallpaper accent" = "Háttérkép kiemelés"; +"Wallpaper accent" = "Háttérkép kiemelőszíne"; /* No comment provided by engineer. */ "Wallpaper background" = "Háttérkép háttérszíne"; @@ -5319,19 +5319,19 @@ "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ó lesz 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 IP-címe látható lesz a következő XFTP-közvetí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ó lesz a következő XFTP-továbbító-kiszolgálók számára: %@."; /* No comment provided by engineer. */ -"Wrong database passphrase" = "Hibás adatbázis-jelmondat"; +"Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* snd error text */ -"Wrong key or unknown connection - most likely this connection is deleted." = "Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött."; +"Wrong key or unknown connection - most likely this connection is deleted." = "Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött."; /* file error text */ -"Wrong key or unknown file chunk address - most likely file is deleted." = "Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött."; +"Wrong key or unknown file chunk address - most likely file is deleted." = "Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött."; /* No comment provided by engineer. */ -"Wrong passphrase!" = "Hibás jelmondat!"; +"Wrong passphrase!" = "Érvénytelen jelmondat!"; /* No comment provided by engineer. */ "XFTP server" = "XFTP-kiszolgáló"; @@ -5355,13 +5355,13 @@ "You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet."; /* No comment provided by engineer. */ -"You are already connected to %@." = "Ön már kapcsolódva van ehhez: %@."; +"You are already connected to %@." = "Ön már kapcsolódva van a következőhöz: %@."; /* No comment provided by engineer. */ "You are already connected with %@." = "Ön már kapcsolódva van vele: %@."; /* No comment provided by engineer. */ -"You are already connecting to %@." = "Már folyamatban van a kapcsolódás ehhez: %@."; +"You are already connecting to %@." = "Már folyamatban van a kapcsolódás a következőhöz: %@."; /* No comment provided by engineer. */ "You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül!"; @@ -5403,7 +5403,7 @@ "You can accept calls from lock screen, without device and app authentication." = "Hívásokat fogadhat a lezárási képernyőről, eszköz- és alkalmazás-hitelesítés nélkül."; /* No comment provided by engineer. */ -"You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben módosíthatja."; +"You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben változtathatja meg."; /* No comment provided by engineer. */ "You can configure operators in Network & servers settings." = "Az üzemeltetőket a „Hálózat és kiszolgálók” beállításaban konfigurálhatja."; @@ -5424,7 +5424,7 @@ "You can give another try." = "Megpróbálhatja még egyszer."; /* No comment provided by engineer. */ -"You can hide or mute a user profile - swipe it to the right." = "Elrejtheti vagy lenémíthatja a felhasználó -profiljait - csúsztassa jobbra a profilt."; +"You can hide or mute a user profile - swipe it to the right." = "Elrejtheti vagy lenémíthatja a felhasználó -profiljait – csúsztassa jobbra a profilt."; /* No comment provided by engineer. */ "You can make it visible to your SimpleX contacts via Settings." = "Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”."; @@ -5442,7 +5442,7 @@ "You can set lock screen notification preview via settings." = "A lezárási képernyő értesítési előnézetét az „Értesítések” menüben állíthatja be."; /* 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." = "Megoszthat egy hivatkozást vagy QR-kódot - így bárki csatlakozhat a csoporthoz. Ha a csoport később törlésre kerül, akkor nem fogja elveszíteni annak tagjait."; +"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." = "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."; /* No comment provided by engineer. */ "You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a címet az ismerőseivel, hogy kapcsolatba léphessenek Önnel a(z) **%@** nevű profilján keresztül."; @@ -5466,16 +5466,16 @@ "You can't send messages!" = "Nem lehet üzeneteket küldeni!"; /* chat item text */ -"you changed address" = "cím megváltoztatva"; +"you changed address" = "Ön megváltoztatta a címet"; /* chat item text */ -"you changed address for %@" = "cím megváltoztatva nála: %@"; +"you changed address for %@" = "Ön megváltoztatta a címet %@ számára"; /* snd group event chat item */ -"you changed role for yourself to %@" = "saját szerepköre megváltozott erre: %@"; +"you changed role for yourself to %@" = "Ön megváltoztatta a saját szerepkörét a következőre: %@"; /* snd group event chat item */ -"you changed role of %@ to %@" = "Ön megváltoztatta %1$@ szerepkörét erre: %@"; +"you changed role of %@ to %@" = "Ön megváltoztatta %1$@ szerepkörét a következőre: %@"; /* No comment provided by engineer. */ "You could not be verified; please try again." = "Nem sikerült hitelesíteni; próbálja meg újra."; @@ -5490,16 +5490,16 @@ "You have already requested connection!\nRepeat connection request?" = "Már küldött egy kapcsolódási kérelmet!\nKapcsolatkérés megismétlése?"; /* No comment provided by engineer. */ -"You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra."; +"You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva."; /* No comment provided by engineer. */ "You invited a contact" = "Meghívta egy ismerősét"; /* No comment provided by engineer. */ -"You joined this group" = "Csatlakozott ehhez a csoporthoz"; +"You joined this group" = "Ön csatlakozott ehhez a csoporthoz"; /* No comment provided by engineer. */ -"You joined this group. Connecting to inviting group member." = "Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz."; +"You joined this group. Connecting to inviting group member." = "Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz."; /* snd group event chat item */ "you left" = "Ön elhagyta a csoportot"; @@ -5544,7 +5544,7 @@ "You will be connected when group link host's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a csoporthivatkozás tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később!"; +"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ "You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később!"; @@ -5574,7 +5574,7 @@ "You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban"; /* No comment provided by engineer. */ -"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "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"; +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "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"; /* No comment provided by engineer. */ "Your calls" = "Hívások"; @@ -5607,7 +5607,7 @@ "Your credentials may be sent unencrypted." = "A hitelesítőadatai titkosítatlanul is elküldhetők."; /* No comment provided by engineer. */ -"Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRLŐDNI FOG, és a HELYÉRE az importált adatbázis kerül."; +"Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra."; /* No comment provided by engineer. */ "Your current profile" = "Jelenlegi profil"; @@ -5625,16 +5625,16 @@ "Your profile" = "Profil"; /* No comment provided by engineer. */ -"Your profile **%@** will be shared." = "A(z) **%@** nevű profilja megosztásra fog kerülni."; +"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 shared only with your contacts. SimpleX servers cannot see your profile." = "A profilja az eszközön van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját."; +"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 az ismerőseivel 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 megváltozott. Ha elmenti, a frissített profil elküldésre kerül az összes ismerősének."; +"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "A profilja megváltozott. Ha elmenti, a profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "A profilja, az ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra."; +"Your profile, contacts and delivered messages are stored on your device." = "A profilja, az ismerősei és az elküldött üzenetei az Ön 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 7c3a7e05de..de0b761e4e 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nuove lingue dell'interfaccia"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld secondo/i"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld secondi"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 giorno"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuto"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 mese"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 settimana"; /* No comment provided by engineer. */ @@ -343,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Interrompere il cambio di indirizzo?"; +/* No comment provided by engineer. */ +"About operators" = "Info sugli operatori"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "Riguardo SimpleX Chat"; @@ -376,6 +379,9 @@ /* No comment provided by engineer. */ "Accepted conditions" = "Condizioni accettate"; +/* chat list item title */ +"accepted invitation" = "invito accettato"; + /* No comment provided by engineer. */ "Acknowledged" = "Riconosciuto"; @@ -1470,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "errori di decifrazione"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "predefinito (%@)"; /* No comment provided by engineer. */ @@ -1480,7 +1487,6 @@ "default (yes)" = "predefinito (sì)"; /* alert action - chat item action swipe action */ "Delete" = "Elimina"; @@ -1568,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Eliminare il messaggio?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Elimina messaggi"; /* No comment provided by engineer. */ @@ -1830,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Attiva (mantieni sostituzioni)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Attivare l'eliminazione automatica dei messaggi?"; /* No comment provided by engineer. */ @@ -2253,7 +2259,7 @@ /* swipe action */ "Favorite" = "Preferito"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Errore del file"; /* alert message */ @@ -3186,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Stato della rete"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "mai"; /* No comment provided by engineer. */ @@ -3930,6 +3936,9 @@ /* chat item action */ "Reply" = "Rispondi"; +/* chat list item title */ +"requested to connect" = "richiesto di connettersi"; + /* No comment provided by engineer. */ "Required" = "Obbligatorio"; @@ -4455,7 +4464,7 @@ "SimpleX address or 1-time link?" = "Indirizzo SimpleX o link una tantum?"; /* No comment provided by engineer. */ -"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app"; +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app."; /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La sicurezza di SimpleX Chat è stata verificata da Trail of Bits."; @@ -4673,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Errore del file temporaneo"; /* server test failure */ diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index da0ba42a86..39b455f8d1 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -10,9 +10,15 @@ /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- [ディレクトリサービス](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) に接続 (ベータ)!\n- 配信証明を送信する (最大 20 人まで)。\n- より速く、より安定。"; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- より安定したメッセージ配信。\n- 改良されたグループ。\n- などなど!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- 任意で削除された連絡先へ通知します。\n- プロフィール名に空白を含めることができます。\n- and more!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 最長 5 分間の音声メッセージ。\n- 消えるまでのカスタム時間。\n- 編集履歴。"; @@ -76,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**推奨**: デバイス トークンと通知は SimpleX Chat 通知サーバーに送信されますが、メッセージの内容、サイズ、送信者は送信されません。"; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**QRスキャン / リンクの貼り付け**: 受け取ったリンクで接続する。"; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**警告**: 即時の プッシュ通知には、キーチェーンに保存されたパスフレーズが必要です。"; @@ -101,7 +110,7 @@ "%@" = "%@"; /* No comment provided by engineer. */ -"%@ (current)" = "%@ (現在)"; +"%@ (current)" = "%@ (現在)"; /* copied message info */ "%@ (current):" = "%@ (現在):"; @@ -136,12 +145,21 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ は検証されています"; +/* No comment provided by engineer. */ +"%@ server" = "%@ サーバー"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ サーバー"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ アップロード済"; /* notification title */ "%@ wants to connect!" = "%@ が接続を希望しています!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@や%@など%lld人のメンバー"; @@ -154,9 +172,24 @@ /* time interval */ "%d days" = "%d 日"; +/* forward confirmation reason */ +"%d file(s) are still being downloaded." = "%d 個のファイルをダウンロードしています。"; + +/* forward confirmation reason */ +"%d file(s) failed to download." = "%d 個のファイルがダウンロードに失敗しました。"; + +/* forward confirmation reason */ +"%d file(s) were deleted." = "%d 個のファイルが削除されました。"; + +/* forward confirmation reason */ +"%d file(s) were not downloaded." = "%d 個のファイルがダウンロードされていません。"; + /* time interval */ "%d hours" = "%d 時"; +/* alert title */ +"%d messages not forwarded" = "%d 個のメッセージが未転送"; + /* time interval */ "%d min" = "%d 分"; @@ -173,7 +206,7 @@ "%d weeks" = "%d 週"; /* No comment provided by engineer. */ -"%lld" = "%lld"; +"%lld" = ""; /* No comment provided by engineer. */ "%lld %@" = "%lld %@"; @@ -208,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lldつの新しいインターフェース言語"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld 秒"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld 秒"; @@ -256,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0秒"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1日"; /* time interval */ @@ -265,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1分"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1ヶ月"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1週間"; +/* No comment provided by engineer. */ +"1-time link" = "使い捨てリンク"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "使い捨てリンクは、*ひとつの連絡先にのみ* 使用できます - 対面または任意のチャットで共有してください。"; + /* No comment provided by engineer. */ "5 minutes" = "5分"; @@ -346,6 +385,12 @@ /* No comment provided by engineer. */ "Add welcome message" = "ウェルカムメッセージを追加"; +/* No comment provided by engineer. */ +"Added media & file servers" = "追加されたメディア & ファイルサーバー"; + +/* No comment provided by engineer. */ +"Added message servers" = "追加されたメッセージサーバー"; + /* No comment provided by engineer. */ "Address" = "アドレス"; @@ -361,6 +406,9 @@ /* No comment provided by engineer. */ "Advanced network settings" = "ネットワーク詳細設定"; +/* No comment provided by engineer. */ +"Advanced settings" = "詳細設定"; + /* chat item text */ "agreeing encryption for %@…" = "%@の暗号化に同意しています…"; @@ -382,6 +430,9 @@ /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。"; +/* profile dropdown */ +"All profiles" = "すべてのプロフィール"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "あなたの連絡先が繋がったまま継続します。"; @@ -401,7 +452,7 @@ "Allow disappearing messages only if your contact allows it to you." = "連絡先が許可している場合のみ消えるメッセージを許可する。"; /* No comment provided by engineer. */ -"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。"; +"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間)"; /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "連絡先が許可している場合にのみ、メッセージへのリアクションを許可します。"; @@ -415,6 +466,9 @@ /* No comment provided by engineer. */ "Allow sending disappearing messages." = "消えるメッセージの送信を許可する。"; +/* No comment provided by engineer. */ +"Allow sharing" = "共有を許可"; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "送信済みメッセージの永久削除を許可する。(24時間)"; @@ -460,6 +514,9 @@ /* pref value */ "always" = "常に"; +/* No comment provided by engineer. */ +"Always use private routing." = "プライベートルーティングを常に使用する。"; + /* No comment provided by engineer. */ "Always use relay" = "常にリレーを経由する"; @@ -497,7 +554,16 @@ "App version: v%@" = "アプリのバージョン: v%@"; /* No comment provided by engineer. */ -"Appearance" = "見た目"; +"Appearance" = "アピアランス"; + +/* No comment provided by engineer. */ +"Apply" = "適用"; + +/* No comment provided by engineer. */ +"Apply to" = "に適用する"; + +/* No comment provided by engineer. */ +"Archive and upload" = "アーカイブとアップロード"; /* No comment provided by engineer. */ "Attach" = "添付する"; @@ -835,7 +901,7 @@ "Connection" = "接続"; /* No comment provided by engineer. */ -"Connection and servers status." = "接続とサーバーのステータス"; +"Connection and servers status." = "接続とサーバーのステータス。"; /* No comment provided by engineer. */ "Connection error" = "接続エラー"; @@ -1020,7 +1086,8 @@ /* message decrypt error item */ "Decryption error" = "復号化エラー"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "デフォルト (%@)"; /* No comment provided by engineer. */ @@ -1030,7 +1097,6 @@ "default (yes)" = "デフォルト(はい)"; /* alert action - chat item action swipe action */ "Delete" = "削除"; @@ -1097,7 +1163,7 @@ /* No comment provided by engineer. */ "Delete message?" = "メッセージを削除しますか?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "メッセージを削除"; /* No comment provided by engineer. */ @@ -1224,7 +1290,7 @@ "Do NOT use SimpleX for emergency calls." = "緊急通報にSimpleXを使用しないでください。"; /* No comment provided by engineer. */ -"Don't create address" = "アドレスを作成しないでください"; +"Don't create address" = "アドレスを作成しない"; /* No comment provided by engineer. */ "Don't enable" = "有効にしない"; @@ -1262,7 +1328,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "有効にする(設定の優先を維持)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "自動メッセージ削除を有効にしますか?"; /* No comment provided by engineer. */ @@ -1827,7 +1893,7 @@ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "インストール [ターミナル用SimpleX Chat](https://github.com/simplex-chat/simplex-chat)"; /* No comment provided by engineer. */ -"Instant" = "すぐに"; +"Instant" = "即時"; /* No comment provided by engineer. */ "Instant push notifications will be hidden!\n" = "インスタントプッシュ通知は非表示になります!\n"; @@ -2084,6 +2150,9 @@ /* 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." = "メッセージ、ファイル、通話は、前方秘匿性、否認可能性および侵入復元性を備えた**耐量子E2E暗号化**によって保護されます。"; +/* No comment provided by engineer. */ +"Migrate from another device" = "別の端末から移行"; + /* No comment provided by engineer. */ "Migrating database archive…" = "データベースのアーカイブを移行しています…"; @@ -2097,7 +2166,7 @@ "Migration is completed" = "移行が完了しました"; /* No comment provided by engineer. */ -"Migrations:" = "移行"; +"Migrations:" = "移行:"; /* time unit */ "minutes" = "分"; @@ -2150,7 +2219,7 @@ /* No comment provided by engineer. */ "Network status" = "ネットワーク状況"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "一度も"; /* notification */ @@ -2431,6 +2500,9 @@ /* No comment provided by engineer. */ "Private filenames" = "プライベートなファイル名"; +/* name of notes to self */ +"Private notes" = "プライベートノート"; + /* No comment provided by engineer. */ "Profile and server connections" = "プロフィールとサーバ接続"; @@ -3388,6 +3460,9 @@ /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "ユーザープロファイルを右にスワイプすると、非表示またはミュートにすることができます。"; +/* No comment provided by engineer. */ +"You can make it visible to your SimpleX contacts via Settings." = "設定でSimpleXの連絡先に表示させることができます。"; + /* notification body */ "You can now chat with %@" = "%@ にメッセージを送信できるようになりました"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 7004d0d124..4ec93e1188 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nieuwe interface-talen"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld seconde(n)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld seconden"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 dag"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuut"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 maand"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 week"; /* No comment provided by engineer. */ @@ -773,7 +773,7 @@ "blocked" = "geblokkeerd"; /* rcv group event chat item */ -"blocked %@" = "geblokkeerd %@"; +"blocked %@" = "blokkeerde %@"; /* marked deleted chat item preview text */ "blocked by admin" = "geblokkeerd door beheerder"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "decoderingsfouten"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "standaard (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "standaard (ja)"; /* alert action - chat item action swipe action */ "Delete" = "Verwijderen"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Verwijder bericht?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Verwijder berichten"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Inschakelen (overschrijvingen behouden)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatisch verwijderen van berichten aanzetten?"; /* No comment provided by engineer. */ @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favoriet"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Bestandsfout"; /* alert message */ @@ -2800,7 +2800,7 @@ "Japanese interface" = "Japanse interface"; /* swipe action */ -"Join" = "Word lid van"; +"Join" = "Word lid"; /* No comment provided by engineer. */ "join as %@" = "deelnemen als %@"; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Netwerk status"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nooit"; /* No comment provided by engineer. */ @@ -3937,7 +3937,7 @@ "Reply" = "Antwoord"; /* chat list item title */ -"requested to connect" = "gevraagd om verbinding te maken"; +"requested to connect" = "verzocht om verbinding te maken"; /* No comment provided by engineer. */ "Required" = "Vereist"; @@ -4040,7 +4040,7 @@ "Save group profile" = "Groep profiel opslaan"; /* No comment provided by engineer. */ -"Save passphrase and open chat" = "Bewaar het wachtwoord en open je chats"; +"Save passphrase and open chat" = "Wachtwoord opslaan en open je chats"; /* No comment provided by engineer. */ "Save passphrase in Keychain" = "Sla het wachtwoord op in de Keychain"; @@ -4611,7 +4611,7 @@ "Strong" = "Krachtig"; /* No comment provided by engineer. */ -"Submit" = "Indienen"; +"Submit" = "Bevestigen"; /* No comment provided by engineer. */ "Subscribed" = "Subscribed"; @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Tijdelijke bestandsfout"; /* server test failure */ @@ -4821,7 +4821,7 @@ "this contact" = "dit contact"; /* No comment provided by engineer. */ -"This device name" = "Deze apparaatnaam"; +"This device name" = "Naam van dit apparaat"; /* No comment provided by engineer. */ "This display name is invalid. Please choose another name." = "Deze weergavenaam is ongeldig. Kies een andere naam."; @@ -5136,7 +5136,7 @@ "Username" = "Gebruikersnaam"; /* No comment provided by engineer. */ -"Using SimpleX Chat servers." = "SimpleX Chat servers gebruiken."; +"Using SimpleX Chat servers." = "Gebruik SimpleX Chat servers."; /* No comment provided by engineer. */ "v%@" = "v%@"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index cc3bd228f9..b32a9e37d4 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Zalecane**: do serwera powiadomień SimpleX Chat wysyłany jest token urządzenia i powiadomienia, lecz nie treść wiadomości, jej rozmiar lub od kogo ona jest."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**Zeskanuj / Wklej link**: aby połączyć się za pomocą otrzymanego linku."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Uwaga**: Natychmiastowe powiadomienia push wymagają zapisania kodu dostępu w Keychain."; @@ -142,6 +145,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ jest zweryfikowany"; +/* No comment provided by engineer. */ +"%@ server" = "%@ serwer"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ serwery/ów"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ wgrane"; @@ -232,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nowe języki interfejsu"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld sekund(y)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld sekund"; @@ -280,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 dzień"; /* time interval */ @@ -289,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuta"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 miesiąc"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 tydzień"; +/* No comment provided by engineer. */ +"1-time link" = "link jednorazowy"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Link jednorazowy może być użyty *tylko z jednym kontaktem* - udostępnij go osobiście lub przez dowolny komunikator."; + /* No comment provided by engineer. */ "5 minutes" = "5 minut"; @@ -328,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Przerwać zmianę adresu?"; +/* No comment provided by engineer. */ +"About operators" = "O operatorach"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "O SimpleX Chat"; @@ -342,6 +360,9 @@ swipe action */ "Accept" = "Akceptuj"; +/* No comment provided by engineer. */ +"Accept conditions" = "Zaakceptuj warunki"; + /* No comment provided by engineer. */ "Accept connection request?" = "Zaakceptować prośbę o połączenie?"; @@ -355,6 +376,9 @@ /* call status */ "accepted call" = "zaakceptowane połączenie"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Zaakceptowano warunki"; + /* No comment provided by engineer. */ "Acknowledged" = "Potwierdzono"; @@ -367,6 +391,9 @@ /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów."; +/* No comment provided by engineer. */ +"Add friends" = "Dodaj znajomych"; + /* No comment provided by engineer. */ "Add profile" = "Dodaj profil"; @@ -376,12 +403,24 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Dodaj serwery, skanując kody QR."; +/* No comment provided by engineer. */ +"Add team members" = "Dodaj członków zespołu"; + /* No comment provided by engineer. */ "Add to another device" = "Dodaj do innego urządzenia"; /* No comment provided by engineer. */ "Add welcome message" = "Dodaj wiadomość powitalną"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Dodaj członków zespołu do konwersacji."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Dodano serwery multimediów i plików"; + +/* No comment provided by engineer. */ +"Added message servers" = "Dodano serwery wiadomości"; + /* No comment provided by engineer. */ "Additional accent" = "Dodatkowy akcent"; @@ -397,6 +436,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Zmiana adresu zostanie przerwana. Użyty zostanie stary adres odbiorczy."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Adres czy jednorazowy link?"; + +/* No comment provided by engineer. */ +"Address settings" = "Ustawienia adresu"; + /* member role */ "admin" = "administrator"; @@ -673,15 +718,30 @@ /* No comment provided by engineer. */ "Bad message ID" = "Zły identyfikator wiadomości"; +/* No comment provided by engineer. */ +"Better calls" = "Lepsze połączenia"; + /* No comment provided by engineer. */ "Better groups" = "Lepsze grupy"; +/* No comment provided by engineer. */ +"Better message dates." = "Lepsze daty wiadomości."; + /* No comment provided by engineer. */ "Better messages" = "Lepsze wiadomości"; /* No comment provided by engineer. */ "Better networking" = "Lepsze sieciowanie"; +/* No comment provided by engineer. */ +"Better notifications" = "Lepsze powiadomienia"; + +/* No comment provided by engineer. */ +"Better security ✅" = "Lepsze zabezpieczenia ✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "Lepszy interfejs użytkownika"; + /* No comment provided by engineer. */ "Black" = "Czarny"; @@ -742,6 +802,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bułgarski, fiński, tajski i ukraiński – dzięki użytkownikom i [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Adres firmowy"; + +/* No comment provided by engineer. */ +"Business chats" = "Czaty biznesowe"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -812,6 +878,9 @@ /* No comment provided by engineer. */ "Change" = "Zmień"; +/* authentication reason */ +"Change chat profiles" = "Zmień profil czatu"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Zmienić hasło bazy danych?"; @@ -855,6 +924,15 @@ /* chat item text */ "changing address…" = "zmiana adresu…"; +/* No comment provided by engineer. */ +"Chat" = "Czat"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Czat już istnieje"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Czat już istnieje!"; + /* No comment provided by engineer. */ "Chat colors" = "Kolory czatu"; @@ -868,10 +946,10 @@ "Chat database deleted" = "Baza danych czatu usunięta"; /* No comment provided by engineer. */ -"Chat database exported" = "Wyeksportowano bazę danych czatu"; +"Chat database exported" = "Wyeksportowano bazę danych czatów"; /* No comment provided by engineer. */ -"Chat database imported" = "Zaimportowano bazę danych czatu"; +"Chat database imported" = "Zaimportowano bazę danych czatów"; /* No comment provided by engineer. */ "Chat is running" = "Czat jest uruchomiony"; @@ -900,9 +978,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Motyw czatu"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Czat zostanie usunięty dla wszystkich członków – tej operacji nie można cofnąć!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć!"; + /* No comment provided by engineer. */ "Chats" = "Czaty"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Sprawdzaj wiadomości co 20 min."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "Sprawdź wiadomości, gdy będzie to dopuszczone."; + /* alert title */ "Check server address and try again." = "Sprawdź adres serwera i spróbuj ponownie."; @@ -963,6 +1053,21 @@ /* No comment provided by engineer. */ "Completed" = "Zakończono"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Warunki zaakceptowane dnia: %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Warunki zostały zaakceptowane przez operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Warunki użytkowania"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for enabled operators after 30 days." = "Warunki zostaną zaakceptowane dla aktywowanych operatorów po 30 dniach."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Skonfiguruj serwery ICE"; @@ -1341,7 +1446,8 @@ /* No comment provided by engineer. */ "decryption errors" = "błąd odszyfrowywania"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "domyślne (%@)"; /* No comment provided by engineer. */ @@ -1351,7 +1457,6 @@ "default (yes)" = "domyślnie (tak)"; /* alert action - chat item action swipe action */ "Delete" = "Usuń"; @@ -1433,7 +1538,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Usunąć wiadomość?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Usuń wiadomości"; /* No comment provided by engineer. */ @@ -1683,7 +1788,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Włącz (zachowaj nadpisania)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Czy włączyć automatyczne usuwanie wiadomości?"; /* No comment provided by engineer. */ @@ -2085,7 +2190,7 @@ /* swipe action */ "Favorite" = "Ulubione"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Błąd pliku"; /* alert message */ @@ -2967,7 +3072,7 @@ /* No comment provided by engineer. */ "Network status" = "Status sieci"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nigdy"; /* No comment provided by engineer. */ @@ -4349,7 +4454,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Tymczasowy błąd pliku"; /* server test failure */ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index dcd3de19d1..b43c46f208 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld новых языков интерфейса"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунд"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунд"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0с"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 день"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 минута"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 месяц"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 неделю"; /* No comment provided by engineer. */ @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "ошибки расшифровки"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "по умолчанию (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "по умолчанию (да)"; /* alert action - chat item action swipe action */ "Delete" = "Удалить"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Удалить сообщения"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Включить (кроме исключений)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Включить автоматическое удаление сообщений?"; /* No comment provided by engineer. */ @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Избранный"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Ошибка файла"; /* alert message */ @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Состояние сети"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "никогда"; /* No comment provided by engineer. */ @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Временная ошибка файла"; /* server test failure */ diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 4fdc49139a..a959e29f08 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -142,9 +142,6 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld นาที"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld วินาที"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld วินาที"; @@ -187,7 +184,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 วัน"; /* time interval */ @@ -196,10 +194,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 นาที"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 เดือน"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 สัปดาห์"; /* No comment provided by engineer. */ @@ -855,7 +855,8 @@ /* message decrypt error item */ "Decryption error" = "ข้อผิดพลาดในการ decrypt"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "ค่าเริ่มต้น (%@)"; /* No comment provided by engineer. */ @@ -865,7 +866,6 @@ "default (yes)" = "ค่าเริ่มต้น (ใช่)"; /* alert action - chat item action swipe action */ "Delete" = "ลบ"; @@ -932,7 +932,7 @@ /* No comment provided by engineer. */ "Delete message?" = "ลบข้อความ?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "ลบข้อความ"; /* No comment provided by engineer. */ @@ -1082,7 +1082,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "เปิดใช้งาน (เก็บการแทนที่)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "เปิดใช้งานการลบข้อความอัตโนมัติ?"; /* No comment provided by engineer. */ @@ -1946,7 +1946,7 @@ /* No comment provided by engineer. */ "Network status" = "สถานะเครือข่าย"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ไม่เคย"; /* notification */ diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index b3eb5d426a..0da95054aa 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Önerilen**: cihaz tokeni ve bildirimler SimpleX Chat bildirim sunucularına gönderilir, ama mesajın içeriği, boyutu veya kimden geldiği gönderilmez."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "edindiğiniz bağlantı aracılığıyla bağlanmak için **Linki tarayın/yapıştırın**."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Dikkat**: Anında iletilen bildirimlere Anahtar Zinciri'nde kaydedilmiş parola gereklidir."; @@ -142,6 +145,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ onaylandı"; +/* No comment provided by engineer. */ +"%@ server" = "%@ sunucu"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ sunucular"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ yüklendi"; @@ -232,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld yeni arayüz dilleri"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld saniye"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld saniye"; @@ -280,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0sn"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 gün"; /* time interval */ @@ -289,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1 dakika"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 ay"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 hafta"; +/* No comment provided by engineer. */ +"1-time link" = "tek kullanımlık bağlantı"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Tek kullanımlık bağlantı *sadece bir kişi ile* kullanılabilir - kişiyle veya uygulama içinden paylaş."; + /* No comment provided by engineer. */ "5 minutes" = "5 dakika"; @@ -328,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Adres değişimi iptal edilsin mi?"; +/* No comment provided by engineer. */ +"About operators" = "Operatörler hakkında"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "SimpleX Chat hakkında"; @@ -342,6 +360,9 @@ swipe action */ "Accept" = "Kabul et"; +/* No comment provided by engineer. */ +"Accept conditions" = "Koşulları kabul et"; + /* No comment provided by engineer. */ "Accept connection request?" = "Bağlantı isteği kabul edilsin mi?"; @@ -355,6 +376,9 @@ /* call status */ "accepted call" = "kabul edilen arama"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Kabul edilmiş koşullar"; + /* No comment provided by engineer. */ "Acknowledged" = "Onaylandı"; @@ -367,6 +391,9 @@ /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Kişilerinizin başkalarıyla paylaşabilmesi için profilinize adres ekleyin. Profil güncellemesi kişilerinize gönderilecek."; +/* No comment provided by engineer. */ +"Add friends" = "Arkadaş ekle"; + /* No comment provided by engineer. */ "Add profile" = "Profil ekle"; @@ -376,12 +403,24 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Karekod taratarak sunucuları ekleyin."; +/* No comment provided by engineer. */ +"Add team members" = "Takım üyesi ekle"; + /* No comment provided by engineer. */ "Add to another device" = "Başka bir cihaza ekle"; /* No comment provided by engineer. */ "Add welcome message" = "Karşılama mesajı ekleyin"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Takım üyelerini konuşmalara ekle."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "medya ve dosya sunucuları eklendi"; + +/* No comment provided by engineer. */ +"Added message servers" = "Mesaj sunucuları eklendi"; + /* No comment provided by engineer. */ "Additional accent" = "Ek ana renk"; @@ -397,6 +436,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Adres değişikliği iptal edilecek. Eski alıcı adresi kullanılacaktır."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "adres mi yoksa tek kullanımlık bağlantı mı?"; + +/* No comment provided by engineer. */ +"Address settings" = "Adres seçenekleri"; + /* member role */ "admin" = "yönetici"; @@ -439,6 +484,9 @@ /* feature role */ "all members" = "bütün üyeler"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Tüm mesajlar silinecektir - bu geri alınamaz!"; @@ -757,6 +805,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarca, Fince, Tayca ve Ukraynaca - kullanıcılara ve [Weblate] e teşekkürler! (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "İş adresi"; + +/* No comment provided by engineer. */ +"Business chats" = "İş konuşmaları"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Sohbet profiline göre (varsayılan) veya [bağlantıya göre](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -827,6 +881,9 @@ /* No comment provided by engineer. */ "Change" = "Değiştir"; +/* authentication reason */ +"Change chat profiles" = "Sohbet profillerini değiştir"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Veritabanı parolasını değiştir?"; @@ -870,6 +927,15 @@ /* chat item text */ "changing address…" = "adres değiştiriliyor…"; +/* No comment provided by engineer. */ +"Chat" = "Sohbet"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Sohbet zaten mevcut"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Sohbet zaten mevcut!"; + /* No comment provided by engineer. */ "Chat colors" = "Sohbet renkleri"; @@ -915,9 +981,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Sohbet teması"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Sohbet bütün üyeler için silinecek - bu geri alınamaz!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Sohbet senden silinecek - bu geri alınamaz!"; + /* No comment provided by engineer. */ "Chats" = "Sohbetler"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Her 20 dakikada mesajları kontrol et."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "İzin verildiğinde mesajları kontrol et."; + /* alert title */ "Check server address and try again." = "Sunucu adresini kontrol edip tekrar deneyin."; @@ -978,6 +1056,33 @@ /* No comment provided by engineer. */ "Completed" = "Tamamlandı"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Şuradaki koşullar kabul edildi: %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Koşullar operatör(ler) için kabul edildi: **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Kullanım koşulları"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for enabled operators after 30 days." = "Koşullar 30 gün sonra etkin operatörler tarafından kabul edilecektir."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for operator(s): **%@**." = "Koşullar operatör(ler) için kabul edilecektir: **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Koşullar bu operatör(ler) için kabul edilecektir: **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Koşullar şu tarihte kabul edilecektir: %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Koşullar etkin operatörler için şu tarihte otomatik olarak kabul edilecektir: %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "ICE sunucularını ayarla"; @@ -1125,6 +1230,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Bağlantı daveti gönderildi!"; +/* No comment provided by engineer. */ +"Connection security" = "Bağlantı güvenliği"; + /* No comment provided by engineer. */ "Connection terminated" = "Bağlantı sonlandırılmış"; @@ -1206,6 +1314,9 @@ /* No comment provided by engineer. */ "Create" = "Oluştur"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Tek kullanımlık bağlantı oluştur"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Rasgele profil kullanarak grup oluştur."; @@ -1257,6 +1368,9 @@ /* No comment provided by engineer. */ "creator" = "oluşturan"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Şu anki koşulların yazısı yüklenemiyor, bu bağlantıdan koşullara inceleyebilirsin:"; + /* No comment provided by engineer. */ "Current Passcode" = "Şu anki şifre"; @@ -1359,7 +1473,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Şifre çözme hataları"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "varsayılan (%@)"; /* No comment provided by engineer. */ @@ -1369,7 +1484,6 @@ "default (yes)" = "varsayılan (evet)"; /* alert action - chat item action swipe action */ "Delete" = "Sil"; @@ -1394,12 +1508,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Sil ve kişiye bildir"; +/* No comment provided by engineer. */ +"Delete chat" = "Sohbeti sil"; + /* No comment provided by engineer. */ "Delete chat profile" = "Sohbet profilini sil"; /* No comment provided by engineer. */ "Delete chat profile?" = "Sohbet profili silinsin mi?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Sohbet silinsin mi?"; + /* No comment provided by engineer. */ "Delete connection" = "Bağlantıyı sil"; @@ -1451,7 +1571,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Mesaj silinsin mi?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Mesajları sil"; /* No comment provided by engineer. */ @@ -1571,6 +1691,9 @@ /* chat feature */ "Direct messages" = "Doğrudan mesajlar"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "Üyeler arası doğrudan mesajlar bu sohbette yasaktır."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Bu grupta üyeler arasında direkt mesajlaşma yasaktır."; @@ -1692,6 +1815,9 @@ /* No comment provided by engineer. */ "e2e encrypted" = "uçtan uca şifrelenmiş"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Uçtan uca şifrelenmiş bildirimler."; + /* chat item action */ "Edit" = "Düzenle"; @@ -1704,12 +1830,15 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Etkinleştir (geçersiz kılmaları koru)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Otomatik mesaj silme etkinleştirilsin mi?"; /* No comment provided by engineer. */ "Enable camera access" = "Kamera erişimini etkinleştir"; +/* No comment provided by engineer. */ +"Enable Flux" = "Flux'u Etkinleştir"; + /* No comment provided by engineer. */ "Enable for all" = "Herkes için etkinleştir"; @@ -1869,12 +1998,18 @@ /* No comment provided by engineer. */ "Error aborting address change" = "Adres değişikliği iptal edilirken hata oluştu"; +/* alert title */ +"Error accepting conditions" = "Koşulları kabul ederken hata oluştu"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Bağlantı isteği kabul edilirken hata oluştu"; /* No comment provided by engineer. */ "Error adding member(s)" = "Üye(ler) eklenirken hata oluştu"; +/* alert title */ +"Error adding server" = "Sunucu eklenirken hata oluştu"; + /* No comment provided by engineer. */ "Error changing address" = "Adres değiştirilirken hata oluştu"; @@ -1959,6 +2094,9 @@ /* No comment provided by engineer. */ "Error joining group" = "Gruba katılırken hata oluştu"; +/* alert title */ +"Error loading servers" = "Sunucular yüklenirken hata oluştu"; + /* No comment provided by engineer. */ "Error migrating settings" = "Ayarlar taşınırken hata oluştu"; @@ -1992,6 +2130,9 @@ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Parolayı Anahtar Zincirine kaydederken hata oluştu"; +/* alert title */ +"Error saving servers" = "Sunucular kaydedilirken hata oluştu"; + /* when migrating */ "Error saving settings" = "Ayarlar kaydedilirken hata oluştu"; @@ -2034,6 +2175,9 @@ /* No comment provided by engineer. */ "Error updating message" = "Mesaj güncellenirken hata oluştu"; +/* alert title */ +"Error updating server" = "Sunucu güncellenirken hata oluştu"; + /* No comment provided by engineer. */ "Error updating settings" = "Ayarları güncellerken hata oluştu"; @@ -2061,6 +2205,9 @@ /* No comment provided by engineer. */ "Errors" = "Hatalar"; +/* servers error */ +"Errors in servers configuration." = "Sunucular yapılandırılırken hatalar oluştu."; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Konuşma sırasında devre dışı bırakılsa bile."; @@ -2106,7 +2253,7 @@ /* swipe action */ "Favorite" = "Favori"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Dosya hatası"; /* alert message */ @@ -2187,9 +2334,21 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Düzeltme grup üyesi tarafından desteklenmiyor"; +/* servers error */ +"For chat profile %@:" = "Sohbet profili için %@:"; + /* No comment provided by engineer. */ "For console" = "Konsol için"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir."; + +/* No comment provided by engineer. */ +"For private routing" = "Gizli yönlendirme için"; + +/* No comment provided by engineer. */ +"For social media" = "Sosyal medya için"; + /* chat item action */ "Forward" = "İlet"; @@ -2361,6 +2520,12 @@ /* time unit */ "hours" = "saat"; +/* No comment provided by engineer. */ +"How it affects privacy" = "Gizliliğinizi nasıl etkiler"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Gizliliğinizi nasıl arttırır"; + /* No comment provided by engineer. */ "How SimpleX works" = "SimpleX nasıl çalışır"; @@ -2565,6 +2730,9 @@ /* No comment provided by engineer. */ "Invite members" = "Üyeleri davet et"; +/* No comment provided by engineer. */ +"Invite to chat" = "Sohbete davet et"; + /* No comment provided by engineer. */ "Invite to group" = "Gruba davet et"; @@ -2679,6 +2847,12 @@ /* swipe action */ "Leave" = "Ayrıl"; +/* No comment provided by engineer. */ +"Leave chat" = "Sohbetten ayrıl"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Sohbetten ayrılsın mı?"; + /* No comment provided by engineer. */ "Leave group" = "Gruptan ayrıl"; @@ -2775,6 +2949,9 @@ /* item status text */ "Member inactive" = "Üye inaktif"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Üye rolü \"%@\" olarak değiştirilecektir. Tüm sohbet üyeleri bilgilendirilecektir."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Üye rolü \"%@\" olarak değiştirilecektir. Ve tüm grup üyeleri bilgilendirilecektir."; @@ -2994,7 +3171,7 @@ /* No comment provided by engineer. */ "Network status" = "Ağ durumu"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "asla"; /* No comment provided by engineer. */ @@ -4385,7 +4562,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_TVLDEKAL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Geçici dosya hatası"; /* server test failure */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index ce8184272d..9b4a1d0980 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld нові мови інтерфейсу"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунд(и)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунд"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0с"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 день"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 хвилина"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 місяць"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 тиждень"; /* No comment provided by engineer. */ @@ -343,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Скасувати зміну адреси?"; +/* No comment provided by engineer. */ +"About operators" = "Про операторів"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "Про чат SimpleX"; @@ -376,6 +379,9 @@ /* No comment provided by engineer. */ "Accepted conditions" = "Прийняті умови"; +/* chat list item title */ +"accepted invitation" = "прийняте запрошення"; + /* No comment provided by engineer. */ "Acknowledged" = "Визнано"; @@ -388,6 +394,9 @@ /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Повідомлення про оновлення профілю буде надіслано вашим контактам."; +/* No comment provided by engineer. */ +"Add friends" = "Додайте друзів"; + /* No comment provided by engineer. */ "Add profile" = "Додати профіль"; @@ -397,12 +406,18 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Додайте сервери, відсканувавши QR-код."; +/* No comment provided by engineer. */ +"Add team members" = "Додайте учасників команди"; + /* No comment provided by engineer. */ "Add to another device" = "Додати до іншого пристрою"; /* No comment provided by engineer. */ "Add welcome message" = "Додати вітальне повідомлення"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Додайте членів своєї команди до розмов."; + /* No comment provided by engineer. */ "Added media & file servers" = "Додано медіа та файлові сервери"; @@ -793,6 +808,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Болгарською, фінською, тайською та українською мовами - завдяки користувачам та [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Адреса підприємства"; + +/* No comment provided by engineer. */ +"Business chats" = "Ділові чати"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Через профіль чату (за замовчуванням) або [за з'єднанням](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -909,6 +930,15 @@ /* chat item text */ "changing address…" = "змінює адресу…"; +/* No comment provided by engineer. */ +"Chat" = "Чат"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Чат вже існує"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Чат вже існує!"; + /* No comment provided by engineer. */ "Chat colors" = "Кольори чату"; @@ -954,6 +984,12 @@ /* No comment provided by engineer. */ "Chat theme" = "Тема чату"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Чат буде видалено для всіх учасників - цю дію неможливо скасувати!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Чат буде видалено для вас - цю дію неможливо скасувати!"; + /* No comment provided by engineer. */ "Chats" = "Чати"; @@ -1440,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "помилки розшифровки"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "за замовчуванням (%@)"; /* No comment provided by engineer. */ @@ -1450,7 +1487,6 @@ "default (yes)" = "за замовчуванням (так)"; /* alert action - chat item action swipe action */ "Delete" = "Видалити"; @@ -1475,12 +1511,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Видалити та повідомити контакт"; +/* No comment provided by engineer. */ +"Delete chat" = "Видалити чат"; + /* No comment provided by engineer. */ "Delete chat profile" = "Видалити профіль чату"; /* No comment provided by engineer. */ "Delete chat profile?" = "Видалити профіль чату?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Видалити чат?"; + /* No comment provided by engineer. */ "Delete connection" = "Видалити підключення"; @@ -1532,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Видалити повідомлення?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Видалити повідомлення"; /* No comment provided by engineer. */ @@ -1655,6 +1697,9 @@ /* chat feature */ "Direct messages" = "Прямі повідомлення"; +/* 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." = "У цій групі заборонені прямі повідомлення між учасниками."; @@ -1791,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Увімкнути (зберегти перевизначення)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Увімкнути автоматичне видалення повідомлень?"; /* No comment provided by engineer. */ @@ -2214,7 +2259,7 @@ /* swipe action */ "Favorite" = "Улюблений"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Помилка файлу"; /* alert message */ @@ -2694,6 +2739,9 @@ /* No comment provided by engineer. */ "Invite members" = "Запросити учасників"; +/* No comment provided by engineer. */ +"Invite to chat" = "Запросити в чат"; + /* No comment provided by engineer. */ "Invite to group" = "Запросити до групи"; @@ -2808,6 +2856,12 @@ /* swipe action */ "Leave" = "Залишити"; +/* No comment provided by engineer. */ +"Leave chat" = "Вийти з чату"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Залишити чат?"; + /* No comment provided by engineer. */ "Leave group" = "Покинути групу"; @@ -2904,12 +2958,18 @@ /* item status text */ "Member inactive" = "Користувач неактивний"; +/* 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!" = "Учасник буде видалений з групи - це неможливо скасувати!"; @@ -3132,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Стан мережі"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ніколи"; /* No comment provided by engineer. */ @@ -3329,6 +3389,9 @@ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Onion хости не будуть використовуватися."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Лише власники чату можуть змінювати налаштування."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Тільки клієнтські пристрої зберігають профілі користувачів, контакти, групи та повідомлення, надіслані за допомогою **2-шарового наскрізного шифрування**."; @@ -3407,6 +3470,9 @@ /* alert title */ "Operator server" = "Сервер оператора"; +/* No comment provided by engineer. */ +"Or import archive file" = "Або імпортуйте архівний файл"; + /* No comment provided by engineer. */ "Or paste archive link" = "Або вставте посилання на архів"; @@ -3575,6 +3641,9 @@ /* No comment provided by engineer. */ "Privacy & security" = "Конфіденційність і безпека"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Конфіденційність для ваших клієнтів."; + /* No comment provided by engineer. */ "Privacy redefined" = "Конфіденційність переглянута"; @@ -3867,6 +3936,9 @@ /* chat item action */ "Reply" = "Відповісти"; +/* chat list item title */ +"requested to connect" = "запит на підключення"; + /* No comment provided by engineer. */ "Required" = "Потрібно"; @@ -4391,6 +4463,9 @@ /* No comment provided by engineer. */ "SimpleX address or 1-time 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, у додаток."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "Безпека SimpleX Chat була перевірена компанією Trail of Bits."; @@ -4571,6 +4646,9 @@ /* No comment provided by engineer. */ "Tap button " = "Натисніть кнопку "; +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше."; + /* No comment provided by engineer. */ "Tap to activate profile." = "Натисніть, щоб активувати профіль."; @@ -4604,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Тимчасова помилка файлу"; /* server test failure */ @@ -5279,6 +5357,9 @@ /* No comment provided by engineer. */ "You are already connected to %@." = "Ви вже підключені до %@."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Ви вже підключені до %@."; + /* No comment provided by engineer. */ "You are already connecting to %@." = "Ви вже з'єднані з %@."; @@ -5477,6 +5558,9 @@ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Ви більше не будете отримувати повідомлення з цього чату. Історія чату буде збережена."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Ви перестанете отримувати повідомлення від цієї групи. Історія чату буде збережена."; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 62ff2088c2..f7ab7f7994 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -214,9 +214,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld 种新的界面语言"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld 秒"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld 秒"; @@ -262,7 +259,8 @@ /* No comment provided by engineer. */ "0s" = "0秒"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1天"; /* time interval */ @@ -271,10 +269,12 @@ /* No comment provided by engineer. */ "1 minute" = "1分钟"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1月"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1周"; /* No comment provided by engineer. */ @@ -1311,7 +1311,8 @@ /* No comment provided by engineer. */ "decryption errors" = "解密错误"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "默认 (%@)"; /* No comment provided by engineer. */ @@ -1321,7 +1322,6 @@ "default (yes)" = "默认 (是)"; /* alert action - chat item action swipe action */ "Delete" = "删除"; @@ -1403,7 +1403,7 @@ /* No comment provided by engineer. */ "Delete message?" = "删除消息吗?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "删除消息"; /* No comment provided by engineer. */ @@ -1647,7 +1647,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "启用(保持覆盖)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "启用自动删除消息?"; /* No comment provided by engineer. */ @@ -2037,7 +2037,7 @@ /* swipe action */ "Favorite" = "最喜欢"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "文件错误"; /* file error text */ @@ -2895,7 +2895,7 @@ /* No comment provided by engineer. */ "Network status" = "网络状态"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "从不"; /* No comment provided by engineer. */ @@ -4220,7 +4220,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "临时文件错误"; /* server test failure */ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 4c781f0aab..417f6a717a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -36,13 +36,13 @@ سيبقى جميع أعضاء المجموعة على اتصال. السماح باختفاء الرسائل فقط إذا سمحت جهة اتصالك بذلك. السماح بحذف الرسائل بشكل لا رجوع فيه فقط إذا سمحت لك جهة الاتصال بذلك. (24 ساعة) - المشرف + المُدير أضِف ملف التعريف السماح بإرسال رسائل مباشرة إلى الأعضاء. قبول التخفي أضِف رسالة ترحيب أضف الخوادم عن طريق مسح رموز QR. - يمكن للمشرفين إنشاء روابط للانضمام إلى المجموعات. + يمكن للمُدراء إنشاء روابط للانضمام إلى المجموعات. قبول طلب الاتصال؟ سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك. مكالمة مقبولة @@ -51,7 +51,7 @@ يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. أجب الاتصال - دائِماً + دائمًا السماح بإرسال رسائل تختفي. السماح بإرسال رسائل صوتية. تطبيق @@ -64,7 +64,7 @@ السماح لجهات اتصالك بإرسال رسائل تختفي. اسمح بالرسائل الصوتية فقط إذا سمحت جهة اتصالك بذلك. رمز مرور التطبيق - دائِماً مُتاح + يعمل دائمًا يمكن للتطبيق استلام الإشعارات فقط عند تشغيله، ولن يتم بدء تشغيل أي خدمة في الخلفية السماح بالرسائل الصوتية؟ ستبقى جميع جهات اتصالك متصلة. @@ -121,7 +121,7 @@ مصادقة 1 دقيقة 30 ثانية - إلغاء الرسالة المباشرة + ألغِ الرسالة الحيّة إلغاء لكل جهة اتصال وعضو في المجموعة\n. الرجاء ملاحظة: إذا كان لديك العديد من الاتصالات، فقد يكون استهلاك البطارية وحركة المرور أعلى بكثير وقد تفشل بعض الاتصالات.]]> جارٍ الاتصال… @@ -464,8 +464,8 @@ سيتم استلام الملف عندما يكتمل جهة اتصالك من رفعِها. المساعدة الملف: %s - إصلاح - إصلاح الاتصال + أصلح + أصلح الاتصال إصلاح الاتصال؟ الإصلاح غير مدعوم من قبل أعضاء المجموعة يمكن للأعضاء إرسال الملفات والوسائط. @@ -567,7 +567,7 @@ مدعو %1$s مدعو عبر رابط المجموعة الدردشة غير صالحة - حي + حيّ رابط اتصال غير صالح الملف كبير! معرفة المزيد @@ -577,7 +577,7 @@ تأكيد الترحيل غير صالح مدعو رابط معاينة الصورة - رسالة مباشرة! + رسالة حيّة! مائل لنتحدث في SimpleX Chat قفل بعد @@ -589,10 +589,10 @@ غادِر مغادرة المجموعة؟ غادر - إيصالات التسليم معطلة! + إيصالات التسليم مُعطَّلة! تعطيل رسائل تختفي - عٌطل مصادقة الجهاز. جاري إيقاف تشغيل قفل SimpleX. + استيثاق الجهاز مُعطَّل. جارِ إيقاف تشغيل قفل SimpleX. %d شهر %d أسابيع %d أسبوع @@ -610,11 +610,11 @@ يختفي في: %s الرسائل المختفية ممنوعة في هذه الدردشة. مُعمّى بين الطريفين - حُرر + حُرّر الرجوع إلى إصدار سابق وفتح الدردشة رسائل مباشرة الرسائل المختفية ممنوعة. - تحرير ملف تعريف المجموعة + حرّر ملف تعريف المجموعة لا تُظهر مرة أخرى الجهاز %d أسبوع @@ -625,7 +625,7 @@ %d ساعة %d ساعات %d شهر - تحرير الصورة + حرّر الصورة %d ملف/ات بإجمالي الحجم %s %d ثانية جهات الاتصال @@ -704,7 +704,7 @@ خطأ في حفظ ملف تعريف المجموعة رسالة نصية ردود فعل الرسائل - سيتم وضع علامة على الرسالة للحذف. سيتمكن المستلم/مون من الكشف عن هذه الرسالة. + سيتم وضع علامة على الرسالة للحذف. سيتمكن المُستلم/ون من الكشف عن هذه الرسالة. سيتم حذف الرسالة - لا يمكن التراجع عن هذا! خطأ في تسليم الرسالة الشبكة والخوادم @@ -729,9 +729,7 @@ إيصالات تسليم الرسائل! دقائق شهور - - توصيل رسائل أكثر استقرارًا. -\n- مجموعات أفضل قليلاً. -\n- و اكثر! + - توصيل رسائل أكثر استقرارًا.\n- مجموعات أفضل قليلاً.\n- و اكثر! حالة الشبكة كتم ردود الفعل الرسائل ممنوعة. @@ -757,7 +755,7 @@ سيتم استخدام مضيفات البصل عند توفرها. لن يتم استخدام مضيفات البصل. لم تٌحدد جهات اتصال - يمكن للمشرف الآن:\n- حذف رسائل الأعضاء.\n- تعطيل الأعضاء (دور المراقب) + يمكن للمُدراء الآن:\n- حذف رسائل الأعضاء.\n- تعطيل الأعضاء (دور المراقب) خدمة الإشعار غير مفعّل` مفعل @@ -782,13 +780,13 @@ اسم عرض جديد: عبارة مرور جديدة… قيد الانتظار - كلمة المرور مطلوبة + عبارة المرور مطلوبة ألصِق الرابط الذي استلمته فقط مالكي المجموعة يمكنهم تفعيل الملفات والوسائط. فقط مالكي المجموعة يمكنهم تفعيل الرسائل الصوتية. (يخزن فقط بواسطة أعضاء المجموعة) - كلمة المرور - تم تعيين كلمة المرور! + رمز المرور + عيّنت رمز المرور! المالك فقط جهة اتصالك يمكنها إرسال رسائل تختفي. جهة اتصالك فقط يمكنها إضافة ردود الفعل على الرسالة @@ -796,26 +794,26 @@ جهة اتصالك فقط يمكنها حذف الرسائل بشكل لا رجعة فيه (يمكنك تعليم الرسالة للحذف). (24 ساعة) أنت فقط يمكنك إرسال رسائل صوتية. افتح - لم يتم تغيير كلمة المرور! - تم تغيير كلمة المرور + لم يتم تغيير رمز المرور! + تم تغيير رمز المرور جارِ فتح قاعدة البيانات… جهة اتصالك فقط يمكنها إرسال رسائل صوتية. ألصق - كلمة المرور غير موجودة في مخزن المفاتيح، يرجى إدخالها يدوياً. قد يحدث هذا إذا قمت باستعادة ملفات التطبيق باستخدام أداة استرجاع بيانات. إذا لم يكن الأمر كذلك، تواصل مع المبرمجين رجاء + لم يتم العثور على عبارة المرور في Keystore، يُرجى إدخالها يدويًا. ربما حدث هذا إذا استعدت بيانات التطبيق باستخدام أداة النسخ الاحتياطي. إذا لم يكن الأمر كذلك، يُرجى التواصل مع المطورين. افتح الدردشة - فتح الرابط في المتصفح قد يقلل خصوصية وحماية اتصالك. الروابط غير الموثوقة من SimpleX ستكون باللون الأحمر + قد يؤدي فتح الرابط في المتصفح إلى تقليل خصوصية الاتصال وأمانه. ستظهر روابط SimpleX غير الموثوقة باللون الأحمر. أنت فقط يمكنك إضافة ردود الفعل على الرسالة. أنت فقط يمكنك حذف الرسائل بشكل لا رجعة فيه (يمكن للمستلم تعليمها للحذف). (24 ساعة) أنت فقط يمكنك إرسال رسائل تختفي أنت فقط يمكنك إجراء المكالمات. فقط جهة اتصالك يمكنها إجراء المكالمات. افتح وحدة تحكم الدردشة - إدخال كلمة المرور - افتح SimpleX Chat للرد على المكالمة + إدخال رمز المرور + افتح SimpleX Chat لقبول المكالمة يمكن لأي شخص استضافة الخوادم. كلمة المرور للإظهار ندّ لِندّ - أنت تقرر من يمكنه الاتصال. + أنت تقرر مَن يمكنه الاتصال. مكالمة قيد الانتظار تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل. صفّر الألوان @@ -879,7 +877,7 @@ حٌديثت السجل في حٌديثت السجل في: %s استعادة - يرى المستلمون التحديثات أثناء كتابتها. + يرى المُستلمون التحديثات أثناء كتابتها. استلمت، ممنوع حفظ سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. @@ -915,7 +913,7 @@ معاينة من المحتمل أن الملف المرجعي للشهادة في عنوان الخادم غير صحيح يتم استلام الرسائل… - يُرجى الاتصال بمشرف المجموعة. + يُرجى الاتصال بمُدير المجموعة. أعد التفاوض إعادة تفاوض التعمية سحب وصول الملف @@ -935,12 +933,12 @@ %s في %s حفظ ملف المجموعة ثانوي - كلمة مرور التدمير الذاتي + رمز المرور للتدمير الذاتي إرسال الملفات غير مدعوم بعد - قام المرسل بإلغاء إرسال الملف + أُلغيَ المرسل نقل الملف. (امسح أو ألصق من الحافظة) ثانية - قد يكون المرسل قد ألغى طلب الاتصال + ربما حذف المرسل طلب الاتصال. مسح رمز QR أرسل لنا بريداً مسح رمز الأمان من تطبيق جهة الاتصال @@ -948,63 +946,63 @@ سيتم إيقاف إرسال الملف. إرسال رسالة إرسال - إرسال رسالة حية + أرسل رسالة حيّة فشلت تجربة الخادم! - حفظ كلمة المرور في مخزن المفاتيح + احفظ عبارة المرور في Keystore أرسل رسالة مباشرة إرسال عبر الخوادم تقييم الأمان الرسائل المرسلة سيتم حذفها بعد المدة المحدّدة. تعيين رسالة تظهر للأعضاء الجدد! - تعيين كلمة المرور + عيّن رمز المرور تم إرساله في: %s %s (الحالي) رسالة مرسلة عيّن تفضيلات المجموعة - عيينها بدلا من توثيق النظام + عيّنها بدلاً من استيثاق النظام. مشاركة إرسال - حفظ كلمة المرور وفتح الدردشة - حدد جهات اتصال + احفظ عبارة المرور وافتح الدردشة + حدد جهات الاتصال تعيين يوم واحد ثواني رسالة مرسلة أرسل رسالة تختفي - حفظ كلمة مرور الحساب + حفظ كلمة مرور ملف التعريف تدمير ذاتي - مسح الكود - إرسال أسئلة وأفكار + مسح الرمز + أرسل أسئلة وأفكار مشاركة العنوان مع جهات الاتصال؟ مشاركة العنوان حفظ رسالة الترحيب؟ - حفظ السيرفرات - أرسل تقارير الاستلام إلى - إرسال تقارير الاستلام معطل لـ %d جهة اتصال. - إرسال تقارير الاستلام مفعل لـ %d جهة اتصال - تعيين كلمة المرور للتصدير + احفظ الخوادم + أرسل إيصالات التسليم إلى + إرسال الإيصالات مُعطَّل لـ %d جهة اتصال. + إرسال الإيصالات مفعّل لـ %d جهة اتصال + عيّن عبارة المرور للتصدير تم تغيير رمز الأمان - تقارير الارسال + إيصالات الإرسال تم إرساله في حدد - إرسال تقارير الاستلام سيتم تفعيله لجميع جهات الاتصال. - سيتم تفعيل إرسال تقارير الاستلام لجميع جهات الاتصال ذات حسابات دردشة ظاهرة + سيتم تفعيل إرسال إيصالات التسليم لجميع جهات الاتصال. + سيتم تفعيل إرسال إيصالات التسليم لجميع جهات الاتصال في جميع ملفات تعريف الدردشة المرئية. قائمة انتظار آمنة فشل الإرسال - تم الإرسال + أُرسلت تعيين اسم جهة الاتصال… - إرسال رسالة حية - سيتم تحديثها للمستلم مع كتابتك لها + أرسل رسالة حيّة - سيتم تحديثها للمُستلم مع كتابتك لها تعيين اسم جهة الاتصال الإعدادات حفظ الخوادم؟ مسح رمز QR الخادم رمز الأمان - حفظ الإعدادات؟ + حفظ التفضيلات؟ حفظ الإعدادات؟ سري - كلمة مرور التدمير الذاتي - تم تغيير كلمة مرور التدمير الذاتي! - تم تفعيل كلمة مرور التدمير الذاتي + رمز المرور للتدمير الذاتي + تغيّرت رمز المرور للتدمير الذاتي! + فعّلت رمز المرور للتدمير الذاتي! الإعدادات دعوة لمرة واحدة SimpleX عرض جهة الاتصال والرسالة @@ -1069,7 +1067,7 @@ إعدادات وكيل SOCKS إيقاف التشغيل السماعة قيد التشغيل - إرسال + أرسل النظام السمة لبدء محادثة جديدة @@ -1077,7 +1075,7 @@ لامركزية بالكامل – مرئية للأعضاء فقط. النظام فشل الاختبار في الخطوة %s. - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! يجلب التطبيق الرسائل الجديدة بشكل دوري - يستخدم نسبة قليلة من البطارية يوميًا. لا يستخدم التطبيق إشعارات الدفع - لا يتم إرسال البيانات من جهازك إلى الخوادم. سيتم إلغاء الاتصال الذي قبلته! لن تتمكن جهة الاتصال التي شاركت هذا الرابط معها من الاتصال! @@ -1085,15 +1083,15 @@ لحماية خصوصيتك، يستخدم SimpleX معرّفات منفصلة لكل جهة اتصال لديك. لحماية معلوماتك، فعّل قفل SimpleX \nسيُطلب منك إكمال المصادقة قبل تفعيل هذه الميزة. عزل النقل - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! دعم البلوتوث وتحسينات أخرى. - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! يتم تشغيل SimpleX في الخلفية بدلاً من استخدام إشعارات push.]]> انقر لبدء محادثة جديدة (للمشاركة مع جهة اتصالك) للتواصل عبر الرابط للاتصال، يمكن لجهة الاتصال مسح رمز QR أو استخدام الرابط في التطبيق. - خوادم الاختبار + اختبر الخوادم لا معرّفات مُستخدم دعم SIMPLEX CHAT تبديل @@ -1104,7 +1102,7 @@ انقر للانضمام إلى وضع التخفي النظام السمات - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! قاعدة البيانات لا تعمل بشكل صحيح. انقر لمعرفة المزيد ألوان الواجهة انقر لتنشيط ملف التعريف. @@ -1115,7 +1113,7 @@ انتهت مهلة اتصال TCP لحماية المنطقة الزمنية، تستخدم ملفات الصور / الصوت التوقيت العالمي المنسق (UTC). فقدنا القراد الثاني! ✅ - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! لم تكتمل محاولة تغيير عبارة مرور قاعدة البيانات. لاستلام الإشعارات، يُرجى إدخال عبارة مرور قاعدة البيانات مصادقة النظام @@ -1126,7 +1124,7 @@ مقاطع الفيديو كثيرة! زر النقر شكرًا لك على تثبيت SimpleX Chat! - خادم الاختبار + اختبر الخادم تجزئة الرسالة السابقة مختلفة. معرف الرسالة التالية غير صحيح (أقل أو يساوي السابق). \nيمكن أن يحدث ذلك بسبب بعض العلل أو عندما يُخترق الاتصال. @@ -1267,9 +1265,7 @@ إلغاء إخفاء ملف تعريف الدردشة الرسائل الصوتية ممنوعة في هذه الدردشة. مقاطع فيديو وملفات تصل إلى 1 جيجا بايت - - رسائل صوتية تصل إلى 5 دقائق. -\n- الوقت المخصص لتختفي. -\n- تحرير التاريخ. + - رسائل صوتية تصل إلى 5 دقائق.\n- الوقت المخصص لتختفي.\n- تحرير التاريخ. يمكنك تفعيلة لاحقًا عبر الإعدادات يمكنك تفعيلها لاحقًا عبر إعدادات الخصوصية والأمان للتطبيق. عبر رابط المجموعة @@ -1350,9 +1346,9 @@ سيتم تحديث عبارة مرور تعمية قاعدة البيانات وتخزينها في الإعدادات. هل تريد إزالة عبارة المرور من الإعدادات؟ استخدم عبارة مرور عشوائية - حفظ عبارة المرور في الإعدادات - إعداد كلمة المرور لقاعدة البيانات - تعيين عبارة مرور قاعدة البيانات + احفظ عبارة المرور في الإعدادات + إعداد عبارة المرور لقاعدة البيانات + عيّن عبارة مرور قاعدة البيانات افتح مجلد قاعدة البيانات سيتم تخزين عبارة المرور في الإعدادات كنص عادي بعد تغييرها أو إعادة تشغيل التطبيق. يُخزين عبارة المرور في الإعدادات كنص عادي. @@ -1365,9 +1361,7 @@ اكتشاف والانضمام إلى المجموعات العربية والبلغارية والفنلندية والعبرية والتايلاندية والأوكرانية - شكرًا للمستخدمين و Weblate. إنشاء ملف تعريف جديد في تطبيق سطح المكتب. 💻 - - الاتصال بخدمة الدليل (تجريبي)! -\n- إيصالات التسليم (ما يصل إلى 20 عضوا). -\n- أسرع وأكثر استقرارًا. + - الاتصال بخدمة الدليل (تجريبي)!\n- إيصالات التسليم (ما يصل إلى 20 عضوا).\n- أسرع وأكثر استقرارًا. افتح حدث خطأ أثناء إنشاء جهة اتصال للعضو أرسل رسالة مباشرة للاتصال @@ -1482,7 +1476,7 @@ تحقق من الرمز مع سطح المكتب مسح رمز QR من سطح المكتب إلغاء الحظر - - إشعار اختياريًا جهات الاتصال المحذوفة. \n- أسماء الملفات التعريفية بمسافات. \n- و اكثر! + - إشعار اختياريًا جهات الاتصال المحذوفة.\n- أسماء الملفات التعريفية بمسافات.\n- و اكثر! مسار الملف غير صالح لقد طلبت بالفعل الاتصال عبر هذا العنوان! إظهار وحدة التحكم في نافذة جديدة @@ -1596,7 +1590,7 @@ إلغاء حظر العضو للجميع؟ إلغاء الحظر للجميع حدث خطأ أثناء حظر العضو للجميع - حُظر %d رسالة من قبل المشرف + حُظرت %d رسالة من قبل المُدير محظور %s أُلغيت حظر %s حظرت %s @@ -1604,8 +1598,8 @@ محظور حظر للجميع حظر العضو للجميع؟ - محظور من قبل المشرف - محظور من قبل المشرف + محظور من قبل المُدير + محظور من قبل المُدير الرسالة كبيرة جدًا رسالة الترحيب طويلة جدًا ترحيل قاعدة البيانات قيد التقدم. @@ -1616,7 +1610,7 @@ متصفح الويب الافتراضي مطلوب للمكالمات. يُرجى تضبيط المتصفح الافتراضي في النظام، ومشاركة المزيد من المعلومات مع المطورين. حدث خطأ أثناء فتح المتصفح أرشفة و رفع - يمكن للمشرفين حظر عضو للجميع. + يمكن للمُدراء حظر عضو للجميع. ترحيل بيانات التطبيق جارِ أرشفة قاعدة البيانات سيتم تعمية جميع جهات الاتصال والمحادثات والملفات الخاصة بك بشكل آمن ورفعها في أجزاء إلى مُرحلات XFTP التي ضُبطت. @@ -1715,7 +1709,7 @@ كل الأعضاء يمكن للأعضاء إرسال روابط SimpleX. روابط SimpleX محظورة. - المشرفين + المُدراء مفعّل لـ المالكون الملفات والوسائط غير مسموح بها @@ -1850,8 +1844,7 @@ لا يمكن إرسال الرسالة تفضيلات الدردشة المحدّدة تحظر هذه الرسالة. التفاصيل - بدءًا من %s. -\nجميع البيانات خاصة بجهازك. + بدءًا من %s.\nجميع البيانات خاصة على جهازك.. أرسلت الإجمالي الحجم الملفات المرفوعة @@ -1861,14 +1854,14 @@ إصدار الخادم غير متوافق مع تطبيقك: %1$s. العضو غير نشط رسالة محوّلة - لا يوجد اتصال مباشر حتى الآن، يتم تحويل من قِبل المشرف. + لا يوجد اتصال مباشر حتى الآن، يتم تحويل من قِبل المُدير. امسح / ألصِق الرابط خوادم SMP المهيأة خوادم SMP أخرى خوادم XFTP المهيأة خوادم XFTP أخرى أظهِر النسبة المئوية - مُعطّل + مُعطَّل مستقرّ يتوفر تحديث: %s التمس التحديثات @@ -1879,7 +1872,7 @@ تذكر لاحقا تخطي هذه النسخة أُلغيت تنزيل التحديث - مُعطّل + مُعطَّل غير نشط معلومات الخوادم عرض المعلومات ل @@ -1916,7 +1909,7 @@ أخرى موّكل مؤمن - أرسِل الأخطاء + أرسل الأخطاء أُرسلت مباشرةً مُرسَل عبر الوكيل مشترك @@ -1976,9 +1969,9 @@ تمويه الوسائط متوسط ناعم - المكالمة + مكالمة اتصل - الرسالة + مراسلة افتح بحث الإعدادات @@ -2009,7 +2002,7 @@ يجب عليك السماح لجهات اتصالك بالاتصال حتى تتمكن من الاتصال بها. يُرجى الطلب من جهة اتصالك تفعيل المكالمات. حذف %d رسائل الأعضاء؟ - سيتم وضع علامة على الرسائل للحذف. سيتمكن المستلم/(المستلمون) من الكشف عن هذه الرسائل. + سيتم وضع علامة على الرسائل للحذف. سيتمكن المُستلم/(المُستلمون) من الكشف عن هذه الرسائل. حدد سيتم حذف الرسائل لجميع الأعضاء. سيتم وضع علامة على الرسائل على أنها تحت الإشراف لجميع الأعضاء. @@ -2049,8 +2042,7 @@ صفّر كافة التلميحات يُرجى التأكد من أن رابط SimpleX صحيح. الرابط غير صالح - %1$d خطأ في الملف: -\n%2$s + %1$d خطأ في الملف:\n%2$s فشل تنزيل %1$d ملف/ات. لم يتم تنزيل %1$d ملف/ات. نزّل @@ -2222,7 +2214,7 @@ أو استورد ملف الأرشيف لا توجد خدمة خلفية الإشعارات والبطارية - يمكن فقط لأصحاب الدردشة تغيير التفضيلات. + فقط مالكي الدردشة يمكنهم تغيير التفضيلات. الخصوصية لعملائك. الجوالات عن بُعد ادعُ للدردشة @@ -2236,9 +2228,94 @@ طلبت الاتصال يُرجى تقليل حجم الرسالة أو إزالة الوسائط ثم إرسالها مرة أخرى. يمكنك نسخ الرسالة وتقليل حجمها لإرسالها. - عندما يتم تفعيل أكثر من مُشغل واحد، لن يكون لدى أي منهم بيانات تعريفية لمعرفة من يتواصل مع من. + عندما يتم تفعيل أكثر من مُشغل واحد، لن يكون لدى أي منهم بيانات تعريفية لمعرفة مَن يتواصل مع مَن. سيتم تغيير الدور إلى %s. وسيتم إشعار الجميع في الدردشة. سيتم إرسال ملف تعريفك للدردشة إلى أعضاء الدردشة سوف تتوقف عن تلقي الرسائل من هذه الدردشة. سيتم حفظ سجل الدردشة. عن المُشغلين - \ No newline at end of file + توصلت SimpleX Chat وFlux إلى اتفاق لتضمين الخوادم التي تديرها Flux في التطبيق. + جارِ إعادة التفاوض على التعمية. + إصلاح الاتصال؟ + يتطلب الاتصال إعادة التفاوض على التعمية. + إصلاح + فعّل السجلات + خطأ في حفظ قاعدة البيانات + شطب + الاتصال غير جاهز. + القائمة + لا دردشات + لا توجد محادثات في القائمة %s. + لا توجد محادثات غير مقروءة + لم يتم العثور على أي محادثات + المفضلات + أضف القائمة + الكل + المجموعات + افتح باستخدام %s + أضف إلى القائمة + احذف + حذف القائمة؟ + حرّر + اسم القائمة... + يجب أن يكون اسم القائمة والرموز التعبيرية مختلفين لجميع القوائم. + احفظ القائمة + جهات الاتصال + خطأ في إنشاء قائمة الدردشة + الشركات + خطأ في تحميل قوائم الدردشة + سيتم إزالة جميع المحادثات من القائمة %s، وسيتم حذف القائمة + أنشئ قائمة + خطأ في تحديث قائمة الدردشة + الملحوظات + تغيير القائمة + تغيير الترتيب + خطأ في حفظ الإعدادات + خطأ في إنشاء التقرير + أنت والمشرفون فقط هم من يرون ذلك + بلاغ مؤرشف + لا يراه إلا المُرسِل والمُشرفين + أرشف + أرشف البلاغ + احذف البلاغ + بلّغ + ملف تعريفي غير لائق + إزعاج (spam) + سبب آخر + البلاغات + 1 بلاغ + بلاغات الأعضاء المؤرشفة + %d بلاغات + بلاغات الأعضاء + بلّغ عن المحتوى: سيراه مشرفو المجموعة فقط. + بلّغ عن أُخرى: سيراه مشرفو المجموعة فقط. + مشرف + بلاغ مؤرشف بواسطة %s + بلّغ عن ملف تعريف العضو: سيراه مشرفو المجموعة فقط. + انتهاك إرشادات المجتمع + محتوى غير لائق + بلّغ عن مخالفة: سيراه مشرفو المجموعة فقط. + بلّغ عن إزعاج (spam): سيراه مشرفو المجموعة فقط. + أرشفة البلاغ؟ + سبب الإبلاغ؟ + سيتم أرشفة البلاغ لك. + إزعاج (spam) + نعم + اسأل + لا + افتح الرابط + فتح رابط الويب؟ + حُظر الاتصال + افتح الروابط من قائمة الدردشة + المحتوى ينتهك شروط الاستخدام + حُظر الاتصال بواسطة مُشغل الخادم:\n%1$s. + حُظر الملف بواسطة مُشغل الخادم:\n%1$s. + الافتراضي %s + سنة واحدة + عطّل حذف الرسائل التلقائي + تعطيل حذف الرسائل التلقائي؟ + لن يتم حذف الرسائل الموجودة في هذه الدردشة أبدًا. + احذف رسائل الدردشة من جهازك. + عيّن اسم الدردشة… + لا يمكن التراجع عن هذا الإجراء - سيتم حذف الرسائل المُرسلة والمُستلمة في هذه الدردشة قبل التاريخ المحدّد. + تغيير حذف الرسائل التلقائي؟ + 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 ded58ac9b3..8add4ee6fb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2488,7 +2488,7 @@ Messages sent Messages received Details - Starting from %s.\nAll data is kept private on your device.. + Starting from %s.\nAll data is kept private on your device. Message reception Active connections Pending diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index c51b33e456..ed901927a5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -220,11 +220,11 @@ Грешка при свързване (AUTH) свързване… Промени адреса за получаване - Базата данни на чата е изтрита + Базата данни е изтрита Чатът работи Чатът е спрян - БАЗА ДАННИ НА ЧАТА - Базата данни на чат е импортирана + БАЗА ДАННИ + Базата данни е импортирана Потвърди новата парола… Потвърди актуализаациите на базата данни свързан @@ -338,7 +338,7 @@ Понижаване на версията на базата данни Актуализация на базата данни версията на базата данни е по-нова от приложението, но няма миграция надолу за: %s - групата изтрита + групата е изтрита Контактът е проверен създател Създай групов линк @@ -406,7 +406,7 @@ Идентификатори в базата данни и опция за изолация на транспорта. Изтрий адрес Изтрий адрес\? - ЦВЕТОВЕ НА ТЕМАТА + ЦВЕТОВЕ НА ИНТЕРФЕЙСА Създай Създай профил Изтрий изображение @@ -440,7 +440,7 @@ Изтрий сървър Не създавай адрес Име на профила: - двуслойно криптиране от край до край.]]> + Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения. Деактивирай потвърждениeто\? Активиране (запазване на промените) Активирай потвърждениeто\? @@ -491,7 +491,7 @@ Изтрий съобщенията Грешка при криптиране на базата данни Активиране на автоматично изтриване на съобщения\? - Вашата чат база данни не е криптирана - задайте парола, за да я защитите. + Вашата база данни не е криптирана - задайте парола, за да я защитите. Криптиране на база данни\? покана за група %1$s Изпратихте покана за групата @@ -567,13 +567,13 @@ Криптирана база данни Активирай TCP keep-alive Изчезващите съобщения са забранени в този чат. - Изчезващите съобщения са забранени в тази група. + Изчезващите съобщения са забранени. Изчезващи съобщения Въведи съобщение при посрещане…(незадължително) Съобщение при посрещане Грешка при свързване със сървъра С незадължително съобщение при посрещане. - Грешка при изтриване на чат базата данни + Грешка при изтриване на базата данни Грешка при промяна на настройката Грешка при създаване на групов линк Грешка при изтриване на групов линк @@ -597,10 +597,10 @@ Предстояща връзка със сървъра Вашият контакт трябва да бъде онлайн, за да осъществите връзката. \nМожете да откажете тази връзка и да премахнете контакта (и да опитате по -късно с нов линк). - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни Грешка: %s Грешка при запазване на файл - Грешка при импортиране на чат базата данни + Грешка при импортиране на базата данни Грешка при отстраняване на член Грешка при запазване на профила на групата Грешка при зареждане на SMP сървъри @@ -641,14 +641,14 @@ Файлът ще бъде получен, когато вашият контакт завърши качването му. Файлът ще бъде получен, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! Филтрирайте непрочетените и любимите чатове. - Членовете на групата могат да изпращат лични съобщения. + Членовете могат да изпращат лични съобщения. помощ ПОМОЩ Здравей, \nСвържи се с мен през SimpleX Chat: %s - Членовете на групата могат да добавят реакции към съобщенията. - Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа) - Членовете на групата могат да изпращат гласови съобщения. + Членовете могат да добавят реакции към съобщенията. + Членовете могат необратимо да изтриват изпратените съобщения. (24 часа) + Членовете могат да изпращат гласови съобщения. Дори когато е деактивиран в разговора. Бързо и без чакане, докато подателят е онлайн! Френски интерфейс @@ -668,8 +668,8 @@ Груповият профил се съхранява на устройствата на членовете, а не на сървърите. Скрий Забрани изпращането на изчезващи съобщения. - Файловете и медията са забранени в тази група. - Членовете на групата могат да изпращат файлове и медия. + Файловете и медията са забранени. + Членовете могат да изпращат файлове и медия. Скрити чат профили Допълнително намален разход на батерията Групово модериране @@ -693,7 +693,7 @@ Само вие можете да изпращате изчезващи съобщения. Само вашият контакт може да изпраща изчезващи съобщения. Забрани изпращането на изчезващи съобщения. - Членовете на групата могат да изпращат изчезващи съобщения. + Членовете могат да изпращат изчезващи съобщения. Невалиден QR код Невалиден линк! Неправилен код за сигурност! @@ -705,7 +705,7 @@ Покани приятели Скрий профила Как се използва форматирането - Може да се промени по-късно през настройките. + Как се отразява на батерията Незабавно Режим инкогнито Покани членове @@ -716,14 +716,14 @@ Покажи профила Как се използва курсив - Импортиране на чат база данни\? + Импортиране на база данни? Ако изберете да отхвърлите, подателят НЯМА да бъде уведомен. Изображение Изображението ще бъде получено, когато вашият контакт завърши качването му. Изображението е запазено в Галерия Изображението е изпратено Изображението ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! - Необратимото изтриване на съобщения е забранено в тази група. + Необратимото изтриване на съобщения е забранено. Подобрена конфигурация на сървъра Това може да се случи, когато: \n1. Времето за пазене на съобщенията е изтекло - в изпращащия клиент е 2 дена а на сървъра е 30. @@ -752,7 +752,7 @@ невалиден формат на съобщението Невалиден линк за връзка Скриване на контакт и съобщение - разрешете на SimpleX да работи във фонов режим в следващия диалогов прозорец. В противен случай известията ще бъдат деактивирани.]]> + Разрешете в следващия диалогов прозорец, за да получавате незабавно известия.]]> Незабавни известия Незабавни известия! Незабавните известия са деактивирани! @@ -765,7 +765,7 @@ Инсталирай SimpleX Chat за терминал Как работи Как работи SimpleX - Защитен от спам и злоупотреби + Защитен от спам Игнорирай Покани членове Необратимото изтриване на съобщения е забранено в този чат. @@ -791,7 +791,7 @@ Оценете приложението Уверете се, че адресите на WebRTC ICE сървъра са в правилен формат, разделени на редове и не са дублирани. Мрежа и сървъри - Мрежови настройки + Разширени настройки Порт порт %d Задължително @@ -852,7 +852,7 @@ Забрани реакциите на съобщенията. Моля, въведете предишната парола след възстановяване на резервното копие на базата данни. Това действие не може да бъде отменено. Голям файл! - Реакциите на съобщения са забранени в тази група. + Реакциите на съобщения са забранени. Ново в %s предлага %s предлага %s: %2s @@ -884,7 +884,7 @@ Получаващият адрес ще бъде променен към друг сървър. Промяната на адреса ще завърши, след като подателят е онлайн. Нов архив на база данни Стар архив на база данни - Някои не-фатални грешки са възникнали по време на импортиране - може да видите конзолата за повече подробности. + Някои не-фатални грешки са възникнали по време на импортиране: Съобщения Моля, въведете правилната текуща парола. Моля, съхранявайте паролата на сигурно място, НЯМА да можете да я промените, ако я загубите. @@ -955,7 +955,7 @@ Получаване на съобщения… Работи, когато приложението е отворено Simplex Chat услуга - SimpleX фонова услуга – използва няколко процента от батерията на ден.]]> + SimpleX работи във фонов режим вместо да използва push известия.]]> За да получавате известия, моля, въведете паролата на базата данни Влезте с вашите идентификационни данни Грешка при доставката на съобщението @@ -963,7 +963,7 @@ Най-вероятно този контакт е изтрил връзката с вас. Няма история Отвори конзолата - Отвори чат профилите + Промени чат профилите Моля, запомнете я или я съхранявайте на сигурно място - няма начин да възстановите загубена парола! Получено съобщение Отговори @@ -978,8 +978,8 @@ Разрешение е отказано! профилно изображение запазено място за профилно изображение - Протокол и код с отворен код – всеки може да оперира собствени сървъри. - Хората могат да се свържат с вас само чрез ликовете, които споделяте. + Всеки може да оперира сървъри. + Вие решавате кой може да се свърже с вас. Поверителността преосмислена Добави поверителна връзка Отвори @@ -1066,7 +1066,7 @@ Деактивиране на потвърждениe за доставка за групи\? Активиране за всички групи Изпращането на потвърждениe за доставка е разрешено за %d групи - Рестартирайте приложението, за да използвате импортирана чат база данни. + Рестартирайте приложението, за да използвате импортирана база данни. Тази група има над %1$d членове, потвърждениeто за доставка няма да се изпраща. СЪРВЪРИ %s: %s @@ -1173,14 +1173,14 @@ Напълно децентрализирана – видима е само за членовете. Транспортна изолация Благодарение на потребителите – допринесете през Weblate! - Хешът на предишното съобщение е различен. + Хешът на предишното съобщение е различен.\" Тествай сървърите Благодарение на потребителите – допринесете през Weblate! За да не се разкрива часовата зона, файловете с изображения/глас използват UTC. Този текст не е линк за връзка! Твърде много видеоклипове! Тази настройка се прилага за съобщения в текущия ви профил - За да се защити поверителността, вместо потребителски идентификатори, използвани от всички други платформи, SimpleX има идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. + За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. @@ -1189,8 +1189,8 @@ Докосни бутона Благодарим Ви, че инсталирахте SimpleX Chat! Запази и уведоми контактите - Ново поколение поверителни съобщения - Първата платформа без никакви потребителски идентификатори – поверителна по дизайн. + Бъдещето на комуникацията + Няма потребителски идентификатори. Системна Неправилно ID на следващото съобщение (по-малко или еднакво с предишното). \nТова може да се случи поради някаква грешка или когато връзката е компрометирана. @@ -1233,7 +1233,7 @@ Можете да споделите този адрес с вашите контакти, за да им позволите да се свържат с %s. Премахни от любимите ВИЕ - Вашата чат база данни + Вашата база данни Изчаква се получаването на изображението Изчаква се получаването на изображението Изчаква се получаването на видеото @@ -1245,7 +1245,7 @@ Гласово съобщение Гласово съобщение (%1$s) Гласово съобщение… - Гласовите съобщения са забранени в тази група. + Гласовите съобщения са забранени. непрочетено Добре дошли! Добре дошли %1$s! @@ -1275,8 +1275,7 @@ Вашият чат профил ще бъде изпратен на членовете на групата Вашият чат профил ще бъде изпратен \nдо вашия контакт - Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. -\nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. + Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. \nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. Актуализирането на настройките ще свърже отново клиента към всички сървъри. актуализиран профил на групата Видео @@ -1630,7 +1629,7 @@ Потвърди мрежовите настройки Грешка при изтеглянето на архива Подготвя се качване - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни Стартиране на чата Квантово устойчиво криптиране Миграция на данните от приложението @@ -1711,8 +1710,8 @@ Мрежова връзка SimpleX линкове Забранете изпращането на SimpleX линкове - Членовете на групата могат да изпращат SimpleX линкове. - SimpleX линкове са забранени в тази група. + Членовете могат да изпращат SimpleX линкове. + SimpleX линкове са забранени. Активирано за собственици Камера @@ -1749,4 +1748,525 @@ Профилни изображения Променете формата на профилните изображения Квадрат, кръг или нещо между тях. + %1$d файл(а) не бяха изтеглени. + %1$d файл(а) все още се изтеглят. + Приети условия + Приеми условията + %1$d файлова грешка(и):\n%2$s + %1$d файл(а) бяха изтрити. + Неуспешно изтегляне на %1$d файл(а). + %1$s съобщения не са препратени + приета покана + %1$d друга(и) файлова(и) грешка(и). + a + b + За операторите + Допълнителен акцент 2 + Няма сървъри за получаване на файлове. + Грешка при поверително рутиране + Без Tor или VPN вашият IP адрес ще бъде видим за тези XFTP релета:\n%1$s. + Препращане на %1$s съобщението(ята)? + Препращане на съобщенията… + Препращане на %1$s съобщения + Позволи понижаване + Когато IP-то е скрито + НЕ изпращайте съобщения директно, дори ако вашият или получаващият сървър не поддържат поверително рутиране. + За поверителното рутиране + Грешка при свързване към препращащият сървър %1$s. Моля, опитайте по-късно. + Покажи състоянието на съобщението + За да защити вашия IP адрес, поверително рутиране използва вашите SMP сървъри за доставяне на съобщения. + Препращане на съобщенията без файловете? + Неизвестни сървъри + ФАЙЛОВЕ + Показване на списъка на чатовете в нов прозорец + Системна + Тъмна + Черна + Нулиране на цветовете + То защитава вашия IP адрес и връзки. + Адресът на препращащия сървър е несъвместим с мрежовите настройки: %1$s. + Версията на препращащия сървър е несъвместима с мрежовите настройки: %1$s. + Препращащият сървър %1$s не успя да се свърже с получаващия сървър %2$s. Моля, опитайте по-късно. + Версията на получаващия сървър %1$s е несъвместима с препращащия сървър %2$s. + Грешка от получаващия сървър: %1$s + Адресът на сървъра е несъвместим с мрежовите настройки. + Съобщението е препратено + Все още няма директна връзка, съобщението е препратено от администратора. + Тема на приложението + Защитете вашия IP адрес от реле сървърите за съобщения, избрани от вашите контакти.\nАктивирайте в настройките *Мрежа и сървъри*. + Поверително рутиране на съобщенията 🚀 + Препращайте до 20 съобщения наведнъж. + Не сте свързани с тези сървъри. Поверителното рутиране се използва за доставяне на съобщенията до тях. + Грешен ключ или неизвестна връзка - най-вероятно тази връзка е изтрита. + Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения. + Препращащ сървър: %1$s\nГрешка: %2$s + Версията на сървъра е несъвместима с мрежовите настройки. + Защити IP адреса + ПОВЕРИТЕЛНО РУТИРАНЕ НА СЪОБЩЕНИЯ + Приложението ще поиска потвърждение за изтегляния от неизвестни файлови сървъри (с изключение на .onion сървъри или когато SOCKS прокси е активирано). + Грешка: %1$s + Изтегляне + Съобщенията бяха изтрити, след като ги избрахте. + Нищо за препращане! + Поверително рутиране + Незащитен + Изпращайте съобщения директно, когато IP адресът е защитен и вашият или получаващият сървър не поддържа поверително рутиране. + Изпращайте съобщения директно, когато вашият или получаващият сървър не поддържат поверително рутиране. + Винаги използвай поверително рутиране. + Използвай поверително рутиране с неизвестни сървъри. + Тема на профила + Цветен режим + Цветове за тъмен режим + Няма сървъри за поверително рутиране на съобщения. + Без Tor или VPN вашият IP адрес ще бъде видим за файловите сървъри. + Предупреждение за доставката на съобщението + НЕ използвайте поверително рутиране. + Цветове на чата + Грешка при препращане на съобщенията + Адресът на получаващия сървър %1$s е несъвместим с настройките на препращащия сървър %2$s. + Неизвестни сървъри! + Проблеми с мрежата - съобщението е отказано след много опити за изпращане. + Препращащ сървър: %1$s\nГрешка от получаващия сървър: %2$s + Може да копирате и намалите размера на съобщението, за да бъде изпратено. + Винаги + Не + Никога + Режим за рутиране на съобщения + Използвай поверително рутиране с неизвестни сървъри, когато IP адресът не е защитен. + Светла + Да + Тема на чата + Получен отговор + Изпратен отговор + Премахни изображението + Тапетен фон + Тапетен акцент + Добър ден! + Добро утро! + Запълване + Повтори + Мащаб + Моля, опитайте по-късно. + Файлът не е намерен - най-вероятно файлът е бил изтрит или отказан. + Грешка на файловия сървър: %1$s + Грешен ключ или неизвестен адрес на файлово парче - най-вероятно файлът е изтрит. + Файлов статус: %s + Всички профили + Започвайки от %s.\nВсички данни се съхраняват поверително на вашето устройство.. + Свържете отново сървъра, за да принудите доставката на съобщенията. Това използва допълнителен трафик. + Грешка при нулиране на статистиката + Сканирай / Постави линк + Конфигурирани XFTP сървъри + Размер на шрифта + Статус на съобщението: %s + Задай тема по подразбиране + Потвърдете файловете от неизвестни сървъри. + Изпратени съобщения + Грешка при стартиране на WebView. Актуализирайте системата си до новата версия. Моля, свържете се с разработчиците.\nГрешка: %s + Други XFTP сървъри + Мащабиране + Всички цветови режими + Светъл режим + С намален разход на батерията. + Файлова грешка + Статус на съобщението + Файлов статус + Нови чат теми + Безопасно получаване на файлове + Персийски потребителски интерфейс + Няма информация, опитайте да презаредите + Показване на информация за + Информация за сървърите + Файлове + Транспортни сесии + Грешка + Информация за опашката за съобщения + няма + информация за опашката на сървъра: %1$s\n\nпоследно получено съобщение: %2$s + Подробности + Повторно свързване на сървърите? + Предишни свързани сървъри + Грешка при повторното свързване на сървърите + Грешка при повторното свързване на сървъра + Повторно свързване на всички сървъри + Нулиране + Свалено + Избраните чат настройки забраняват това съобщение. + неактивен + Тъмен режим + Активни връзки + Предстоящи + Свързани сървъри + Общо + деактивирано + Свържете се отново с всички свързани сървъри, за да принудите доставката на съобщенията. Това използва допълнителен трафик. + Този линк е използван с друго мобилно устройство, моля, създайте нова линк на настолното устройство. + Свързан + Получени съобщения + Покритие на съобщенията + Сървъри през прокси + Нулиране на всички статистически данни + Нулиране на всички статистически данни? + Статистиката на сървърите ще бъде нулирана - това не може да бъде отменено! + Подробна статистика + Членът е неактивен + Съобщението може да бъде доставено по-късно, ако членът стане активен. + Не може да се изпрати съобщението + Покажи процент + Повторно свързване на сървъра? + Качено + Изпратени общо + Грешка при копиране + Адресът на сървъра е несъвместим с мрежовите настройки: %1$s. + Версията на сървъра е несъвместима с вашето приложение: %1$s. + Грешки + Статистика + Свързване + Изпратени съобщения + Отстраняване на грешки за доставка + Изпълване + Разширени настройки + Приложи към + Нулиране с темата за приложението + Нулиране с потребителска тема + Направете вашите чатове да изглеждат различно! + Текущ профил + Повторно свързване + Получени съобщения + Получени общо + Получени грешки + Моля, проверете дали мобилното и настолното устройство са свързани към една и съща локална мрежа и дали защитната стена на настолното устройство позволява връзката.\nМоля, споделете всички други проблеми с разработчиците. + Временна файлова грешка + Конфигурирани SMP сървъри + Други SMP сървъри + Подобрена доставка на съобщения + опити + изтекли + други + дубликати + Потвърден + Грешки при потвърждението + Връзки + Започвайки от %s. + XFTP сървър + Изпратени директно + Изпратени чрез прокси + Чрез прокси + SMP сървър + Изпрати грешки + други грешки + грешки при декриптиране + Грешки при изтриване + Създаден + Завършен + Изтрит + Защитен + Абониран + Абонаменти игнорирани + Абонаментни грешки + Качени парчета + Качени файлове + Грешки при качване + Размер + Парчета изтрити + Изтеглени парчета + Изтеглени файлове + Настройки + Деактивирано + Стабилен канал + Бета канал + Налична актуализация: %s + Изтегляне на актуализация на приложението, не затваряйте приложението + Създай + Много + Форма на съобщение + Позволи обаждания? + Обажданията са забранени! + Моля, поискайте вашия контакт да позволи обажданията. + Размазване за по-добра поверителност. + Изтрийте до 20 съобщения наведнъж. + Възпроизвеждане от чат списъка. + SimpleX протоколите, прегледани от Trail of Bits. + По-добра сигурност ✅ + %1$s съобщения се запазват + Запази разговора + Използвай различни прокси идентификационни данни за всяка връзка. + Грешка за запазване на прокси + Актуализацията на приложението е изтеглена + Обаждането на контакта не е позволено + Тече свързване с контакт, моля изчакайте или проверете по-късно! + Обаждането на груповия член е не е позволено + Системен режим + Архивирайте контактите, за да разговаряте по-късно. + Покани + Някои файлове не са били експортирани + Базата данни е експортирана + Невалиден линк + Моля, проверете, че SimpleX линкът е правилен. + Изтрий само разговора + SOCKS прокси + Автоматични актуализации на приложението + Запази и се свържи отново + Достъпен панел + Използвайте приложението с една ръка. + Свържете се с приятелите си по-бързо. + Контролирайте вашата мрежа + Изтеглете новите версии от GitHub. + Увеличете размера на шрифта. + По-добри обаждания + Превключете аудио и видео по време на разговора. + Няма филтрирани контакти + Вашите контакти + Можете да запазите експортирания архив. + Можете да мигрирате експортираната база данни. + Можете да го промените в настройките за Изглед. + TCP връзка + Състояние на връзката и сървърите. + Изтриване на %d съобщения на членовете? + Потвърди изтриването на контакта? + Потребителско име + Провери за актуализации + Изключено + Средно + Малко + Ъгъл + Опашка + Контактът е изтрит.. + Трябва да разрешите на вашия контакт да може да ви се обажда, за да можете и вие да се обаждате. + Постави линк + Връзката ви беше преместена към %s, но възникна неочаквана грешка при пренасочването ви към профила. + Сесия на приложението + Сървър + Изтегли %s (%s) + Пропусни тази версия + Провери за актуализации + БАЗА ДАННИ + Превключване на чат списъка: + Можете да изпращате съобщения до %1$s от архивираните контакти. + Достъпен панел + Изпращането на съобщения на груповия член не е налично + Грешка при стартиране на WebView. Уверете се, че сте инсталирали WebView и поддържаната архитектура е ARM64.\nГрешка: %s + Съобщенията ще бъдат изтрити - това не може да бъде отменено! + Съобщенията ще бъдат изтрити за всички членове. + Съобщенията ще бъдат маркирани като модерирани за всички членове. + Избрано %d + Нищо не е избрано + Съобщение + отвори + съобщение + обаждане + Контактът ще бъде изтрит - това не може да бъде отменено! + свързване + Изтрий без известие + Разговорът е изтрит! + Все още ще можете да видите разговора с %1$s в списъка с чатовете. + Сподели профил + Избери чат профил + Грешка при превключване на профил + Покани + Звукът е заглушен + Панели на приложението + Изпратете съобщение за да се активират обажданията. + търсене + видео + Контактът е изтрит! + Архивирани контакти + Достъпни панели + Отвори настройките на сървъра + Продължи + Отвори местоположението на файла + Инсталиране на актуализация + Напомни по-късно + Инсталирането е успешно + Моля, рестартирайте приложението. + Изтеглянето на актуализацията е отменено + Премахни архив? + Каченият архив на базата данни ще бъде окончателно премахнат от сървърите. + За да извършвате обаждания, разрешете да използва микрофона. Прекратете разговора и опитайте да се обадите отново. + Натиснете бутона за информация близо до адресното поле, за да разрешите използването на микрофона. + Отворете Safari Настройки / Уеб страници / Микрофон, след което изберете Позволи за localhost. + Нов интерфейс 🎉 + Нови медийни опции + Подобрен интерфейс + Избери + Съобщенията ще бъдат маркирани за изтриване. Получателят(ите) ще могат да разкрият тези съобщения. + Размазване на медия + Сървъри за съобщения + Ново съобщение + Сървъри за медия и файлове + Прокси идентификация + Използвай случайни идентификационни данни + Използвай различни прокси идентификационни данни за всеки профил. + Не използвай идентификационни данни с прокси. + Вашите идентификационни данни могат да бъдат изпратени некриптирани. + Парола + Уверете се, че прокси конфигурацията е правилна. + Нови идентификационни данни за SOCKS ще се използват всеки път, когато стартирате приложението. + Ще се използват нови SOCKS идентификационни данни за всеки сървър. + За да бъдете уведомени за новите версии, включете периодичната проверка за стабилни или бета версии. + Деактивиране + Нулирай всички съвети + Грешки при изтегляне + Сървърен адрес + Няма сървъри за съобщения. + Няма сървъри за получаване на съобщения. + За чат профил %s: + Грешки в конфигурацията на сървърите. + Недоставени съобщения + Връзката достигна лимита за недоставени съобщения, вашият контакт може да е офлайн. + Сподели SimpleX адресът в социалните мрежи. + Сигурност на връзката + За да се предпазите от подмяна на вашия линк, можете да сравните кодовете за сигурност на контакта. + За социалните мрежи + SimpleX адрес или еднократен линк? + Създай еднократен линк + Приложението защитава вашата поверителност, като използва различни оператори за всеки разговор. + Например, ако контактът ви получава съобщения чрез SimpleX Chat сървър, приложението ви ще ги достави чрез Flux сървър. + Сървърни оператори + Мрежови оператори + Преглед по-късно + Можете да конфигурирате операторите в Мрежа и сървъри. + Условията ще бъдат автоматично приети за активираните оператори на: %s. + Вашите сървъри + Условията ще бъдат приети на: %s. + Използвай сървърите + Използвай %s + %s.]]> + Преглед на условията + Условия за ползване + %s, приемете условията за използване.]]> + За изпращане + Добавени сървъри за съобщения + Използвай за файлове + - Отворете чата при първото непрочетено съобщение.\n- Преминете към цитирани съобщения. + Резервно рутиране на съобщения + Активирай логовете + Добавете членовете на вашия екип към разговорите. + задраскан + Продължи + Добави приятели + %s.]]> + Уеб страница + Условия, приети на: %s. + Дистанционни мобилни устройства + Предварително зададени сървъри + %s.]]> + Отвори промените + Грешка при актуализиране на сървъра + Сървърният протокол е променен. + Грешка при добавяне на сървър + Личните съобщения между членовете са забранени в този чат. + Персонализирана форма на съобщенията. + По-добри дати на съобщението. + Активиране на flux + за по-добра поверителност на метаданните. + Подобрена навигация в чата + Грешка при запазване на базата данни + Вашият профил ще бъде изпратен до членовете + %s.]]> + За получаване + Добавени медийни и файлови сървъри + %s.]]> + Връзката не е готова. + Оператор + Вижте актуализираните условия + Xiaomi устройства : моля, активирайте Autostart в системните настройки, за да работят известията.]]> + Няма съобщение + Или да се сподели лично + криптирани от край до край, с постквантова сигурност в директните съобщения.]]> + Без фонова услуга + Проверявай за съобщения на всеки 10 минути + Приложението винаги работи във фонов режим + Известия и батерия + Условията ще бъдат приети за активираните оператори след 30 дни. + Можете да конфигурирате сървърите през настройките. + Актуализация + %s.]]> + Използвай за съобщения + Личните съобщения между членовете са забранени. + %1$s.]]> + SimpleX адресът и еднократните линкове за връзки са безопасни за споделяне чрез всеки месинджър. + Можете да зададете име на връзка, за да запомните с кого е споделена. + Сървърът е добавен към оператор %s. + Грешка при актуализирането на чат списъка + Грешка при създаването на чат списъка + Това съобщение е изтрито или не е получено. + Грешка при зареждането на чат списъка + Няма чатове в списъка %s. + Няма непрочетени чатове + Докосни Създаване на SimpleX адрес в менюто, за да го създадете по-късно. + Няма чатове + Няма намерени чатове + Любими + Контакти + Групи + Добави списък + Отвори с %s + Поправи + В ход е предоговаряне на криптирането. + Създай списък + Добави към списъка + Запази списъка + Името на списъка и емотиконите трябва да са различни за всички списъци. + Всички чатове ще бъдат премахнати от списъка %s и списъкът ще бъде изтрит + Редактирай + само с един контакт - споделете лично или чрез произволен месинджър.]]> + Адрес или еднократен линк? + Нов сървър + %s сървъри + Текстът на текущите условия не може да бъде зареден, можете да прегледате условията през този линк: + Сървърният оператор е променен. + Покана за чат + Чатът ще бъде изтрит за вас - това не може да бъде отменено! + Напусни чата + Изтрий чата + Изтрий чата? + Чатът ще бъде изтрит за всички членове - това не може да бъде отменено! + Членът ще бъде премахнат от чата - това не може да бъде отменено! + Чат + Преглед на условията + Сървърите за нови файлове от текущия ви профил + Поверителност за вашите клиенти. + Децентрализация на мрежата + Вторият предварително зададен оператор в приложението! + Бизнес чатове + Сподели адресът публично + Всички + Само собствениците на чат могат да променят настройките. + Изтрий списъка? + Списък + Изтрий + Бизнеси + Име на списъка... + Когато е активиран повече от един оператор, никои от тях няма метаданни, за да научи кой с кого комуникира. + Чатът вече съществува! + Ролята ще бъде променена на %s. Всички в чата ще бъдат уведомени. + Мрежов оператор + Сървър на оператора + Прозрачност + Изтриване или модериране до 200 съобщения. + Или импортирайте архивен файл + заявка за свързване + Грешка при запазване на сървърите + Няма сървъри за медия и файлове. + Няма сървъри за изпращане на файлове. + Грешка при приемане на условия + Съобщението е твърде голямо! + Моля, намалете размера на съобщението и го изпратете отново. + Моля, намалете размера на съобщението или премахнете мултимедията и изпратете отново. + Бизнес адрес + Напусни чата? + Размазване + SimpleX Chat и Flux сключиха споразумение за включване на управлявани от Flux сървъри в приложението. + %s.]]> + %s.]]> + Отвори условията + Превключете чат профила за еднократен линк за връзка. + Поправи връзката? + Връзката изисква предоговаряне на криптирането. + Сподели еднократен линк за връзка с приятел + Настройки на адреса + Изберете мрежови оператори за използване. + Как помага за поверителността + Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. + Добави членове на екипа \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml new file mode 100644 index 0000000000..fa972b34fa --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -0,0 +1,2297 @@ + + + Afegiu servidors escanejant codis QR. + %1$s missatges no reenviats + Tots els missatges nous de %s s\'amagaran! + L\'aplicació només pot rebre notificacions quan s\'està executant, no s\'iniciarà cap servei en segon pla + El servei en segon pla sempre s\'executa: les notificacions es mostraran tan aviat com els missatges estiguin disponibles. + Color addicional + S\'ha demanat rebre la imatge + blocat + Tots els vostres contactes, converses i fitxers es xifraran de manera segura i es penjaran en trossos als relés XFTP configurats. + Sempre actiu + Identificador incorrecte de missatge + per a cada contacte i membre del grup.\nTingueu en compte: si teniu moltes connexions, el vostre el consum de bateria i trànsit pot ser substancialment més elevat i algunes connexions poden fallar.]]> + Hash incorrecte de missatge + L\'optimització de la bateria està activa, desactiva el servei en segon pla i les sol·licituds periòdiques de missatges nous. Podeu tornar-los a activar mitjançant la configuració. + intents + Blocar + %s té una versió no compatible. Si us plau, assegureu-vos que feu servir la mateixa versió als dos dispositius]]> + Trucada + L\'autenticació ha fallat + Autenticació no disponible + Tant tu com el teu contacte podeu enviar missatges de veu. + trucar + La trucada ja s\'ha acabat! + Es pot desactivar mitjançant la configuració: les notificacions encara es mostraran mentre l\'aplicació s\'executa.]]> + Color + El canvi d\'adreça s\'avortarà. S\'utilitzarà l\'adreça de recepció antiga. + Configuració avançada + Accepta automàticament les sol·licituds de contacte + Vols permetre els missatges de veu? + Mala adreça d\'ordinador + xifratge d\'extrem a extrem amb PFS, repudi i recuperació per intrusió.]]> + Tots els teus contactes romandran connectats. L\'actualització del perfil s\'enviarà als teus contactes. + Ja estem connectant! + Ja us uniu al grup! + Adjuntar + Identificador incorrecte de missatge + Cancel·la la vista prèvia del fitxer + S\'ha superat la capacitat: el destinatari no ha rebut missatges enviats anteriorment. + per a cada perfil de xat que tingueu a l\'aplicació.]]> + Afegeix un contacte: crea un enllaç d\'invitació nou o connecta\'t mitjançant un enllaç que has rebut.]]> + Tingueu en compte: utilitzar la mateixa base de dades en dos dispositius trencarà el desxifrat dels missatges de les vostres connexions, com a protecció de seguretat.]]> + Utilitza més bateria! L\'aplicació sempre s\'executa en segon pla: les notificacions es mostren a l\'instant.]]> + %s està en mal estat]]> + %s amb el motiu: %s]]> + %1$d missatge(s) moderat(s) per %2$s + blocat per l\'administrador + xifratge extrem a extrem resistent quàntic amb PFS, repudi i recuperació d\'intrusió.]]> + %1$d altre(s) error(s) del fitxer. + No es pot rebre el fitxer + Permet-ho al següent diàleg per rebre notificacions a l\'instant.]]> + No es pot inicialitzar la base de dades + Dispositius Xiaomi: activeu l\'inici automàtic a la configuració del sistema perquè les notificacions funcionin.]]> + Cancelar vista prèvia de la imatge + No es pot enviar el missatge + S\'ha demanat rebre el vídeo + Tornar + Càmera + Càmera no disponible + Cancelar + Cancel·la el missatge en directe + a dalt, aleshores: + Crea grup: per crear un grup nou.]]> + Escaneja el codi QR.]]> + Obre a l\'aplicació per a mòbils i després toca Connecta a l\'aplicació.]]> + Tots els missatges se suprimiran; això no es pot desfer! + Tots els missatges s\'eliminaran NOMÉS per a tu; això no es pot desfer! + cancel·la la vista prèvia de l\'enllaç + Es compartirà un nou perfil aleatori. + només amb un contacte: compartiu-lo en persona o mitjançant qualsevol missatger.]]> + escanejar el codi QR a la videotrucada o el vostre contacte pot compartir un enllaç d\'invitació.]]> + mostra el codi QR a la videotrucada o comparteix l\'enllaç.]]> + Contactes arxivats + Guia de l\'usuari.]]> + Configuració de xarxa avançada + Configuració avançada + Accedir als servidors mitjançant el servidor SOCKS al port %d? S\'ha d\'iniciar abans d\'activar aquesta opció. + per sessió + Tingueu en compte: els retransmissions de missatges i fitxers es connecten mitjançant un servidor intermediari SOCKS. Les trucades i l\'enviament de visualitzacions prèvies d\'enllaç utilitzen connexió directa.]]> + Permetre la versió anterior + Sempre + Utilitzeu sempre l\'encaminament privat. + Creació de l\'aplicació: %s + Aparença + S\'ha baixat l\'actualització de l\'aplicació + Versió + Versió de l\'aplicació: v%s + Beta + Tots els teus contactes romandran connectats. + Afegeix els membres del teu equip a les converses. + Acceptació automàtica + Adreça d\'empresa + negreta + trucada acabada %1$s + error de trucada + trucant… + trucada en curs + Càmera + Càmera i micròfon + Qualsevol pot allotjar servidors. + Bluetooth + xifrats d\'extrem a extrem, amb seguretat postquàntica als missatges directes.]]> + El millor per a la bateria. Només rebràs notificacions quan l\'aplicació s\'està executant (Sense servei en segon pla).]]> + Bo per a la bateria. L\'aplicació revisa els missatges cada 10 minuts. Podeu perdre trucades o missatges urgents.]]> + repositori GitHub.]]> + %1$s vol connectar amb tu mitjançant + Feu servir sempre el retransmisor + App always runs in background + trucada + trucada (no xifrada) + Trucades d\'àudio i vídeo + Trucades a la pantalla de bloqueig: + %1$d missatge(s) omés(os). + %1$d missatge(s) omés(os). + Respondre trucada + Àudio desactivat + Àudio activat + hash de missatge erroni + La trucada ha finalitzat + Trucada en curs + Còpia de seguretat de dades de l\'aplicació + Contrasenya de l\'aplicació + S\'ha cancel·lat l\'autenticació + Accepta imatges automàticament + S\'eliminaran totes les dades de l\'aplicació. + Es crea un perfil de xat buit amb el nom proporcionat i l\'aplicació s\'obre com de costum. + La contrasenya de l\'aplicació es substitueix per una contrasenya d\'autodestrucció. + APLICACIÓ + ICONA APLICACIÓ + Desenfocar els mitjans + TRUCADES + Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. + Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions. + No es pot accedir a Keystore per desar la contrasenya de la base de dades + Tingueu en compte: NO podreu recuperar ni canviar la contrasenya si la perdeu.]]> + No es poden convidar contactes! + ha blocat a %s + i %d esdeveniments més + acordant el xifratge… + acordant el xifratge per a %s… + autor + %1$s MEMBRES + No es pot convidar el contacte! + Tots els membres del grup romandran connectats. + Blocar per a tots + Blocar membre + Blocar membre? + Blocar membre per a tots? + Blocat per l\'administrador + No es pot trucar al contacte + Permetre trucades? + Trucades prohibides! + No es pot trucar al membre del grup + No es pot enviar missatges als membres del grup + %s.]]> + %s.]]> + %s.]]> + %s.]]> + S\'han afegit servidors multimèdia i de fitxers + S\'han afegit servidors de missatges + Tots els xats i missatges se suprimiran; això no es pot desfer! + Negre + Tema de l\'aplicació + Secundari addicional + Fons + Color addicional 2 + Tots els modes + sempre + Aplicar a + Barres d\'eines d\'aplicació + Desenfocar + Trucades d\'àudio/vídeo + \nDisponible en v5.1 + Permet trucades només si el vostre contacte ho permet. + Permet la desaparició de missatges només si el vostre contacte ho permet. + "Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores)" + Permet les reaccions als missatges només si el teu contacte les permet. + Permet missatges de veu només si el vostre contacte els permet. + Permet que els teus contactes afegeixin reaccions als missatges. + Permet que els teus contactes et truquin. + Permet als teus contactes eliminar de manera irreversible els missatges enviats. (24 hores) + Permet que els teus contactes enviïn missatges que desapareixen. + Permet que els teus contactes enviïn missatges de veu. + Tant tu com el teu contacte pots suprimir de manera irreversible els missatges enviats. (24 hores) + Tant tu com el teu contacte podeu enviar missatges que desapareguin. + Tant tu com el teu contacte podeu afegir reaccions als missatges. + Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores) + Permet enviar missatges directes als membres. + Permet suprimir de manera irreversible els missatges enviats. (24 hores) + Permet enviar missatges que desapareixen. + Permet enviar fitxers i mitjans. + Permet enviar missatges de veu. + Les trucades d\'àudio/vídeo estan prohibides. + Tant tu com el teu contacte podeu fer trucades. + Permet enviar enllaços SimpleX. + tots els membres + cancel·lat %s + Els administradors poden crear els enllaços per unir-se als grups. + Trucades d\'àudio i vídeo + Per perfil de xat (per defecte) o per connexió (BETA). + Totes les dades s\'esborren quan s\'introdueix. + Contrasenya de l\'aplicació + Missatges millorats + Unes quantes coses més + Xifratge dels fitxers locals nous (excepte vídeos). + Àrab, búlgar, finès, hebreu, tailandès i ucraïnès - gràcies als usuaris i Weblate. + Grups millorats + Blocar membres del grup + Migració de dades d\'aplicacions + Arxiva els contactes per xatejar més tard. + Desenfoca per a una millor privadesa. + Trucades millorades + Dates de missatges millorades. + Seguretat millorada ✅ + Millor experiència d\'usuari + Xats d\'empresa + (aquest dispositiu v%s)]]> + (nou)]]> + %s s\'ha desconnectat]]> + Usar des de l\'ordinador a l\'aplicació mòbil i escaneja el codi QR.]]> + %s està ocupat]]> + %s està inactiu]]> + %s no es troba]]> + %s s\'ha desconnectat]]> + Aplicar + Arxivar i carregar + Arxivant bases de dades + Cancel·la la migració + Migra des d\'un altre dispositiu al dispositiu nou i escaneja el codi QR.]]> + Tots els perfils + Avís: l\'arxiu s\'eliminarà.]]> + Reconegut + Errors de reconeixement + %1$d fitxer(s) no s\'ha(n) baixat. + %1$d missatge(s) no s\'ha(n) pogut desencriptar. + s\'està connectant + blocat + k + Connecta + error + vostè + EN DIRECTE + moderat + reenviat + desat + s\'està connectant… + Descripció + %1$d error(s) de fitxer:\n%2$s + Desconnecta + Permet + Autentica + Desbloca + Comparteix + Desa + Edita + Info + Cerca + Reenviat + Esborra + Mostra + Amaga + Modera + Selecciona + Expandeix + La baixada ha fallat per a %1$d fitxer(s). + %1$d fitxer(s) ha(n) estat eliminat(s). + esborrat + Ocult + Còpia + Lliurament + %1$d encara s\'estan baixant els fitxers. + Connecta + Error + Respon + Amaga + connectat + Atura + Connectat + Immediatament + Historial + Desat + Permet + Revoca + invitació acceptada + 30 segons + Accepta + Voleu acceptar la sol·licitud de connexió? + Acceptar incògnit + Quant a l\'adreça de SimpleX + Enllaç d\'un sol ús + Afegiu un contacte + Adreça o enllaç d\'un sol ús? + Afegiu servidors predefinits + Afegeix servidor + Quant a SimpleX + trucada acceptada + Accepta + 1 dia + 1 mes + 1 setmana + administrador + Afegiu-hi amics + Afegeix membres de l\'equip + Afegeix missatge de benvinguda + Adreça + Cancel·la el canvi d\'adreça + Accepta les condicions + Condicions acceptades + "Afegiu perfil" + Voleu cancel·lar el canvi d\'adreça? + 1 minut + Accepta + 6 noves llengües d\'interfície + Cancel·la + Quant a SimpleX Chat + Els administradors poden blocar un membre per a tothom. + Connexions actives + 5 minuts + Accepta + Quant als operadors + Afegiu l\'adreça al vostre perfil per tal que els vostres contactes puguin compartir-la amb la resta del món. L\'actualització del perfil s\'enviarà als vostres contactes. + Configuració d\'adreça + Afegeix a un altre dispositiu + administradors + Suprimir contacte? + Desapareixerà a: %s + L\'autenticació del dispositiu està desactivada. S\'està desactivant el bloqueig SimpleX. + L\'autenticació del dispositiu no està activada. Podeu activar SimpleX Lock mitjançant Configuració, un cop hàgiu activat l\'autenticació del dispositiu. + desactivat + %1$s.]]> + %1$s.]]> + L\'adreça del servidor de destinació de %1$s és incompatible amb la configuració del servidor de reenviament %2$s. + Suprimir perfil + %d hores + Esborrar per mi + Els missatges directes entre membres estan prohibits en aquest xat. + La versió del servidor de destinació de %1$s és incompatible amb el servidor de reenviament %2$s. + La migració de la base de dades està en curs. Pot trigar uns minuts. + %d missatge(s) bloquejat(s) + %d missatge(s) bloquejat(s) per l\'administrador + %d missatge(s) marcat(s) eliminat(s) + Error de desxifrat + Suprimir cua + Esborrar fitxer + SimpleX s\'executa en segon pla en lloc d\'utilitzar notificacions push.]]> + Desactivar notificacions + Ús de la bateria de l\'aplicació / Sense restriccions a la configuració de l\'aplicació.]]> + Ús de la bateria de l\'aplicació / Sense restriccions a la configuració de l\'aplicació.]]> + %d minuts + Desactiva el bloqueig SimpleX + Error del servidor de destinació: %1$s + Suprimir el missatge? + Suprimir %d missatges? + Suprimir %d missatges dels membres? + Suprimir el missatge del membre? + Error de descodificació + Eliminar i notificar el contacte + Suprimir contacte + Suprimir sense notificació + Desconnectat + Missatge que desapareix + Suprimir + Suprimir + Suprimir la connexió pendent? + Contrasenya i exportació de la base de dades + Suprimir servidor + Utilitza els hosts .onion a No si el servidor SOCKS no els admet.]]> + Desactivat + NO envieu missatges directament, encara que el vostre servidor de destinació no admeti l\'encaminament privat. + Suprimir l\'adreça? + Opcions de desenvolupador + Desactivar + Esborrar adreça + Suprimir imatge + El nom mostrat no pot contenir espais en blanc. + Descentralitzada + Desactivar + Desactivar per a tothom + Desactiva per a tots els grups + Desactivar (conservant anul·lacions) + Desactivar (mantenint les anul·lacions del grup) + Desactivar rebuts? + Desactivar rebuts per a grups? + Eines per a desenvolupadors + DISPOSITIU + La base de dades es xifra amb una contrasenya aleatòria. Si us plau, canvieu-la abans d\'exportar. + Contrasenya de la base de dades + Suprimir perfil? + Esborrar la base de dades + Suprimir tots els fitxers + Suprimir fitxers i mitjans? + Suprimir fitxers de tots els perfils + %d fitxer(s) amb una mida total de %s + Base de dades xifrada! + Suprimir missatges + La base de dades es xifra amb una contrasenya aleatòria, podeu canviar-la. + La contrasenya de xifratge de la base de dades s\'actualitzarà. + La contrasenya de xifratge de la base de dades s\'actualitzarà i s\'emmagatzemarà a la configuració. + La contrasenya de la base de dades és diferent de la desada al Keystore. + La contrasenya de la base de dades és necessària per obrir el xat. + La base de dades es xifrarà i la contrasenya s\'emmagatzemarà a la configuració. + La base de dades es xifrarà. + La base de dades es xifrarà i la frase de contrasenya s\'emmagatzemarà a Keystore. + La contrasenya de xifratge de la base de dades s\'actualitzarà i s\'emmagatzemarà a Keystore. + Actualització de la base de dades + La versió de la base de dades és més recent que l\'aplicació, però no hi ha cap migració cap avall per a: %s + migració diferent a l\'aplicació/base de dades: %s / %s + contacte eliminat + grup esborrat + %d esdeveniments de grup + %d contacte(s) seleccionat(s) + Suprimir xat + Suprimir xat? + Suprimir grup + Suprimir grup? + Suprimir enllaç + Suprimir enllaç? + Entrega de depuració + Suprimit + Suprimit: %s + Desapareixerà a + directe + inactiu + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s, accepteu les condicions d\'ús.]]> + Suprimir perfil? + Suprimir perfil + predeterminat (%s) + Suprimir per a tothom + Missatges directes + Missatges que desapareixen + Els missatges que desapareixen estan prohibits en aquest xat. + %dd + %d dia + %d dies + Suprimir en + %dh + %d hora + Els missatges directes entre membres estan prohibits. + Els missatges directes entre membres estan prohibits en aquest grup. + Els missatges que desapareixen estan prohibits. + %dm + %d min + %d mes + %d mesos + %dmes + No envieu l\'historial als nous membres. + Missatges que desapareixen + Diferents noms, avatars i aïllament del transport. + Descobreix i uneix-te a grups + Suprimir fins a 20 missatges alhora. + dies + Suprimeix o modera fins a 200 missatges. + Rebuts de lliurament! + Els rebuts de lliurament estan desactivats! + La versió de l\'aplicació d\'ordinador %s no és compatible amb aquesta aplicació. + Ordinadors + Dispositius + Desconnectar + Desconnectar l\'ordinador? + S\'ha desconnectat amb el motiu: %s + Desconnectar mòbils + Ordinador + Adreça ordinador + L\'ordinador té una versió no compatible. Si us plau, assegureu-vos que feu servir la mateixa versió als dos dispositius + L\'ordinador té un codi d\'invitació incorrecte + L\'ordinador està ocupat + L\'ordinador està inactiu + L\'ordinador ha estat desconnectat + Descobrible a través de la xarxa local + Descobriu a través de la xarxa local + %s]]> + %1$s!]]> + %1$s.]]> + %1$s.]]> + Suprimir la base de dades d\'aquest dispositiu + No heu d\'utilitzar la mateixa base de dades en dos dispositius. + Detalls + errors de desxifrat + Estadístiques detallades + Suprimides + Errors d\'eliminació + Mòbil connectat + Connectar en mode incògnit + connexió %1$d + Comprova si hi ha missatges cada 10 minuts durant un minut com a màxim + Canvia el codi d\'accés + Canvia el perfil + Confirmeu les credencials + Xats + Parleu amb els desenvolupadors + connecta + Confirmeu l\'eliminació del contacte? + Connectat + Confirma + Voleu suprimir les notes privades? + Botó de tancament + Connecta + Consola del xat + Comproveu l\'adreça del servidor de i torneu a provar. + Servidors SMP configurats + Configura els servidors ICE + Perfil del xat + Connexió + Comprova si hi ha actualitzacions + Comprova si hi ha actualitzacions + connectat + Comprova missatges cada 10 minuts + Canvia la modalitat de bloqueig + Canvia el mode l\'autodestrucció + Canvia el codi d\'autodestrucció + Confirmeu el codi d\'accés + BASE DE DADES DELS XATS + XATS + Tema del xat + Colors del xat + Base de dades suprimida + Base de dades importada + Base de dades exportada + Confirmeu el nou mot de pas… + Voleu canviar la frase de pas per a la base de dades? + connectat + connectat directament + canviant d\'adreça per %s… + connectat + complet + Canvia + Xat + Condicions acceptades el: %s. + Condicions d\'ús + Mode de color + Preferències del xat + Compareu els codis de seguretat amb els vostres contactes. + Estat de la connexió i dels servidors + Connectat a un mòbil + Pròximament! + Connecta automàticament + El xat ja existeix! + Xat migrat! + Comproveu la connexió a Internet i torneu a provar + Servidors connectats + Blocs eliminats + Blocs descarregats + Error en desar el fitxer + Canviar l\'adreça de recepció? + connectar amb els desenvolupadors de SimpleX Chat per fer qualsevol pregunta i rebre actualitzacions.]]> + Obre a l\'aplicació mòbil.]]> + Error en desar els servidors ICE + Error en desar el servidor intermediari + BASE DE DADES DELS XATS + El xat s\'està executant + El xat està aturat + Error: %s + El xat està aturat + El xat està aturat. Si ja heu utilitzat aquesta base de dades en un altre dispositiu, hauríeu de tornar-la a transferir abans d\'iniciar el xat. + ha canviat el rol de %s a %s + ha canviat el teu rol a %s + ha canviat d\'adreça per a tu + canviant d\'adreça… + canviant d\'adreça… + El xat se suprimirà; això no es pot desfer! + Canviar el rol del grup? + Canviar rol + Canvia l\'adreça de recepció + Error en desar el perfil del grup + Mòbil + Errors + Contacte verificat + El contacte i tots els missatges s\'eliminaran, l\'acció és irreversible! + connexió establerta + Error de connexió + Temps de connexió enhaurit + El contacte la existeix + Error de connexió (AUTH) + Nom del contacte + Contacte eliminat! + El contacte encara no s\'hi ha connectat! + Sol·licitud de connexió tramesa! + Connecta via enllaç + el contacte admet criptografia d\'extrem a extrem + el contacte no té criptografía d\'extrem a extrem + El contacte ha estat eliminat + Preferències del contacte + Connecteu amb els vostres amic més ràpidament. + Connexió interrompuda + Connexió aturada + Connexió finalitzada + Voleu connectar a través d\'un enllaç? + Connexions + Error en carregar l\'arxiu + Error en desar la configuració + Error en aturar el xat + Error en actualitzar el servidor + Introduïu la contrasenya + Error en iniciar el xat + Error en desar la contrasenya de l\'usuari + Introduïu la contrasenya correcta. + Introduïu la contrasenya… + Introduïu el nom del grup: + Error en verificar la contrasenya: + finalitzada + Introduïu la contrasenya + error en mostrar el contingut + error en mostrar el missatge + Error en desar els servidors SMP + Error en desar els servidors XFTP + Error en enviar el missatge + Errors en la configuració dels servidors. + Error en canviar el perfil! + Error en actualitzar la configuració de la xarxa + Error en establir l\'adreça + Error en sincronitzar la connexió + Error en actualitzar la privadesa de l\'usuari + Error en mostrar la notificació, contacteu amb els desenvolupadors. + Introduïu el codi + Error en canviar el perfil + Introduïu el servidor manualment + Error en enviar la invitació + Error en actualitzar l\'enllaç del grup + Fins i tot quan està desactivat a la conversa. + Continua + Continua + Continua + Icona contextual + El contacte serà eliminat; l\'acció no es pot desfer! + Conversa eliminada! + Copiat al porta-retalls + Contribuïu + Contactes + Error en restablir les estadístiques + Error + Error en reconnectar els servidors + Introduïu el missatge de benvinguda… (opcional) + Introduïu el vostre nom: + Error en obrir el navegador + Error en exportar la base de dades de xat + Error en suprimir la base de dades de xat + Error en importar la base de dades de xat + Error en canviar la configuració + Error en eliminar el membre + Error en bloquejar el membre per a tots + Error en canviar el rol + Error en afegir servidor + Error en suprimir la base de dades + Error en reconnectar el servidor + Error en xifrar la base de dades + Error en exportar la base de dades de xat + Blocs penjats + El xat se suprimirà per a tots els membres; això no es pot desfer! + acolorit + Feu clic al botó d\'informació al costat del camp d\'adreça per permetre l\'ús del micròfon. + Completades + Error en acceptar les condicions + Error en connectar-se al servidor de reenviament %1$s. Si us plau, prova-ho més tard. + Error en crear el missatge + Error en crear el perfil! + Error en reenviar els missatges + Error en carregar els servidors SMP + Error en carregar els servidors XFTP + Error en afegir membre(s) + Error en crear l\'adreça + Error en unir-se al grup + Error en carregar els detalls + Error en rebre el fitxer + Error en acceptar la sol·licitud de contacte + Error en suprimir el contacte + Error en suprimir el grup + Error en cancel·lar el canvi d\'adreça + Error en canviar l\'adreça + Error en suprimir la sol·licitud de contacte + Error en suprimir la connexió de contacte pendent + Error en suprimir les notes privades + Error en suprimir el perfil d\'usuari + Comparar el fitxer + Error: %1$s + Error + Trieu un fitxer + Netejar + Netejar + Netejar xat + Netejar xat? + Netejar verificació + Error en inicialitzar WebView. Assegureu-vos que teniu WebView instal·lat i que sigui suportada l\'arquitectura arm64. Error: %s + Error en inicialitzar WebView. Actualitzeu el vostre sistema a la nova versió. Poseu-vos en contacte amb els desenvolupadors. Error: %s + Netejar + Error en crear l\'enllaç del grup + Error en crear el contacte del membre + Error en suprimir l\'enllaç del grup + Introduïu el missatge de benvinguda… + Interfície en xinès i espanyol + Error en habilitar els rebuts de lliurament. + Introduïu el nom d\'aquest dispositiu… + Error + Error en baixar l\'arxiu + Crea una adreça + Crea + Crea un grup tot fent servir un perfil aleatori. + Perfil actual + Crea un enllaç d\'un sol ús + No crear cap adreça + Crea + Crea perfil + Cantonada + Frase de pas actual… + Crea un enllaç + Crea un grup secret + No tornis a mostrar + Tema fosc + Creat + Crea un grup + Crea un enllaç de group + Errada crítica + personalitzat + Mode fosc + Crea una cua + Crea un fitxer + Codi d\'accés actual + Ara per ara, la mida màxima per als fitxers és %1$s. + Crea un enllaç d\'invitació d\'un sol ús + Crea un grupo secreto + Temps personalitzat + Crea un perfil de xat + Creeu una adreça perquè la gent pugui connectar amb vós. + Crea una adreça SimpleX + Crea perfil + creador + ID de la base de dades + ID de la base de dades: %d + (actual) + Fosc + Fosc + Creeu el vostre perfil + editat + SimpleX Chat i Flux han acordat incloure els servidors operats per Flux a l\'aplicació. + Voleu compartir l\'adreça de SimpleX o un enllaç d\'un sol ús? + Configuració + Tanca + s\'està connectant… + Les condicions s\'acceptaran per als operadors habilitats després de 30 dies. + SimpleX no pot funcionar en segon pla. Només rebreu les notificacions quan obriu l\'aplicació. + Trucades de SimpleX chat + Missatges de xat de SimpleX + enviat + per llegir + Benvinguts! + Envia + Envia + Restableix + Fitxer + Imatge + Vídeo + Silencia + Activa el so + Favorit + Configuració + ajuda + Correu electrònic + Adreça de SimpleX + Més + Compartir els enllaços d\'un sol ús i les adreces de SimpleX és segur a través de qualsevol mitjà. + Enganxa + Desa + Torna-ho a provar + Adreça de SimpleX + Desa + Nom d\'usuari + Contrasenya + Amfitrió + Port + No + Requerit + Amb IP desprotegida + Mai + + No + Estable + Mostra: + Amaga: + SimpleX + La seguretat de SimpleX chat ha estat auditada per Trail of Bits. + Parlem a SimpleX Chat + El nom no és vàlid! + cursiva + Crea una connexió privada + Trucada d\'àudio entrant + Codi d\'accés incorrecte + Bloca després + Mode de blocatge + Mode d\'incògnit + inactiu + Mode clar + Grups d\'incògnit + MEMBRE + Voleu unir-vos al grup? + Surt + Voleu sortir del xat? + Voleu sortir del grup? + Invitació caducada! + Convida membres + convidat + ha sortit + Protegeix la vostra adreça IP i les vostres connexions. + So de trucada + Videotrucada entrant + indirecte (%1$s) + invitació al grup %1$s + Convida + Versió incompatible + Interfície en lituà + Error intern + Augmenta la mida de la lletra. + Nom local + Enllaç no vàlid + Convida + Convida amics + Interfície en italià + Ruta de fitxer no vàlida + dades no vàlides + En resposta a + Descarrega + Membre inactiu + Conserva la conversa + Marcar com ha llegit + Enllaç no vàlid + El codi QR no és vàlid + Més informació + El codi QR no és vàlid + Codi de seguretat incorrecte! + L\'adreça del servidor no és vàlida! + Ajuda sobre Markdown + Markdown en els missatges + Assegureu-vos que la configuració del servidor intermediari és correcta. + Instal·la l\'actualització + "Instal·lació completada" + Mitjà + La versió de la base de dades és incompatible + ha convidat %1$s + ha sortit + membre + Convida al grup + Surt del xat + Surt del grup + Incògnit + El mode d\'incògnit protegeix la vostra privacitat usant un perfil aleatori nou per a cada contacte. + Clar + Clar + Els membres poden enviar missatges directes. + Els membres poden enviar fitxers i multimèdia. + Corregir el nom a %s? + connectant… + Trucada amb xifratge d\'extrem a extrem + Videotrucada amb xifratge d\'extrem a extrem + xifrat d\'extrem a extrem + Activa les trucades des de la pantalla de bloqueig mitjançant Configuració. + missatge duplicat + Activa bloqueig + Activar (conservar anul·lacions) + Activa (mantenir les anul·lacions de grup) + Activar la supressió automàtica de missatges? + Creat a + Connectar-se directament? + Connexió + El text de les condicions actuals no s\'ha pogut carregar, podeu revisar les condicions mitjançant aquest enllaç: + Trucada en connexió + Baixant l\'arxiu + %d setmana + Confirmeu la contrasenya + connectant (presentat) + connectant (anunciat) + connectant (invitació de presentació) + %ds + %d setmanes + Baixa noves versions de GitHub. + Habilita Flux + Connectar amb l\'ordinador + Connectant + Errors de descàrrega + Confirmeu fitxers de servidors desconeguts. + Les condicions s\'acceptaran el dia: %s. + Connectades + Arxius descarregats + Descarregat + connectant (acceptat) + Ordinador connectat + Connectat amb l\'ordinador + trucada en connexió… + Creant un enllaç a l\'arxiu + Connectant amb l\'ordinador + - Connexió al servei de directoris (BETA)!\n- Confirmacions de lliurament (fins a 20 membres).\n- Més ràpid i més estable. + Habilita per a tothom + el contacte %1$s ha canviat a %2$s + El contacte permet + %ds + duplicats + Editar imatge + Activar + Habilita per a tots els grups + Habilita als xats directes (BETA)! + Copiar error + La baixada ha fallat + %d seg + Connectar mitjançant l\'adreça de contacte? + Conectar mitjançant enllaç d\'invitació? + Nom de visualització duplicat! + Descarregar fitxer + Contacte ocult: + %d segons + connectant… + Connectar amb %1$s? + Descarregar + La connexió requereix una renegociació del xifratge. + Connectar mitjançant enllaç/codi QR + Habilita l\'accés a la càmera + Seguretat de connexió + Creant enllaç… + Servidors XFTP configurats + No utilitzeu credencials amb servidor intermediari. + Versió del nucli: v%s + Personalitzar tema + NO utilitzeu l\'encaminament privat. + S\'està baixant l\'actualització de l\'aplicació, no la tanquis + Baixa %s (%s) + ID de bases de dades i opció d\'aïllament de transport. + Auriculars + Error de base de dades + Confirmeu les actualitzacions de la base de dades + Reducció de versió de la base de dades + Baixa la versió anterior i obri el xat + connectant + Editar perfil del grup + Creat a: %s + Connectant al contacte, si us plau, espereu o comproveu-ho més tard! + Les condicions s\'acceptaran automàticament per als operadors habilitats el dia: %s. + Colors en mode fosc + habilitat per al contacte + habilitat + habilitat per a tu + Els contactes poden marcar missatges per suprimir-los; els podreu veure. + Habilitat per + Personalitza i comparteix temes de color. + Temes personalitzats + Crea perfil nou a l\'aplicació per a ordinador. 💻 + Forma de missatge personalitzable. + No activeu + Connectar amb tu mateix? + Confirmeu la configuració de la xarxa + Confirmeu que recordeu la contrasenya de la base de dades per migrar-la. + La connexió a l\'ordinador és deficient + Confirmar la càrrega + Controla la teva xarxa + Baixant els detalls de l\'enllaç + Error en desar els servidors + Habilita els registres + Per a xarxes socials + Nom complet: + Sortir sense desar + Contrasenya de perfil amagada + Concedir permisos + Per exemple, si el vostre contacte rep missatges mitjançant un servidor SimpleX Chat, la vostra aplicació els lliurarà mitjançant un servidor Flux. + Gira la càmera + Activar codi d\'autodestrucció + Activar els rebuts? + Activar autodestrucció + Activar els rebuts per a grups? + FITXERS + EXPERIMENTAL + Exportar base de dades + Xifrar + Fitxer: %s + Grup + Arreglar connexió + Totalment descentralitzat: només visible per als membres. + Moderació de grups + Missatge de benvinguda als grups + Grup inactiu + La invitació de grup ja no és vàlida, el remitent l\'ha eliminada. + Grup no trobat! + es requereix renegociar el xifratge + grup esborrat + PER A CONSOLA + Arreglar connexió? + Correcció no suportada per membre del grup + Nom complet del grup: + Farciment + Encaix + Enllaços de grup + Perfils de xat ocults + Xifra els fitxers i els mitjans emmagatzemats + Ordinador trobat + El grup ja existeix! + Finalitzar la migració + expirats + xifratge acordat + Unió més ràpida i missatges més fiables. + Activar TCP keep-alive + S\'ha suprimit el fitxer o l\'enllaç no és vàlid + Error en desar la base de dades + xifratge ok + Penjar + Bon dia! + Preferències del grup + Fitxers + Correcció no suportada pel contacte + Arreglar + per a una millor privadesa de les metadades. + Bona tarda! + El fitxer exportat no existeix + Reenvia i desa missatges + Hola!\nConnecteu-vos amb mi mitjançant SimpleX Chat: %s + Característiques experimentals + La renegociació del xifratge ha fallat. + renegociació de xifratge permesa + Error de renegociació de xifratge + Enllaç complet + No s\'ha pogut carregar el xat + No s\'han pogut carregar els xats + Per al perfil de xat %s: + El servidor de reenviament %1$s no s\'ha pogut connectar al servidor de destinació %2$s. Si us plau, prova-ho més tard. + L\'adreça del servidor de reenviament és incompatible amb la configuració de xarxa: %1$s. + La versió del servidor de reenviament és incompatible amb la configuració de xarxa: %1$s. + L\'execució de la funció triga massa temps: %1$d segons: %2$s + Trucada finalitzada + Activar Bloqueig SimpleX + Servidor de reenviament: %1$s\nError: %2$s + Servidor de reenviament: %1$s\nError del servidor de destinació: %2$s + No s\'ha trobat el fitxer: el més probable és que s\'hagi suprimit o cancel·lat. + Error del servidor de fitxers: %1$s + Reenviat des de + Per a tothom + El fitxer s\'eliminarà dels servidors. + Reenviar + Reenviar %1$s missatges? + Reenviar missatges sense fitxers? + Reenviar missatge… + Reenviar missatges… + Arxius i mitjans prohibits! + Reenviant %1$s missatges + No es permeten fitxers ni suports + Fitxer + Error de fitxer + No s\'ha trobat el fitxer + Fitxer desat + El fitxer es rebrà quan el vostre contacte acabi de carregar-lo. + El fitxer es rebrà quan el vostre contacte estigui en línia, espereu o comproveu més tard! + Arreglar connexió? + Arreglar + Renegociació de xifratge en curs. + De la Galeria + Cerqueu aquest permís a la configuració d\'Android i concediu-lo manualment. + Concedir a la configuració + Donar permís(os) per fer trucades + Auriculars + Xifra fitxers locals + AJUT + Arxius i mitjans + Xifrar base de dades? + Base de dades xifrada + Invitació del grup caducada + perfil de grup actualitzat + xifratge acordat per a %s + xifratge correcte per a %s + renegociació de xifratge permesa per a %s + es requereix renegociar el xifratge per a %s + Amplieu la selecció de rols + El grup se suprimirà per a tots els membres; això no es pot desfer! + Enllaç de grup + El grup se suprimirà per tu; això no es pot desfer! + Estat del fitxer + Estat del fitxer: %s + El perfil del grup s\'emmagatzema als dispositius dels membres, no als servidors. + Per a l\'encaminament privat + xifratge correcte + Mida de la lletra + Arxius i mitjans + Els fitxers i els mitjans estan prohibits. + Interfície en francès + Ràpid i sense esperar fins que el remitent estigui en línia! + Per fi, els tenim! 🚀 + Ús de la bateria encara més reduït + Filtra els xats preferits i no llegits. + Trobar xats més ràpidament + Corregir el xifratge després de restaurar les còpies de seguretat. + Reenvia fins a 20 missatges alhora. + Finalitzar la migració a un altre dispositiu. + trucada perduda + Micròfon + Trucada perduda + Esborrany + Mòbils remots + Elimina + Expulsa + t\'ha expulsat + eliminat + Renegocia l\'encriptació + mesos + Migra aquí + Missatges enviats + Missatges rebuts + Expulsar membre + minuts + ha expulsat %1$s + moderat per %s + Error en l\'enviament del missatge + El missatge podrà ser lliurat si el membre esdevé actiu. + Missatge + El missatge és massa llarg! + missatge + MISSATGES I FITXERS + Missatges + Estat del missatge + Estat del missatge: %s + Missatge massa llarg + Menús i alertes + Eliminar la imatge + Els membres poden enviar missatges de veu. + Voleu desar les preferències? + Desa + Desa la frase de pas al Keystore + Desa la frase de pas i obre el xat + Desa i reconnecta + Repeteix + Grups més segurs + Repeteix la descàrrega + Repetir importació + Restaura la còpia de la base de dades + %s i %s + Restaura + Voleu restaurar la còpia de la base de dades? + Solament quan l\'app és oberta + Voleu revocar el fitxer? + Voleu renegociar l\'encriptació? + Desa els servidors + Missatge desat + Desa el perfil del grup + Esperant connexió mòbil: + Podeu amagar o silenciar un perfil d\'usuari; manteniu-lo premut per al menú. + Ja esteu connectant mitjançant aquest enllaç d\'un sol ús! + Pots compartir un enllaç o un codi QR; qualsevol es podrà unir al grup. No perdràs membres del grup si més tard el suprimeixes. + Podeu configurar servidors mitjançant la configuració. + Pots intentar-ho un altre cop. + La vostra base de dades de xat actual s\'ELIMINARÀ i SUBSTITUÏRÀ per la importada.\nAquesta acció no es pot desfer: el vostre perfil, contactes, missatges i fitxers es perdran de manera irreversible. + has compartit enllaç d\'un sol ús en mode incògnit + Repetir la sol·licitud de connexió? + Sense informació, prova de tornar a carregar + Pot passar quan:\n1. Els missatges van caducar al client d\'enviament al cap de 2 dies o al servidor després de 30 dies.\n2. No s\'ha pogut desxifrar el missatge, perquè tu o el teu contacte feien servir una còpia de seguretat de la base de dades antiga.\n3. La connexió s\'ha compromès. + Només dades de perfil local + Unir-se al teu grup? + El missatge es marcarà per suprimir-lo. Els destinataris podran revelar aquest missatge. + Nova experiència de xat 🎉 + Unir-te + Enganxar enllaç per connectar! + Arxiu de bases de dades antigues + Migrar des d\'un altre dispositiu + Les notificacions només es lliuraran fins que l\'aplicació s\'aturi! + Obrir canvis + el membre %1$s ha canviat a %2$s + Canvia l\'aspecte dels teus xats! + Màxim 40 segons, rebut a l\'instant. + Només tu pots suprimir missatges de manera irreversible (el teu contacte pot marcar-los per suprimir-los). (24 hores) + Obrir configuració del servidor + altres errors + - Notificació opcional als contactes suprimits.\n- Noms de perfil amb espais.\n- I més! + Pendents + Els missatges se suprimiran; això no es pot desfer! + Connexió de xarxa més fiable. + Nou dispositiu mòbil + - El xat s\'obre amb al primer missatge no llegit.\n- Desplaçaments fins als missatges citats. + Obriu Configuració de Safari / Llocs web / Micròfon i, a continuació, trieu Permetre localhost. + Servidor de l\'operador + O enganxeu l\'enllaç de l\'arxiu + O compartiu aquest enllaç de fitxer de manera segura + enviament fallat + L\'enviament de rebuts de lliurament s\'habilitarà per a tots els contactes. + Adreça del servidor + %s pujades + Connexió TCP + Mode d\'incògnit simplificat + El rol es canviarà a %s. Tots els participants del xat rebran una notificació. + Tancar? + Les notificacions deixaran de funcionar fins que torneu a iniciar l\'aplicació + Els teus contactes romandran connectats. + Compartir l\'adreça amb els contactes? + Compartir enllaç + Desa la configuració d\'acceptació automàtica + Deixar de compartir + O per compartir en privat + Podeu crear-la més tard + Podeu fer-lo visible per als vostres contactes de SimpleX mitjançant Configuració. + Nom del perfil: + Desa i notifica el contacte + El teu perfil actual + El teu perfil s\'emmagatzema al teu dispositiu i només es comparteix amb els teus contactes, els servidors SimpleX no poden veure\'l. + Desa i notifica els contactes + Desa i notifica als membres del grup + Amagar el perfil + Contrasenya per mostrar + Desa la contrasenya del perfil + Per revelar el vostre perfil ocult introduïu una contrasenya completa al camp de cerca de la pàgina Els vostres perfils de xat. + Tu controles el teu xat! + Pots utilitzar la sintaxi markdown per donar format als teus missatges: + Com utilitzar la sintaxis markdown + trucada rebutjada + iniciant… + resposta rebuda… + esperant resposta… + esperant confirmació… + confirmació rebuda… + Un navegador web predeterminat és necessari per a les trucades. Configura\'n un al sistema i comparteix més informació amb els desenvolupadors. + Privadesa redefinida + Tu decideixes qui es pot connectar. + Immune al correu brossa + Sense identificadors d\'usuari. + Per protegir la vostra privadesa SimpleX utilitza identificadors separats per a cadascun dels vostres contactes.. + Obrir SimpleX + Com funciona SimpleX + Només els dispositius client emmagatzemen perfils d\'usuari, contactes, grups i missatges. + Notificacions privades + Com afecta la bateria + Periòdic + Instantánea + Quan l\'aplicació s\'està executant + Sense servei de fons + L\'aplicació protegeix la teva privadesa utilitzant diferents operadors en cada conversa. + Quan hi ha més d\'un operador habilitat, cap d\'ells té metadades per saber qui es comunica amb qui. + Com ajuda a la privadesa + Revisar més tard + Actualitzar + Podeu configurar els operadors a la configuració: Xarxa i Servidors. + videotrucada (sense xifrar) + Ignorar + Rebutjar + Per fer trucades, permet utilitzar el micròfon. Finalitza la trucada i prova de tornar a trucar. + Videotrucada + El servidor de retransmissió només s\'utilitza si cal. Un tercer pot observar la vostra adreça IP. + Mostrar + Trucades + sense xifratge e2e + Obrir + mitjançant retransmissor + Desactivar vídeo + p2p + Activar vídeo + Trucada rebutjada + Trucada pendent + So silenciat + Missatges omesos + El hash del missatge anterior és diferent.\" + L\'identificador del missatge següent és incorrecte (menor o igual a l\'anterior).\nPot passar per algun error o quan la connexió està compromesa. + Informeu-ho als desenvolupadors. + Pot passar quan tu o el teu contacte feu servir la còpia de seguretat de la base de dades antiga. + L\'aplicació us demanarà que confirmeu les baixades de servidors de fitxers desconeguts (excepte .onion o quan el servidor intermediari SOCKS estigui habilitat). + Envia previsualitzacions d\'enllaços + Mostra els últims missatges + Enviar + Codi nou + Desactivat + Codi d\'accés + Codi d\'accés desat! + Codi d\'accés canviat! + Nou nom mostrat: + Codi d\'autodestrucció canviat! + Codi d\'autodestrucció activat! + L\'enviament de rebuts està desactivat per a %d contactes + L\'enviament de rebuts està habilitat per a %d contactes + L\'enviament de rebuts està habilitat per a %d grups + ENVIAR ELS REBUS DE LLIURAMENT A + L\'enviament de rebuts està desactivat per a %d grups + Reiniciar + SERVIDOR INTERMEDIARI SOCKS + Imatges de perfil + TEMES + Cua + Forma del missatge + EXECUTAR SIMPLEX + Usar des d\'ordinador + Base de dades de xat + Importar base de dades + Nou arxiu de bases de dades + Obrir la carpeta de la base de dades + Aturar SimpleX? + Atura SimpleX per exportar, importar o suprimir la base de dades de xat. No podreu rebre ni enviar missatges mentre el xat estigui aturat. + Estableix contrasenya per a exportar + Aturar + Reinicieu l\'aplicació per utilitzar la base de dades de xat importada. + Importar + Importar la base de dades de xat? + S\'han produït alguns errors no fatals durant la importació: + Aquesta acció no es pot desfer: el vostre perfil, contactes, missatges i fitxers es perdran de manera irreversible. + Aquesta acció no es pot desfer; els missatges enviats i rebuts anteriors al seleccionat se suprimiran. Pot trigar uns quants minuts. + Podeu migrar la base de dades exportada. + Podeu desar l\'arxiu exportat. + Desa la contrasenya a la configuració + Actualitzar + Error desconegut + Contrasenya incorrecta! + Aquest grup ja no existeix. + ha desblocat a %s + %s, %s i %d membres més connectats + has canviat d\'adreça de servidor per a %s + El membre s\'eliminarà del xat; això no es pot desfer! + El membre s\'eliminarà del grup; això no es pot desfer! + El rol es canviarà a %s. Tots els membres del grup seran avisats. + La sol·licitud de connexió s\'enviarà a aquest membre del grup. + El rol es canviarà a %s. El membre rebrà una nova invitació. + Estat de la xarxa + Operador + Lloc web + Per a enviar + Restablir valors predeterminats + Compte PING + Toqueu per activar el perfil. + Silenciat quan està inactiu! + Encara rebràs trucades i notificacions de perfils silenciats quan estiguin actius. + Només el vostre contacte pot suprimir missatges de manera irreversible (pots marcar-los per suprimir-los). (24 hores) + La supressió de missatges irreversible està prohibida en aquest xat. + Els vostres contactes poden permetre la supressió completa del missatge. + Eliminació irreversible del missatge + Amaga la pantalla de l\'aplicació a les aplicacions recents. + Millora de la privadesa i la seguretat + Configuració del servidor millorada + El(s) destinatari(s) veu(en) l\'actualització mentre l\'escriviu. + Missatges en directe + Els missatges enviats se suprimiran després del temps establert. + Esborrany de missatge + Conserva l\'últim esborrany del missatge, amb fitxers adjunts. + Aïllament de transport + Ara els administradors poden:\n- suprimir els missatges dels membres.\n- desactivar els membres (rol d\'observador) + Protegir els vostres perfils de xat amb contrasenya! + Admet bluetooth i altres millores. + Mitjançant protocol segur de resistència quàntica. + Enllaça aplicacions mòbils i d\'ordinador! 🔗 + Migrar a un altre dispositiu + Preparant càrrega + Sense connexió de xarxa + Sessions de transport + Assegurades + Usar l\'aplicació durant la trucada. + Desblocar per tothom + Els missatges es marcaran com a moderats per a tots els membres. + Utilitzar %s + Suprimir la contrasenya de Keystore? + Suprimir la contrasenya de la configuració? + Nova contrasenya… + Introduïu la contrasenya actual correcta. + Estableix contrasenya per a la base de dades + Estableix contrasenya + Actualitzar la contrasenya de la base de dades + La frase de contrasenya s\'emmagatzema a la configuració com a text pla. + L\'intent de canviar la contrasenya de la base de dades no s\'ha completat. + Error de base de dades desconegut: %s + Error en restaurar base de dades + Migracions: %s + Avís: podeu perdre algunes dades! + Podeu iniciar el xat mitjançant Configuració / Base de dades o reiniciant l\'aplicació. + Uneix-te d\'incògnit + Entrant al grup + Estàs convidat al grup. Uneix-te per connectar amb els seus membres. + T\'has unit a aquest grup. S\'està connectant amb l\'emissor de la invitació. + Deixaràs de rebre missatges d\'aquest xat. L\'historial de xat es conservarà. + Deixaràs de rebre missatges d\'aquest grup. L\'historial de xat es conservarà. + %s i %s connectats + %s, %s i %d membres + Obrir + %s, %s i %s connectats + adreça de contacte eliminat + foto de perfil eliminada + establir una nova foto de perfil + nova adreça de contacte + perfil actualitzat + has canviat d\'adreça de servidor + propietari + estat desconegut + Podeu compartir aquesta adreça amb els vostres contactes perquè es connectin amb %s. + Aquest grup té més de %1$d membres, no s\'envien rebuts d\'entrega. + Rebut + %s a les %s + Enviar missatge directe + Moderat el: %s + Rebut: %s + Desblocar membre + Desblocar membre? + Desblocar membre per tothom? + Rol + informació de cua del servidor: %1$s\n\núltim missatge rebut: %2$s + Informació de la cua de missatges + cap + Enviant via + El teu perfil de xat s\'enviarà als membres del xat + El teu perfil de xat s\'enviarà als membres del grup + Operador de xarxa + %s servidors + Servidors per a fitxers nous al vostre perfil de xat actual + Veure condicions + Obrir condicions + Servidor afegit a l\'operador %s. + Ha canviat l\'operador del servidor. + El protocol del servidor ha canviat. + Temps d\'espera de connexió TCP + Temps d\'espera del protocol + Temps d\'espera del protocol per KB + Concurrència a la recepció + Actualitzar la configuració de xarxa? + Actualitzar + Connexions de perfil i servidor + Mostrar + Silenciar + Activar so + Mostrar perfil + Restablir color + Restablir colors + Mode de sistema + no + desactivat` + Activat + Restablir al tema de l\'aplicació + Restablir al tema d\'usuari + Estableix tema predeterminat + Permeteu + Estableix preferències de grup + Preferències + Reaccions a missatge + Missatges de veu + Prohibir l\'enviament de fitxers i mitjans. + Enllaços SimpleX no permesos. + S\'envien fins a 100 darrers missatges als nous membres. + L\'historial no s\'envia als nous membres. + oferit %s + oferit %s: %2s + Novetats + Nou a %s + Llegeix més + Missatges de veu + Amb missatge de benvinguda opcional. + Noms de fitxers privats + Ús de bateria reduït + Gràcies als usuaris: contribuïu a través de Weblate! + Aviat hi haurà més millores! + Rebuts de lliurament de missatges! + Activa o desactiva l\'incògnit en connectar-te. + Per amagar missatges no desitjats. + Notes privades + La barra de cerca accepta enllaços d\'invitació. + Amb fitxers i mitjans xifrats. + Entrega de missatges millorada + Amb ús reduït de la bateria. + Migra a un altre dispositiu mitjançant el codi QR. + Trucades imatge-en-imatge + Xifratge resistent quàntic + Formar imatges de perfil + L\'autoria del missatge continua sent privada. + Quadrat, cercle o qualsevol forma intermèdia. + En connectar trucades d\'àudio i vídeo. + S\'habilitarà als xats directes! + Gestió de la xarxa + Nous temes de xat + Rebre fitxers amb seguretat + Entrega de missatges millorada + IU en persa + Amb ús reduït de la bateria. + Noves opcions de mitjans + Barres d\'eines de xat accessible + Fes servir l\'aplicació amb una sola mà. + Reprodueix des de la llista de xat. + Actualitza l\'aplicació automàticament + Descentralització de la xarxa + El segon operador preestablert a l\'aplicació! + Privadesa per als teus clients. + Veure condicions actualitzades + hores + setmanes + L\'enviament de rebuts de lliurament s\'habilitarà per a tots els contactes de tots els perfils de xat visibles. + Desenllaçar + Desenllaçar l\'ordinador? + Podeu activar-los més tard mitjançant la configuració de privadesa i seguretat de l\'aplicació. + Aquest enllaç s\'ha utilitzat amb un altre dispositiu mòbil; creeu-ne un de nou a l\'ordinador. + Només un dispositiu pot funcionar al mateix temps + Codi de sessió + Esperant ordinador… + Ordinadors enllaçats + Verifica el codi a l\'ordinador + S\'ha arribat al temps d\'espera durant la connexió a l\'ordinador + Ja heu sol·licitat connexió a través d\'aquesta adreça! + Repetir la sol·licitud d\'unió? + Grup obert + Ja t\'estàs unint al grup mitjançant aquest enllaç. + Informeu-ho als desenvolupadors:\n%s\n\nEs recomana reiniciar l\'aplicació. + O importar un fitxer d\'arxiu + Enganxa l\'enllaç de l\'arxiu + Reiniciar xat + Migrant + Preparant descàrrega + Importació fallida + Important arxiu + Aturant el xat + Migrar dispositiu + Per continuar, el xat s\'ha d\'aturar. + Carregant l\'arxiu + Suprimir l\'arxiu? + Repetir la càrrega + Pots intentar-ho un altre cop. + L\'arxiu de base de dades carregat s\'eliminarà permanentment dels servidors. + Migració completada + Verificar contrasenya + Altre + Info servidors + Mostrant info per a + Iniciat el %s\nLes dades es mantenen privades al vostre dispositiu.. + Total + No esteu connectats a aquests servidors. Per enviar missatges s\'usa l\'encaminament privat. + Reconnectar servidors? + Restablir + Restablir totes les estadístiques + Restablir totes les estadístiques? + Les estadístiques dels servidors es restabliran; això no es pot desfer! + Carregat + Missatges enviats + Missatges rebuts + Total rebuts + Total enviats + Iniciat el %s. + Errors de recepció + Reconnectar + Errors d\'enviament + altres + Intermediat + Enviat directament + Enviat mitjançant servidor intermediari + Subscrit + Errors de subscripció + Subscripcions ignorades + Fitxers carregats + Errors de càrrega + Utilitzar servidors + Rebuts desactivats + Reconnectar els servidors per forçar l\'entrega de missatges. Utilitza trànsit addicional. + Reconnectar servidor? + Reconnectar tots els servidors + Comproveu que el mòbil i l\'ordinador estiguin connectats a la mateixa xarxa local i que el tallafoc d\'escriptori permet la connexió.\nSi us plau, comparteix qualsevol altre problema amb els desenvolupadors. + Introduïu la contrasenya anterior després de restaurar la còpia de seguretat de la base de dades. Aquesta acció no es pot desfer. + Interval PING + Informeu-ho als desenvolupadors:\n%s + Encaminament de missatges privat 🚀 + Amagar + Reconnectar el servidor per forçar l\'entrega de missatges. Utilitza trànsit addicional. + Verificar contrasenya de la base de dades + Navegació millorada al xat + Recepció de missatges + Enllaç no vàlid + Unir-te al grup? + marcat eliminat + Obrint la base de dades… + Comproveu que l\'enllaç SimpleX sigui correcte. + l\'enviament de fitxers encara no està suportat + Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. + S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). + Usar perfil actual + Usar nou perfil incògnit + Error aplicació + Esteu connectat al servidor utilitzat per rebre missatges d\'aquest contacte. + El teu perfil s\'enviarà al contacte del qual has rebut aquest enllaç. + Heu compartit una ruta de fitxer no vàlida. Informeu-ne als desenvolupadors de l\'aplicació. + Us connectareu amb tots els membres del grup. + d\'incògnit mitjançant l\'enllaç de l\'adreça de contacte + d\'incògnit mitjançant l\'enllaç del grup + d\'incògnit mitjançant un enllaç d\'un sol ús + xat no vàlid + format de missatge no vàlid + convidat a connectar + L\'obertura de l\'enllaç al navegador pot reduir la privadesa i la seguretat de la connexió. Els enllaços SimpleX no fiables seran vermells. + Notes privades + la recepció de fitxers encara no està suportada + sol·licitada connexió + desat des de %s + Adreça de contacte SimpleX + Enllaç de grup SimpleX + Enllaços SimpleX + Aquest xat està protegit per xifratge d\'extrem a extrem. + Aquest xat està protegit per un xifratge d\'extrem a extrem resistent a la quàntica. + format de missatge desconegut + mitjançant %1$s + Mitjançant navegador + mitjançant enllaç d\'adreça de contacte + mitjançant enllaç de grup + mitjançant enllaç d\'un sol ús + has compartit un enllaç d\'un sol ús + Nom mostrat no vàlid! + Assegureu-vos que les adreces del servidor SMP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + Assegureu-vos que les adreces del servidor XFTP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + No hi ha servidors multimèdia ni de fitxers. + No hi ha servidors de missatges. + No hi ha servidors per a l\'encaminament de missatges privats. + No hi ha servidors per rebre fitxers. + No hi ha servidors per rebre missatges. + No hi ha servidors per enviar fitxers. + Comproveu la vostra connexió de xarxa amb %1$s i torneu-ho a provar. + Si us plau, prova-ho més tard. + Actualitzeu l\'aplicació i contacteu amb els desenvolupadors. + Error d\'encaminament privat + L\'adreça del servidor és incompatible amb la configuració de xarxa: %1$s. + La versió del servidor és incompatible amb la vostra aplicació: %1$s. + Aquest nom mostrat no és vàlid. Si us plau, trieu-ne un altre. + Ja teniu un perfil de xat amb el mateix nom mostrat Si us plau, trieu un altre nom. + Enllaç de connexió no vàlid + El remitent ha cancel·lat la transferència de fitxers. + Servidors desconeguts! + Ja esteu connectat a %1$s. + Comproveu que heu utilitzat l\'enllaç correcte o demaneu al vostre contacte que us n\'enviï un altre. + És possible que el remitent hagi suprimit la sol·licitud de connexió. + La connexió ha arribat al límit de missatges no lliurats, és possible que el vostre contacte estigui fora de línia. + Missatges no lliurats + A menys que el vostre contacte hagi suprimit la connexió o aquest enllaç ja s\'ha utilitzat, pot ser que sigui un error; si us plau, informeu-ho.\nPer connectar-vos, demaneu al vostre contacte que creï un altre enllaç de connexió i comproveu que teniu una connexió de xarxa estable. + Possiblement, l\'empremta digital del certificat a l\'adreça del servidor és incorrecta + El servidor requereix autorització per crear cues, comproveu la contrasenya + El servidor requereix autorització per carregar, comproveu la contrasenya + La prova ha fallat al pas %s. + Cua segura + Carrega fitxer + Notificacions instantànies + Notificacions instantànies! + Les notificacions instantànies estan desactivades! + Notificacions periòdiques + Notificacions periòdiques desactivades + L\'aplicació obté missatges nous periòdicament: utilitza un percentatge de la bateria al dia. L\'aplicació no utilitza notificacions push: les dades del vostre dispositiu no s\'envien als servidors. + Obriu la configuració de l\'aplicació + Sense trucades en segon pla + És possible que l\'aplicació es tanqui al cap d\'un minut en segon pla. + Cal contrasenya + Per rebre notificacions introduïu la contrasenya de la base de dades + Rebent missatges… + Servei de SimpleX Xat + La base de dades no funciona correctament. Toca per obtenir més informació + Videotrucada + Servei de notificacions + Vista prèvia de la notificació + Vista prèvia + Cíclic + Amaga el contacte i el missatge + Text del missatge + Nova sol·licitud de contacte + missatge nou + Mostrar el contacte i el missatge + Mostrar només el contacte + Sense codi d\'accés de l\'aplicació + Camp de codi d\'accés + Bloqueig SimpleX + Mode de Bloqueig SimpleX + Autenticació del sistema + Per protegir la vostra informació activeu SimpleX Lock.\nSe us demanarà que completeu l\'autenticació abans que aquesta funció estigui habilitada. + Activar + No has pogut ser verificat; si us plau, torna-ho a provar. + Recordeu-la o emmagatzemeu-la de manera segura: no hi ha manera de recuperar una contrasenya perduda! + Bloqueig SimpleX habilitat + Se us demanarà que us autentiqueu quan inicieu o reinicieu l\'aplicació després de 30 segons en segon pla. + Inicieu sessió amb la vostra credencial + Avís d\'entrega de missatge + El més probable és que aquest contacte hagi suprimit la connexió amb tu. + Cap missatge + Obrir consola de xat + Obrir pantalla de migració + Bloqueig SimpleX no habilitat! + Aturar SimpleX + Podeu activar el Bloqueig SimpleX mitjançant Configuració. + Aquest missatge s\'ha suprimit o encara no s\'ha rebut. + Problemes de xarxa: el missatge ha caducat després de molts intents d\'enviar-lo. + Clau incorrecta o connexió desconeguda: el més probable és que aquesta connexió s\'hagi suprimit. + L\'adreça del servidor és incompatible amb la configuració de la xarxa. + La versió del servidor és incompatible amb la configuració de la xarxa. + Clau incorrecta o adreça de bloc de fitxer desconeguda: el més probable és que el fitxer s\'hagi suprimit. + Missatge rebut + Missatge enviat + Sense historial + Desat des de + El(s) destinatari(s) no veu(en) de qui és aquest missatge. + Sense informació de lliurament + El missatge se suprimirà; això no es pot desfer! + Els missatges es marcaran per eliminar-los. Els destinataris podran revelar aquests missatges. + Els missatges s\'eliminaran per a tots els membres. + El missatge s\'eliminarà per a tots els membres. + El missatge es marcarà com a moderat per a tots els membres. + S\'aturarà la recepció del fitxer. + L\'enviament del fitxer s\'aturarà. + Aturar fitxer + Deixar de rebre fitxer? + Deixar d\'enviar el fitxer? + Missatge reenviat + Encara no hi ha connexió directa, el missatge el reenvia l\'administrador. + Revocar fitxer + enviament no autoritzat + Aquest text està disponible a la configuració + Benvingut %1$s! + unir-te com a %s + enviar missatge directe + Toca per iniciar un xat nou + Carregant xats… + No hi ha xats filtrats + Cerqueu o enganxeu l\'enllaç SimpleX + Toqueu per connectar + No tens cap xat + No hi ha cap xat seleccionat + Res seleccionat + Seleccionats %d + Res a reenviar! + Els missatges s\'han suprimit després de seleccionar-los. + Compartir fitxer… + Compartir mitjans… + Compartir missatge… + Les preferències de xat seleccionades prohibeixen aquest missatge. + Massa imatges! + Massa vídeos! + Només es poden enviar 10 imatges al mateix temps + Només es poden enviar 10 vídeos al mateix temps + La imatge no es pot descodificar. Si us plau, proveu amb una imatge diferent o contacteu amb els desenvolupadors. + El vídeo no es pot descodificar. Si us plau, prova amb un vídeo diferent o contacta amb els desenvolupadors. + ets observador + No pots enviar missatges! + Poseu-vos en contacte amb l\'administrador del grup. + Només els propietaris del grup poden activar fitxers i mitjans. + Desant %1$s missatges + Envia missatge directe per connectar + Enllaços SimpleX no permesos. + Si us plau, redueix la mida del missatge i torna a enviar-lo. + Si us plau, reduïu la mida del missatge o elimineu mitjans i torneu a enviar-lo. + Missatges de veu no permesos + Podeu copiar i reduir la mida del missatge per enviar-lo. + Imatge + Imatge enviada + Esperant la imatge + Esperant la imatge + Imatge desada a la Galeria + La imatge es rebrà quan el vostre contacte acabi de carregar-la. + La imatge es rebrà quan el vostre contacte estigui en línia, espereu o comproveu més tard! + Esperant el vídeo + Arxiu gran! + Vídeo enviat + El vídeo es rebrà quan el contacte acabi de pujar-lo. + El vídeo es rebrà quan el contacte estigui en línia, espereu o comproveu més tard! + Esperant el vídeo + El vostre contacte ha enviat un fitxer més gran que la mida màxima admesa actualment (%1$s). + Carregant el fitxer + Notificacions + obrir + Si us plau, espereu mentre es carrega el fitxer des del mòbil enllaçat + Error de fitxer temporal + Missatge de veu + Missatge de veu… + Missatge de veu (%1$s ) + Esperant el fitxer + Només suprimeix la conversa + Pendente + L\'adreça de recepció es canviarà per un servidor diferent. El canvi d\'adreça es completarà quan el remitent estigui en línia. + Estableix el nom del contacte… + Pots enviar missatges a %1$s des dels contactes arxivats. + Encara podeu veure la conversa amb %1$s a la llista de xats. + El xifratge funciona i el nou acord de xifratge no és necessari. Pot resultar en errors de connexió! + Renegociar + Veure codi de seguretat + Comprovar codi de seguretat + Enviar Missatge + Grava el missatge de veu + Heu de permetre que el vostre contacte enviï missatges de veu per poder-los enviar. + Missatge en directe! + cap detall + OK + Enllaç d\'invitació d\'un sol ús + Només els propietaris de grups poden activar els missatges de veu. + (només emmagatzemat pels membres del grup) + Permís denegat! + Demaneu al vostre contacte que habiliti l\'enviament de missatges de veu. + Envia un missatge en directe: s\'actualitzarà per al(s) destinatari(s) a mesura que l\'escrius + Envia un missatge que desapareix + Envia missatge en directe + Toqueu per escanejar + (per compartir amb el teu contacte) + Missatges de veu prohibits! + Gràcies per instal·lar SimpleX Xat! + Per iniciar un xat nou + Toqueu el botó + Si decideixes rebutjar, el remitent NO rebrà notificació. + Si heu rebut l\'enllaç d\'invitació de SimpleX Chat, podeu obrir-lo al vostre navegador: + Rebutjar + Per connectar-se mitjançant enllaç + vista prèvia de l\'enllaç + Marcar com a llegit + imatge de perfil + espai per a la imatge de perfil + Codi QR + Estableix el nom del contacte + La connexió que heu acceptat es cancel·larà! + El contacte amb qui has compartit aquest enllaç NO es podrà connectar! + Desfavorit + vol contactar amb tu! + Has convidat un contacte + El vostre contacte ha d\'estar en línia perquè la connexió es completi.\nPots cancel·lar aquesta connexió i eliminar el contacte (i provar-ho més tard amb un enllaç nou). + Mostrar codi QR + Aquest no és un enllaç de connexió vàlid! + Aquest codi QR no és un enllaç! + Et connectaràs al grup quan el dispositiu de l\'amfitrió estigui en línia. Espereu o comproveu més tard! + Et connectaràs quan s\'accepti la teva sol·licitud de connexió, si us plau, espera o consulta més tard! + Si no pots trobar-te en persona, mostra el codi QR en una videotrucada o comparteix l\'enllaç. + Enganxeu l\'enllaç que heu rebut per connectar amb el vostre contacte… + Compartir enllaç d\'un sol ús + Compartir enllaç d\'un sol ús amb un amic + Compartir adreça públicament + Comparteix l\'adreça SimpleX a les xarxes socials. + Per connectar-se, el vostre contacte pot escanejar el codi QR o utilitzar l\'enllaç de l\'aplicació. + Per protegir-vos de la substitució del vostre enllaç, podeu comparar els codis de seguretat de contacte. + Quan algú sol·liciti la connexió, pots acceptar-la o rebutjar-la. + Podeu definir el nom de la connexió per recordar amb qui s\'ha compartit l\'enllaç. + Pots compartir la teva adreça com a enllaç o codi QR; qualsevol es pot connectar amb tu. + S\'enviarà el teu perfil de xat\nal teu contacte + El teu perfil %1$s es compartirà. + Et connectaràs quan el dispositiu del teu contacte estigui en línia, si us plau, espera o consulta més tard! + Si més tard decideixes eliminar la teva adreça els contactes no es perdran. + Mantenir la invitació no utilitzada? + Nou xat + Missatge nou + No hi ha contactes filtrats + Enllaç d\'invitació d\'un sol ús + O escaneja el codi QR + O mostra aquest codi + Enganxar enllaç + Enganxar l\'enllaç rebut + Seleccioneu el perfil de xat + Compartir perfil + Compartir aquest enllaç d\'un sol ús + Toca per enganxar l\'enllaç + El codi QR que heu escanejat no és un enllaç de SimpleX. + El text enganxat no és un enllaç SimpleX. + Aquesta cadena no és un enllaç de connexió! + Podeu tornar a veure l\'enllaç d\'invitació als detalls de connexió. + La teva connexió s\'ha mogut a %s, però s\'ha produït un error inesperat en redirigir-te al perfil. + Marcar com a verificat + Escaneja el codi de seguretat des de l\'aplicació del teu contacte. + Contactes + Com utilitzar-lo + Servidors de fitxers i mitjans + Servidors de missatges + Nou servidor + Altres servidors SMP + Altres servidors XFTP + Servidor preestablert + Adreça predeterminada del servidor + Preguntes i idees + Contacta via email + La prova del servidor ha fallat! + Bloqueig SimpleX + %s no està verificat + %s està verificat + Alguns servidors han fallat la prova: + Provar servidor + Provar servidors + Servidors per a noves connexions del vostre perfil de xat actual + Utilitzar per a noves connexions + Utilitzar servidor + Perfils de xat + El teu servidor + L\'adreça del teu servidor + Configuració + La teva adreça SimpleX + Com + Com utilitzar els vostres servidors + Servidors ICE (un per línia) + Instal·lar SimpleX Chat per al terminal + Assegureu-vos que les adreces del servidor WebRTC ICE estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + Valoreu l\'aplicació + Restablir totes les pistes + Els servidors WebRTC ICE desats s\'eliminaran. + Mostrar percentatge + Estrela a GitHub + Utilitzar servidors SimpleX Xat? + Usant servidors SimpleX Xat. + Servidors ICE + Servidors SMP + Servidors XFTP + Xarxa i servidors + Autenticació d\'intermediari + Servidor intermediari SOCKS + Configuració SOCKS + Utilitzar credencials aleatòries + Utilitzar servidor intermediari SOCKS + Si confirmeu, els servidors de missatgeria podran veure la vostra adreça IP i el vostre proveïdor, a quins servidors us esteu connectant. + Les noves credencials de SOCKS s\'utilitzaran cada vegada que inicieu l\'aplicació. + S\'utilitzaran noves credencials SOCKS per a cada servidor. + Es necessitaran hosts .onion per a la connexió.\nTingueu en compte: no us podreu connectar als servidors sense l\'adreça .onion. + S\'utilitzaran amfitrions .onion quan estiguin disponibles. + No es faran servir hosts .onion + port %d + Encaminament privat + Servidor + Aïllament de transport + Actualitzar el mode d\'aïllament de transport? + Utilitza credencials de servidors intermediari diferents per a cada connexió. + Utilitza credencials de servidor intermediari diferents per a cada perfil. + Utilitzar connexió a Internet directa? + Utilitzar servidors .onion + Utilitzar servidor intermediari SOCKS? + Si disponibles + Les vostres credencials es podrien enviar sense xifrar. + COLORS DE LA INTERFÍCIE + Alternativa d\'encaminament de missatges + Mode d\'encaminament de missatges + Obrir ubicació del fitxer + Envieu missatges directament quan l\'adreça IP està protegida i el vostre servidor de destinació no admet l\'encaminament privat. + Enviar missatges directament quan el vostre servidor de destinació no admet l\'encaminament privat. + Mostrar l\'estat del missatge + simplexmq: v%s (%2s) + Ometre aquesta versió + Per protegir la vostra adreça IP l\'encaminament privat utilitza els vostres servidors SMP per enviar missatges. + Servidors desconeguts + Actualització disponible: %s + Utilitzar l\'encaminament privat amb servidors desconeguts. + Utilitzeu l\'encaminament privat amb servidors desconeguts quan l\'adreça IP no estigui protegida. + Amb IP oculta + Descàrrega d\'actualització cancel·lada + Si us plau, reinicieu l\'aplicació. + Recorda més tard + Mostrar opcions de desenvolupador + Mostrar errors interns + Mostrar trucades lentes d\'API + Per rebre notificacions sobre les noves versions activeu la comprovació periòdica de les versions Estable o Beta. + Compartir amb contactes + La plataforma de missatgeria i aplicacions que protegeix la vostra privadesa i seguretat. + El perfil només es comparteix amb els teus contactes. + No emmagatzemem cap dels vostres contactes o missatges (un cop lliurats) als servidors. + El vostre perfil, contactes i missatges lliurats s\'emmagatzemen al vostre dispositiu. + Obrir configuració + El futur de la missatgeria + Operadors de xarxa + Notificacions i bateria + La contrasenya aleatòria s\'emmagatzema a la configuració com a text pla.\nPodeu canviar-ho més tard. + Seleccioneu els operadors de xarxa que voleu utilitzar. + Operadors de servidor + Configura la contrasenya de la base de dades + Utilitzar contrasenya aleatòria + Obrir SimpleX Chat per acceptar la trucada + El servidor de retransmissió protegeix la vostra adreça IP, però pot veure la durada de la trucada. + Servidors WebRTC ICE + Servidors ICE + Privacitat i seguretat + Protegeix la pantalla de l\'aplicació + Protegir l\'adreça IP + Privacitat + Si introduïu aquesta contrasenya en obrir l\'aplicació, totes les dades de l\'aplicació s\'eliminaran de manera irreversible. + Si introduïu el vostre codi d\'autodestrucció mentre obriu l\'aplicació: + Estableix codi + Aquesta configuració és per al vostre perfil actual + Es pot canviar a la configuració de contacte i grup. + No + CONFIGURACIÓ + Tou + Fort + SUPORT SIMPLEX XAT + Connexió a la xarxa + ENCAMINAMENT DE MISSATGES PRIVAT + mai + No s\'han rebut ni enviats fitxers + Reinicieu l\'aplicació per crear un perfil de xat nou. + Aquesta acció no es pot desfer: se suprimiran tots els fitxers i mitjans rebuts i enviats. Les imatges de baixa resolució es mantindran. + Heu d\'utilitzar la versió més recent de la vostra base de dades de xat NOMÉS en un dispositiu, en cas contrari, podeu deixar de rebre els missatges d\'alguns contactes. + Aquesta configuració s\'aplica als missatges del vostre perfil de xat actual + La vostra base de dades de xat no està xifrada; definiu una contrasenya per protegir-la. + La frase de contrasenya s\'emmagatzemarà a la configuració com a text pla després de canviar-la o reiniciar l\'aplicació. + Contrasenya de la base de dades incorrecta + Heu d\'introduir la contrasenya cada vegada que s\'inicia l\'aplicació: no s\'emmagatzema al dispositiu. + Error en Keystore + Obrir xat + Si us plau, emmagatzemeu la contrasenya de manera segura, NO podreu accedir al xat si la perdeu. + Si us plau, emmagatzemeu la contrasenya de manera segura, NO la podreu canviar si la perdeu. + Confirmació de migració no vàlida + No s\'ha trobat la contrasenya a Keystore, introduïu-la manualment. Això pot haver passat si vau restaurar les dades de l\'aplicació mitjançant una eina de còpia de seguretat. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. + Barres d\'eines d\'aplicacions accessible + Barres d\'eines de xat accessible + Mostra la llista de xat en una finestra nova + Commuta la llista de xat: + Mostrar consola en finestra nova + Podeu canviar-la a la configuració de l\'aparença. + Actualitzar i obrir el xat + Toca per unir-te + Toca per unir-te d\'incògnit + T\'has unit a aquest grup + Has rebutjat la invitació del grup + Esteu utilitzant un perfil d\'incògnit en aquest grup; per evitar mostrar el vostre perfil principal no es permet convidar contactes + Has enviat la invitació del grup + convidat mitjançant l\'enllaç del vostre grup + ha actualitzat el perfil del grup + has canviat el rol de %s a %s + has blocat a %s + has canviat el teu rol a %s + has marxat + has foragitat a %1$s + has desblocat a %s + observador + xifratge extrem a extrem resistent quàntic + Rol inicial + Convidar a xatejar + Nou rol de membre + No hi ha contactes per afegir + Membre anterior %1$s + Ometre convidar membres + desconegut + Convidar membres + Cap contacte seleccionat + Esteu provant de convidar el contacte amb qui heu compartit un perfil d\'incògnit al grup en què feu servir el vostre perfil principal + Missatge de benvinguda + Només els propietaris del xat poden canviar les preferències. + Només els propietaris del grup poden canviar-ne les preferències. + Compartir adreça + Enviar rebuts + Enviat + Registre actualitzat + Moderat el + Registre actualitzat: %s + Enviat: %s + sense text + Eliminar membre? + %s: %s + Eliminar membre + Els missatges de %s es mostraran! + Demaneu al vostre contacte que habiliti les trucades. + Enviar un missatge per activar trucades. + Heu de permetre que el vostre contacte truqui per poder trucar-los. + Vista prèvia + Rebent via + Desa i actualitza el perfil del grup + SERVIDORS + Missatge de benvinguda + El missatge de benvinguda és massa llarg + Els teus servidors + Servidors preestablerts + Revisar condicions + Per a rebre + Utilitzar per a fitxers + Utilitzar per a missatges + Fes el perfil privat! + Permet tenir moltes connexions anònimes sense cap dada compartida entre elles en un únic perfil de xat. + El teu perfil aleatori + Mostrar perfil ocult + Quan comparteixes un perfil d\'incògnit amb algú, aquest perfil s\'utilitzarà per als grups als quals et conviden. + Tema + Importar tema + Error en importar tema + Assegureu-vos que el fitxer tingui la sintaxi YAML correcta. Exporta el tema per tenir un exemple de l\'estructura del fitxer del tema. + Missatge enviat + Resposta enviada + Títol + Missatge rebut + Resposta rebuda + Color imatge de fons + Color de fons + Transparència + Zoom + Enllaços SimpleX + Historial visible + desactivat + recepció no permesa + Establir 1 dia + Només tu pots enviar missatges que desapareixen. + Només el vostre contacte pot enviar missatges que desapareixen. + Prohibir l\'enviament de missatges de veu. + Només tu pots enviar missatges de veu. + Només el vostre contacte pot enviar missatges de veu. + Els missatges de veu estan prohibits en aquest xat. + Només tu pots afegir reaccions als missatges. + Només el vostre contacte pot afegir reaccions als missatges. + Les reaccions als missatges estan prohibides en aquest xat. + Només tu pots fer trucades. + Només el vostre contacte pot fer trucades. + Prohibir l\'enviament de missatges de veu. + Prohibir l\'enviament d\'enllaços SimpleX + Es prohibeix la supressió irreversible de missatges. + Els membres poden afegir reaccions als missatges. + Els membres poden suprimir de manera irreversible els missatges enviats. (24 hores) + Els membres poden enviar missatges que desapareixen. + Els membres poden enviar enllaços SimpleX. + Les reaccions als missatges estan prohibides. + Envia fins a 100 darrers missatges als nous membres. + Els missatges de veu estan prohibits. + propietaris + Múltiples perfils de xat + Gràcies als usuaris: contribuïu a través de Weblate! + Comprovar la seguretat de la connexió + Estableix el missatge que es mostra als nous membres! + Interfície d\'usuari en japonès i portuguès + Reaccions a missatges + Aviat hi haurà més millores! + Interfície en polonès + Codi d\'autodestrucció + Configureu-lo en lloc de l\'autenticació del sistema. + Gràcies als usuaris: contribuïu a través de Weblate! + Gràcies als usuaris: contribuïu a través de Weblate! + Gràcies als usuaris: contribuïu a través de Weblate! + Vídeos i fitxers de fins a 1 GB + - Missatges de veu fins a 5 minuts.\n- Temps personalitzat per a missatges temporals.\n- Historial d\'edició. + - Lliurament de missatges més estable.\n- Grups millorats.\n- I més! + Nova aplicació per a ordinador! + La doble comprovació que ens mancava! ✅ + Manté les vostres connexions + Fer desaparèixer un missatge + Unir-se a converses de grup + Historial recent i bot de directori millorat. + Canvia l\'àudio i el vídeo durant la trucada. + Canvia el perfil de xat per a invitacions d\'un sol ús. + Podeu activar-ho més tard mitjançant Configuració + Enllaçar un mòbil + Mòbils enllaçats + Nom d\'aquest dispositiu + Verificar codi al mòbil + Verificar connexió + El nom del dispositiu es compartirà amb el client mòbil connectat. + Aquest dispositiu + Opcions ordinador enllaçat + Cap mòbil connectat + No compatible! + Obrir port al tallafoc + Enganxar adreça d\'ordinador + Aleatori + Recarregar + Escaneja el codi QR des de l\'ordinador + Per permetre que una aplicació mòbil es connecti a l\'ordinador obriu aquest port al vostre tallafoc, si el teniu habilitat + Verificar connexions + Aquesta funció encara no està disponible. Prova la propera versió. + Aquest és el vostre enllaç d\'un sol ús! + Aquesta és la teva pròpia adreça SimpleX! + Confirmeu que la configuració de xarxa és correcta per a aquest dispositiu. + Servidors connectats prèviament + Servidors intermediats + Com funciona + IU en hongarès i turc + Codi d\'accés no canviat! + Protegiu la vostra adreça IP dels servidors de retransmissió de missatgeria escollits pels vostres contactes.\nActiva a la configuració de *Xarxa i servidors*. + Toqueu Crea adreça SimpleX al menú per crear-la més tard. + Per protegir la zona horària els fitxers d\'imatge/veu utilitzen UTC. + Desbloca + La càrrega ha fallat + Per verificar el xifratge d\'extrem a extrem amb el vostre contacte compareu (o escanegeu) el codi dels vostres dispositius. + L\'actualització de la configuració reconnectarà el client a tots els servidors. + Atenció: l\'inici del xat a diversos dispositius és incompatible i provocaria errors en el lliurament de missatges. + Canvia + Voleu desar les preferències? + Voleu deixar de compartir l\'adreça? + secret + Altaveu desactivat + Altaveu activat + Sense Tor o una VPN, el servidors de fitxers podran veure la vostra adreça. + Sistema + Autodestrucció + Codi d\'autodestrucció + Grups petits (màx. 20) + Alguns fitxers no han estat exportats + s + Mida + Voleu iniciar un xat? + Escala + + Selecciona + %s baixades + WiFi + Ethernet per cable + Estadistiques + Servidor XFTP + Avaluació de seguretat + Servidor SMP + segons + Inicia un xat + "Invitació d\'un sol ús per SimpleX" + Sense Tor o una VPN, la vostra adreça serà visible per als següents relays XFTP: %1$s. + Funció lenta + heu estat convidat a un grup + cerca + (escaneja o enganxa del porta-retalls) + Escaneja un codi QR + Comença una conversa nova + Logo de SimpleX + Equip SimpleX + Heu acceptat la connexió + Escaneja / Enganxa un enllaç + Escaneja codi + Codi de seguretat + Voleu desar els servidors? + Escaneja el codi QR del servidor + Servidors SMP + Servidors XFTP + Altaveu + VÓS + %s segon(s) + "Heu estat convidat a un grup" + %s connectat + codi de seguretat modificat + encriptació extrem a extrem estàndard + Selecciona contactes + vós : %1$s + %s (actual) + Voleu desar el missatge de benvinguda? + Sistema + Sistema + Sistema + Secundari + Protocols de SimpleX revisats per Trail of Bits. + Escaneja amb el mòbil + Vídeo + vídeo + Tema del perfil + Contrasenya del perfil + No es permeten trucades ni videotrucades. + No es permeten reaccions als missatges. + No es permet l\'enviament de missatges temporals. + No es permet l\'eliminació irreversible de missatges. + No es permeten reaccions als missatges. + No es permet l\'enviament de missatges directes als membres. + No es permet l\'enviament de missatges temporals. + L\'actualització del perfil s\'enviarà als vostres contactes. + a + b + ratllat + Suprimir en + Eliminar el perfil de xat per + La connexió no està preparada. + Error en crear la llista de xat + Error en carregar llistes de xat + Error en actualitzar les llistes de xat + Contactes + Preferits + Cap xat no llegit + Afegir llista + Tot + Negocis + Obrir amb %s + Sense xats + No s\'han trobat xats + Canviar llista + Eliminar + Grups + No hi ha cap xat a la llista %s. + Llista + El nom de la llista i l\'emoji haurien de ser diferents per a totes les llistes. + Notes + Afegir a la llista + Crear llista + Desar llista + Tots els xats s\'eliminaran de la llista %s i aquesta serà suprimida + Canviar ordre + Editar + Eliminar llista? + Nom de la llista... + Spam + Arxivar + Arxivar informe + Informar de spam: només ho veurà la moderació del grup. + Informar del perfil d\'un/a membre: només ho veurà la moderació del grup. + Informar de violació: només ho veurà la moderació del grup. + Informar de contingut: només ho veurà la moderació del grup. + Informar d\'altres: només ho veurà la moderació del grup. + informe arxivat per %s + Un altre motiu + Violació de les normes de la comunitat + Contingut inadequat + Perfil inadequat + Suprimir informe + L\'informe s\'arxivarà. + Informar + Spam + El contingut infringeix les condicions d\'ús + Connexió blocada + La connexió està bloquejada per l\'operador del servidor:\n%1$s. + Arxivar informe? + El fitxer està blocat per l\'operador del servidor:\n%1$s. + Motiu de l\'informe? + 1 informe + Informes de membres arxivats + %d informes + Informes de membres + Informes + Error en desar configuració + No + Obrir l\'enllaç web? + + Obrir enllaç + moderador + Preguntar + Obrir enllaços de la llista de xat + informes arxivats + Només ho veuen remitents i moderació + Només ho veieu vosaltres i moderació + Error en crear informe + Establir nom del xat… + Suprimir els missatges de xat del teu dispositiu. + Voleu canviar la supressió automàtica de missatges? + predeterminat (%s) + 1 any + Desactivar la supressió de missatges + Desactivar la supressió automàtica de missatges? + Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat abans del seleccionat se suprimiran. + Els missatges d\'aquest xat no se suprimiran mai. + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index c80d24f0bd..3d9b4a4f87 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1860,7 +1860,7 @@ Nastavené SMP servery Probíhá Části nahrány - %1$d chuba souboru(ů):\n%2$s + %1$d chyba souboru(ů):\n%2$s %1$d jiná chyba souboru(ů). Chyba přeposílaní zpráv Adresa serveru není kompatibilní s nastavením sítě. @@ -2041,4 +2041,5 @@ Pošlete zprávu pro povolení volání. Přijmout podmínky Přijaté podmínky - \ No newline at end of file + Přidat seznam + 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 be6896d932..a3fddd820c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -544,7 +544,7 @@ \n3. Die Verbindung wurde kompromittiert. Datenschutz & Sicherheit - Ihre Privatsphäre + Privatsphäre App-Bildschirm schützen Bilder automatisch akzeptieren Link-Vorschau senden @@ -585,20 +585,19 @@ Fehler beim Beenden des Chats Fehler beim Exportieren der Chat-Datenbank Chat-Datenbank importieren? - Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die importierte ERSETZT. -\nDiese Aktion kann nicht rückgängig gemacht werden! Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die importierte ERSETZT.\nIhr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! Importieren Fehler beim Löschen der Chat-Datenbank Fehler beim Importieren der Chat-Datenbank Chat-Datenbank importiert Starten Sie die App neu, um die importierte Chat-Datenbank zu verwenden. Chat-Profil löschen? - Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! Chat-Datenbank gelöscht Starten Sie die App neu, um ein neues Chat-Profil zu erstellen. Sie dürfen die neueste Version Ihrer Chat-Datenbank NUR auf einem Gerät verwenden, andernfalls erhalten Sie möglicherweise keine Nachrichten mehr von einigen Ihrer Kontakte. Dateien und Medien löschen? - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. + Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! Keine empfangenen oder gesendeten Dateien %d Datei(en) mit einem Gesamtspeicherverbrauch von %s nie @@ -608,7 +607,7 @@ %s Sekunde(n) Löschen der Nachrichten Automatisches Löschen von Nachrichten aktivieren? - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. + Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden! Nachrichten löschen Fehler beim Ändern der Einstellung @@ -842,7 +841,7 @@ Chat-Präferenzen Kontakt-Präferenzen Gruppen-Präferenzen - Gruppen-Präferenzen einstellen + Gruppen-Präferenzen festlegen Ihre Präferenzen Direkte Nachrichten Für jeden löschen @@ -913,8 +912,8 @@ Periodisch Erlauben Sie das Senden von verschwindenden Nachrichten. In diesem Chat sind verschwindende Nachrichten nicht erlaubt. - Nur Sie können verschwindende Nachrichten senden. - Nur Ihr Kontakt kann verschwindende Nachrichten senden. + Nur Sie können verschwindende Nachrichten versenden. + Nur Ihr Kontakt kann verschwindende Nachrichten versenden. Fehler beim Laden des Chats Fehler beim Laden der Chats Bitte aktualisieren Sie die App und nehmen Sie Kontakt mit den Entwicklern auf. @@ -923,7 +922,7 @@ Erlauben Sie Ihren Kontakten das Senden von verschwindenden Nachrichten. Das Senden von verschwindenden Nachrichten nicht erlauben. Verschwindende Nachrichten sind nicht erlaubt. - Mitglieder können verschwindende Nachrichten senden. + Mitglieder können verschwindende Nachrichten versenden. Fügen Sie Server durch Scannen der QR-Codes hinzu. Verschwindende Nachrichten Übernehmen @@ -937,7 +936,7 @@ Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen. Private Benachrichtigungen Chat verwenden - Ihr Kontakt und Sie können beide verschwindende Nachrichten senden. + Ihr Kontakt und Sie können beide verschwindende Nachrichten versenden. %dh Gruppen-Links Neu in %s @@ -1146,7 +1145,7 @@ Die ID der nächsten Nachricht ist falsch (kleiner oder gleich der vorherigen). \nDies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompromittiert wurde. %1$d Nachrichten konnten nicht entschlüsselt werden. - Der Hash der vorherigen Nachricht unterscheidet sich. + Der Hash der vorherigen Nachricht unterscheidet sich.\" Sie können die SimpleX-Sperre über die Einstellungen aktivieren. SOCKS-Proxy-Einstellungen System-Authentifizierung @@ -1247,7 +1246,7 @@ Selbstzerstörung aktivieren Wenn Sie diesen Zugangscode während des Öffnens der App eingeben, werden alle App-Daten unwiederbringlich gelöscht! Selbstzerstörungs-Zugangscode - Zugangscode einstellen + Zugangscode festlegen Reaktionen auf Nachrichten sind nicht erlaubt. Fehler beim Laden von Details Empfangene Nachricht @@ -1323,7 +1322,7 @@ Wechsel der Empfängeradresse beenden? Dateien und Medien sind nicht erlaubt! Nur Gruppenbesitzer können Dateien und Medien aktivieren. - Mitglieder können Dateien und Medien senden. + Mitglieder können Dateien und Medien versenden. Der Wechsel der Empfängeradresse wird beendet. Die bisherige Adresse wird weiter verwendet. Dateien und Medien sind nicht erlaubt. Favorit entfernen @@ -1791,7 +1790,7 @@ SimpleX-Links sind nicht erlaubt Sprachnachrichten sind nicht erlaubt SimpleX-Links - Mitglieder können SimpleX-Links senden. + Mitglieder können SimpleX-Links versenden. Administratoren Alle Mitglieder Aktiviert für @@ -1880,7 +1879,7 @@ Bild entfernen Wiederholen Skalieren - Default-Design einstellen + Default-Design festlegen Wallpaper-Akzent Wallpaper-Hintergrund Anwenden auf @@ -2025,8 +2024,7 @@ Zoom SMP-Server Informationen zeigen für - Beginnend mit %s. -\nAlle Daten werden nur auf Ihrem Gerät gespeichert. + Beginnend mit %s.\nAlle Daten werden nur auf Ihrem Gerät gespeichert. Statistiken Transport-Sitzungen Hochgeladen @@ -2286,7 +2284,7 @@ Ende-zu-Ende-verschlüsselt versendet. In Direktnachrichten sogar mit Post-Quantum-Security.]]> Team-Mitglieder aufnehmen Freunde aufnehmen - Einladung akzeptiert + Einladung angenommen Geschäftliche Adresse Geschäftliche Chats Nehmen Sie Team-Mitglieder in Ihre Unterhaltungen auf. @@ -2320,6 +2318,89 @@ Wenn mehr als ein Betreiber aktiviert ist, hat keiner von ihnen Metadaten, um zu erfahren, wer mit wem kommuniziert. Chat %1$s verbunden.]]> - Über Betreiber + Über die Betreiber SimpleX-Chat und Flux haben vereinbart, die von Flux betriebenen Server in die App aufzunehmen. - \ No newline at end of file + Die Verbindung erfordert eine Neuverhandlung der Verschlüsselung. + Die Neuverhandlung der Verschlüsselung läuft. + Reparieren + Verbindung reparieren? + Log-Daten aktivieren + Fehler beim Speichern der Datenbank + Verbindung noch nicht bereit. + Alle + Unternehmen + Fehler beim Erstellen der Chat-Liste + Fehler beim Laden der Chat-Listen + Fehler beim Aktualisieren der Chat-Liste + Favoriten + Liste + Keine Chats + Keine Chats gefunden + Keine Chats in der Liste %s. + Keine ungelesenen Chats + Liste erstellen + Löschen + Liste löschen? + Bearbeiten + Listenname... + Der Listenname und das Emoji sollen für alle Listen unterschiedlich sein. + Liste speichern + Alle Chats werden von der Liste %s entfernt und die Liste wird gelöscht + Gruppen + Kontakte + Liste hinzufügen + Zur Liste hinzufügen + Mit %s öffnen + Anmerkungen + Anordnung ändern + Liste ändern + Fehler beim Erstellen der Meldung + Fehler beim Abspeichern der Einstellungen + Archivierte Meldung + Spam melden: Nur Gruppenmoderatoren werden es sehen. + Melden + Anderer Grund + Meldung archivieren? + Moderator + Inhalt melden: Nur Gruppenmoderatoren werden es sehen. + Unangemessener Inhalt + Unangemessenes Profil + Nur Absender und Moderatoren sehen es + Nur Sie und Moderatoren sehen es + Spam + Archiv + Grund der Meldung? + Die Meldung wird für Sie archiviert. + Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen. + Anderes melden: Nur Gruppenmoderatoren werden es sehen. + Verstoß melden: Nur Gruppenmoderatoren werden es sehen. + Meldung archivieren + Meldung löschen + Verstoß gegen die Gemeinschaftsrichtlinien + Archivierte Meldung von %s + Eine Meldung + %d Meldungen + Mitglieder-Meldungen + Meldungen + Archivierte Mitglieder-Meldungen + Inhalt verletzt Nutzungsbedingungen + Spam + Verbindung blockiert + Die Datei wird vom Serverbetreiber blockiert:\n%1$s. + Die Verbindung wird vom Serverbetreiber blockiert:\n%1$s. + Fragen + Nein + Web-Link öffnen + Web-Links aus der Chat-Liste öffnen + Web-Link öffnen? + Ja + Chat-Name festlegen… + Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Dies kann nicht rückgängig gemacht werden! + Automatisches Löschen von Nachrichten ändern? + Chat-Nachrichten von Ihrem Gerät löschen + Automatisches Löschen von Nachrichten deaktivieren? + Löschen von Nachrichten deaktivieren + 1 Jahr + Voreinstellung (%s) + Die Nachrichten in diesem Chat werden nie gelöscht. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 6163d7e873..2790e00698 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -36,8 +36,7 @@ Apariencia Versión por cada perfil que tengas en la aplicación.]]> - Se usará una conexión TCP (y credenciales SOCKS) independiente por cada contacto y miembro del grupo. -\nRecuerda: si tienes muchas conexiones, el consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar. + por cada contacto y miembro del grupo. \nRecuerda: si tienes muchas conexiones, el consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar.]]> a + b Acerca de SimpleX negrita @@ -209,7 +208,7 @@ Eliminar grupo Editar perfil de grupo Conexión - Eliminar el perfil de chat para + Eliminar el perfil Oscuro %dd %d días @@ -217,7 +216,7 @@ conectado directa El contacto permite - predeterminado (%s) + predefinido (%s) Eliminar para todos activado Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. @@ -310,7 +309,7 @@ Error al guardar perfil de grupo Salir sin guardar Archivo guardado - Voltear la cámara + Girar la cámara Invitación de grupo caducada La invitación al grupo ya no es válida, ha sido eliminada por el remitente. El grupo será eliminado para tí. ¡No podrá deshacerse! @@ -330,7 +329,7 @@ Error al cambiar configuración Archivo: %s ¡Error al cambiar perfil! - Introduce el servidor manualmente + Añadir manualmente Cómo usar los servidores Error al parar SimpleX Introduce la contraseña correcta. @@ -690,13 +689,12 @@ Código de seguridad Escanea el código de seguridad desde la aplicación de tu contacto. Guardar servidores - Escanear código QR del servidor + Escanear código QR Servidor predefinido Guardar y notificar contacto ¿Guardar preferencias\? Guardar y notificar grupo - A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo. -\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. + A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo. \nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. La aplicación recoge nuevos mensajes periódicamente lo que consume un pequeño porcentaje de batería al día. La aplicación no usa notificaciones push por tanto los datos de tu dispositivo no se envían a los servidores push. Bloqueo SimpleX Desbloquear @@ -745,7 +743,7 @@ Configuración Altavoz desactivado Inciar chat nuevo - Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás recibir o enviar mensajes. + Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. Gracias por instalar SimpleX Chat! Para proteger tu privacidad, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos. Para proteger tu información, activa el Bloqueo SimpleX. @@ -785,7 +783,7 @@ Probar servidor Probar servidores Estrella en GitHub - Lista de servidores para las conexiones nuevas del perfil + Servidores para conexiones nuevas en tu perfil ¿Usar conexión directa a Internet\? El perfil sólo se comparte con tus contactos. inicializando… @@ -825,7 +823,7 @@ ¡Prueba no superada! Algunos servidores no han superado la prueba: Usar servidor - Usar para conexiones nuevas + Para conexiones nuevas Sistema mediante enlace de un solo uso Chats @@ -1071,7 +1069,7 @@ Hash de mensaje incorrecto ID de mensaje incorrecto Puede ocurrir si tu contacto o tu usáis una copia de seguridad antigua de la base de datos. - El hash del mensaje anterior es diferente. + El hash del mensaje anterior es diferente.\" %1$d mensaje(s) no ha(n) podido ser descifrado(s). Detener archivo El archivo será eliminado de los servidores. @@ -1368,7 +1366,7 @@ Cifra archivos almacenados y multimedia Error al establecer contacto con el miembro Recuerda: los servidores están conectados mediante proxy SOCKS, pero las llamadas y las previsualizaciones de enlaces usan conexión directa.]]> - Cifra archivos locales + Cifrar archivos locales Nueva aplicación para ordenador! 6 idiomas nuevos para el interfaz Cifrado de los nuevos archivos locales (excepto vídeos). @@ -1518,7 +1516,7 @@ Se envían hasta 100 mensajes más recientes a los miembros nuevos. Añadir contacto: crea un enlace de invitación nuevo o usa un enlace recibido.]]> No se envía el historial a los miembros nuevos. - O muestra este código QR + O muestra el código QR Hasta 100 últimos mensajes son enviados a los miembros nuevos. El código QR escaneado no es un enlace SimpleX. El texto pegado no es un enlace SimpleX. @@ -1580,8 +1578,7 @@ El ordenador tiene un código de invitación incorrecto El ordenador ha sido desconectado estado desconocido - Migración de la base de datos en progreso. -\nPodría tardar varios minutos. + Migración de la base de datos en curso. \nPodría tardar varios minutos. El ordenador tiene una versión sin soporte. Por favor, asegúrate de usar la misma versión en ambos dispositivos el contacto %1$s ha cambiado a %2$s perfil actualizado @@ -1785,7 +1782,7 @@ La dirección del servidor es incompatible con la configuración de la red. Con IP desprotegida Clave incorrecta o conexión desconocida - probablemente esta conexión fue eliminada - Usar enrutamiento privado con servidores de retransmisión desconocidos. + Usar enrutamiento privado con servidores de mensaje desconocidos. Enviar mensajes directamente cuando tu dirección IP está protegida y tu servidor o el de destino no admitan enrutamiento privado. ¡Servidores desconocidos! Sin Tor o VPN, tu dirección IP será visible para estos relés XFTP: @@ -1814,7 +1811,7 @@ Restablecer color Escala Respuesta enviada - Establecer tema predeterminado + Establecer tema predefinido Sistema Color de fondo Encaje @@ -1862,7 +1859,7 @@ Info servidores Archivos Mostrando - Suscrito + Suscritas Errores de suscripción Suscripciones ignoradas Para ser notificado sobre versiones nuevas, activa el chequeo periódico para las versiones Estable o Beta. @@ -1883,7 +1880,7 @@ Total Sesiones de transporte Servidor XFTP - No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado. + No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. Todos los perfiles Conectadas Estadísticas detalladas @@ -1905,7 +1902,7 @@ Error de enrutamiento privado La dirección del servidor es incompatible con la configuración de red: %1$s. La versión del servidor es incompatible con tu aplicación: %1$s. - Tamaño fuente + Tamaño de la fuente Error al restablecer las estadísticas Restablecer Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse! @@ -1945,7 +1942,7 @@ Reconectar todos los servidores ¿Reconectar servidor? ¿Reconectar servidores? - Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional. + Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional. Reconectar todos los servidores para forzar la entrega de mensajes. Usa tráfico adicional. Restablecer todas las estadísticas ¿Restablecer todas las estadísticas? @@ -1968,8 +1965,7 @@ Tamaño Conexiones activas Iniciado el %s. - Iniciado el %s -\nTodos los datos son privados a tu dispositivo + Iniciado el %s \nLa información es privada en tu dispositivo. Bloques eliminados Bloques descargados Bloques subidos @@ -2006,7 +2002,7 @@ El contacto será eliminado. ¡No podrá deshacerse! ¡Conversación eliminada! Elimina sin notificar - Sólo borrar la conversación + Eliminar sólo la conversación Conservar conversación buscar Contactos archivados @@ -2039,7 +2035,7 @@ La dirección del servidor de destino de %1$s es incompatible con la configuración del servidor de reenvío %2$s. La versión del servidor de destino de %1$s es incompatible con el servidor de reenvío %2$s. Tus contactos - Necesitas permitir que tus contacto llamen para poder llamarles. + Debes permitir que tus contacto te llamen para poder llamarles. Error al conectar con el servidor de reenvío %1$s. Por favor, inténtalo más tarde. La dirección del servidor de reenvío es incompatible con la configuración de red: %1$s. El servidor de reenvío %1$s no ha podido conectarse al servidor de destino %2$s. Por favor, intentalo más tarde. @@ -2093,7 +2089,7 @@ Los mensajes serán eliminados. ¡No podrá deshacerse! Error al cambiar perfil Selecciona perfil de chat - Comparte perfil + Perfil a compartir Tu conexión ha sido trasladada a %s pero ha ocurrido un error inesperado al redirigirte al perfil. Sonido silenciado Error al iniciar WebView. Asegúrate de tener WebView instalado y que sea compatible con la arquitectura amr64.\nError: %s @@ -2101,12 +2097,12 @@ Esquinas Cola Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación. - Sesión de aplicación + por sesión Abre la configuración de Safari / Sitios Web / Micrófono y a continuación selecciona Permitir para localhost. Pulsa el botón info del campo dirección para permitir el uso del micrófono. Para hacer llamadas, permite el uso del micrófono. Cuelga e intenta llamar de nuevo. - Se usarán credenciales SOCKS nuevas por cada servidor. - Servidor + Se usarán credenciales SOCKS nuevas para cada servidor. + por servidor Llamadas mejoradas Sistema de fechas mejorado. Experiencia de usuario mejorada @@ -2120,13 +2116,13 @@ Error al guardar servidores Error en la configuración del servidor. Para el perfil de chat %s: - Ningún servidor de mensajes. - Ningún servidor para recibir archivos. - Ningún servidor para enviar archivos. + Sin servidores para mensajes. + Sin servidores para recibir archivos. + Sin servidores para enviar archivos. Seguridad de conexión Compartir enlace de un uso con un amigo Comparte tu dirección SimpleX en redes sociales. - Configuración de dirección + Configurar dirección Crear enlace de un uso Para redes sociales ¿Dirección SimpleX o enlace de un uso? @@ -2142,7 +2138,7 @@ %s servidores Las condiciones serán aceptadas el: %s. Condiciones de uso - Para el enrutamiento privado + Para enrutamiento privado Error al añadir servidor Abrir cambios Abrir condiciones @@ -2168,9 +2164,9 @@ para mejorar la privacidad de los metadatos. Ningún mensaje Servidor nuevo - Ningún servidor de archivos y multimedia. - Ningún servidor para enrutamiento privado. - Ningún servidor para recibir mensajes. + Sin servidores para archivos y multimedia. + Sin servidores para enrutamiento privado. + Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado Selecciona los operadores de red a utilizar @@ -2181,11 +2177,11 @@ Tus servidores Usar %s Usar servidores - Usar para mensajes + Uso para mensajes Ver condiciones Para recibir Para enviar - Usar para archivos + Uso para archivos Transparencia Ver condiciones actualizadas Mensajes no entregados @@ -2199,8 +2195,8 @@ %s.]]> %s.]]> %s.]]> - %s, acepta las condiciones de uso.]]> - Los servidores para archivos nuevos en tu perfil actual + %s, debes aceptar las condiciones de uso.]]> + Servidores para archivos nuevos en tu perfil ¡Segundo operador predefinido! Puedes configurar los servidores a través de su configuración. Para protegerte contra una sustitución del enlace, puedes comparar los códigos de seguridad con tu contacto. @@ -2217,7 +2213,7 @@ Por favor, reduce el tamaño del mensaje y envíalo de nuevo. Por favor, reduce el tamaño del mensaje o elimina los archivos y envíalo de nuevo. Puedes copiar y reducir el tamaño del mensaje para enviarlo. - Añade a los miembros de tu equipo a las conversaciones. + Añade a miembros de tu equipo a las conversaciones. Notificaciones y batería Invitar al chat Añadir amigos @@ -2243,7 +2239,7 @@ Comprobar mensajes cada 10 min. Sin servicio en segundo plano Chat - Barra de herramientas accesible + Barra de chat accesible Mensajes directos entre miembros no permitidos. %1$s.]]> ¡El chat ya existe! @@ -2251,5 +2247,88 @@ La aplicación siempre funciona en segundo plano cifrados de extremo a extremo y con seguridad postcuántica en mensajes directos.]]> ¡Mensaje demasiado largo! - Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación. + Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. + Activar registros + Error al guardar base de datos + La conexión requiere renegociar el cifrado. + Renegociación de cifrado en curso. + Reparar + ¿Reparar conexión? + Conexión no establecida. + Error al cargar listas de chat + Error al actualizar las listas de chat + Favoritos + Lista + Sin chats + Ningún chat encontrado + Sin chats en la lista %s. + Añadir lista + Todo + Empresas + Contactos + Grupos + Abrir con %s + Añadir a la lista + Todos los chats serán quitados de la lista %s y esta será eliminada + Crear lista + Eliminar + Eliminar lista? + Editar + Nombre de lista... + El nombre de lista y el emoji deben ser differentes en todas las listas. + Guardar lista + Ningún chat sin leer + Error al crear lista de chat + Notas + Reordenar + Cambiar lista + Error al crear informe + Error al guardar la configuración + informe archivado por %s + ¿Archivar informe? + El informe será archivado para ti. + Informe + Borrar informe + 1 informe + Informes + Informar de spam: sólo los moderadores del grupo lo verán. + Informar de violación: sólo los moderadores del grupo lo verán. + Informar de contenido: sólo los moderadores del grupo lo verán. + Informar de otros: sólo los moderadores del grupo lo verán. + moderador + El contenido viola las condiciones de uso. + Spam + Conexión bloqueada + Conexión bloqueada por el operador del servidor:\n%1$s. + ¿Motivo del informe? + Archivo bloqueado por el operador del servidor:\n%1$s + Archivar + Archivar informe + Informes de miembro + Informes de miembros archivados + %d informes + Informar del perfil de un miembro: sólo los moderadores del grupo lo verán. + Otro motivo + informes archivados + Violación de las normas de la comunidad + Contenido inapropiado + Perfil inapropiado + Solo el remitente y el moderador pueden verlo + Solo tú y el moderador podéis verlo + Spam + Abrir enlace desde la lista de chat + Si + No + ¿Abrir enlace web? + Preguntar + Abrir enlace + Nombre para el chat… + ¿Desactivar la eliminación automática de mensajes? + Desactivar la eliminación de mensajes + ¿Modificar la eliminación automática de mensajes? + Eliminar todos los mensajes del dispositivo + Los mensajes nunca se borran en esta conversación + Todos los mensajes previos a la selección serán eliminados de este chat. ¡No podrá deshacerse! + Un año + predefinido (%s) \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 866506460c..376b3e2d36 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -1850,4 +1850,4 @@ \nلطفا هر مشکل دیگری را با توسعه‌دهندگان به اشتراک بگذارید. این لینک توسط موبایل دیگری استفاده شده است، لطفا لینک جدیدی در کامپیوتر بسازید. خطای سرور پرونده:%1$s - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 26847aeaf5..1dd6598ef3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1501,4 +1501,4 @@ Yhteensopimaton versio Uusi mobiililaite Tämä laite - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 62891fd8b8..0721617b2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -80,14 +80,14 @@ Notifications instantanées ! Les notifications instantanées sont désactivées ! Il peut être désactivé via les paramètres - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.]]> - L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres. + Lorsque l\'optimisation de la batterie est activée, le service en arrière-plan et les demandes périodiques de nouveaux messages sont désactivés. Il est possible de les réactiver dans les paramètres. Notifications périodiques Les notifications périodiques sont désactivées ! Une phrase secrète est nécessaire - autoriser SimpleX à fonctionner en arrière-plan dans la fenêtre de dialogue suivante. Sinon, les notifications seront désactivées.]]> + Autoriser le dans la boîte de dialogue suivante pour recevoir des notifications instantanément.]]> Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs. - SimpleX service de fond - il utilise quelques pour cent de la batterie par jour.]]> + SimpleX fonctionne en arrière-plan au lieu d\'utiliser les notifications push.]]> Cacher Aperçu affiché Nom du contact @@ -147,8 +147,8 @@ Vous n\'avez aucune discussion Trop d’images ! Partager le fichier… - Attacher - Annuler l’aperçu d’image + Joindre + Annuler l\'aperçu de l\'image Annuler l’aperçu du fichier échec d’envoi non lu @@ -275,7 +275,7 @@ E-mail Se connecter L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé. - Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles. + Le service est toujours en cours d’exécution en arrière-plan, les notifications s’afficheront dès que les messages seront disponibles. Afficher le contact et le message Masquer le contact et le message Connectez-vous en utilisant votre identifiant @@ -302,7 +302,7 @@ Lien invalide ! Ce lien n\'est pas un lien de connexion valide ! Demande de connexion envoyée ! - Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard ! + Le fichier sera reçu lorsque votre contact sera en ligne, veuillez patienter ou vérifier plus tard ! Message vocal… La taille maximale supportés des fichiers actuellement est de %1$s. Message vocal (%1$s) @@ -370,11 +370,11 @@ confimation reçu… connexion… N\'importe qui peut heberger un serveur. - Pour protéger votre vie privée, au lieu d\'IDs utilisés par toutes les autres plateformes, SimpleX possède des IDs pour les queues de messages, distinctes pour chacun de vos contacts. + Pour protéger votre vie privée, SimpleX utilise des identifiants distincts pour chacun de vos contacts. Collez le lien que vous avez reçu Utiliser le chat Notifications privées - Peut être modifié ultérieurement via les paramètres. + Comment il affecte la batterie Quand l\'application fonctionne Périodique Instantanée @@ -438,7 +438,7 @@ en attente de confirmation… connecté terminé - La nouvelle génération \nde messagerie privée + L\'avenir de la messagerie La vie privée redéfinie Aucun identifiant d\'utilisateur. Protégé du spam @@ -448,12 +448,12 @@ Établir une connexion privée Comment ça fonctionne Comment SimpleX fonctionne - chiffrement de bout en bout à deux couches.]]> + Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages. GitHub repository.]]> Batterie peu utilisée. L\'app vérifie les messages toutes les 10 minutes. Vous risquez de manquer des appels ou des messages urgents.]]> Consomme davantage de batterie L\'app fonctionne toujours en arrière-plan - les notifications s\'affichent instantanément.]]> %1$d message(s) manqué(s) - ID de message incorrecte + ID du message incorrect PARAMÈTRES Cela peut arriver quand : \n1. Les messages ont expiré dans le client expéditeur après 2 jours ou sur le serveur après 30 jours. @@ -575,7 +575,7 @@ %1$s veut se connecter à vous via Vos appels Se connecter via relais - Appels en écran verrouillé : + Appels sur l\'écran de verrouillage : Afficher Désactiver Vos serveurs ICE @@ -595,12 +595,12 @@ Appel manqué Appel en connexion Répondre à l\'appel - hash de message incorrect + mauvais hachage du message message dupliqué Messages manqués Vie privée et sécurité Protéger l\'écran de l\'app - Images auto-acceptées + Acceptation automatique des images Sauvegarde des données de l\'app VOUS AIDE @@ -638,7 +638,7 @@ Changer la phrase secrète de la base de données \? La base de données sera chiffrée. Erreur de la keychain - Fichier : %s + Fichier : %s La phrase secrète de la base de données est nécessaire pour ouvrir le chat. Enregistrer la phrase secrète et ouvrir le chat Ouvrir le chat @@ -753,8 +753,8 @@ Système Autoriser l\'envoi de messages directs aux membres. Interdire l\'envoi de messages directs aux membres. - Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés. (24 heures) - La suppression irréversible de messages est interdite dans ce groupe. + Les membres peuvent supprimer de manière irréversible les messages envoyés. (24 heures) + La suppression irréversible de messages est interdite. Envoi via État du réseau Changer d\'adresse de réception @@ -789,8 +789,8 @@ Seulement vous pouvez envoyer des messages éphémères. Vous seul pouvez envoyer des messages vocaux. Autoriser la suppression irréversible de messages envoyés. (24 heures) - Les messages éphémères sont interdits dans ce groupe. - Les membres du groupe peuvent envoyer des messages vocaux. + Les messages éphémères sont interdits. + Les membres peuvent envoyer des messages vocaux. Supprimer après %d sec %ds @@ -818,7 +818,7 @@ Autorise votre contact à envoyer des messages éphémères. directe Entièrement décentralisé – visible que par ses membres. - Les membres du groupes peuvent envoyer des messages éphémères. + Les membres peuvent envoyer des messages éphémères. Interdire l’envoi de messages éphémères. Le mode incognito protège votre vie privée en utilisant un nouveau profil aléatoire pour chaque contact. La mise à jour des ces paramètres reconnectera le client à tous les serveurs. @@ -828,8 +828,8 @@ Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise. (24 heures) Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer comme supprimé). (24 heures) Seulement votre contact peut envoyer des messages éphémères. - Vous et votre contact êtes tous deux en mesure d\'envoyer des messages éphémères. - Les messages vocaux sont interdits dans ce groupe. + Vous et votre contact pouvez envoyer des messages éphémères. + Les messages vocaux sont interdits. Saisir le nom du groupe : indirecte (%1$s) Groupe @@ -842,9 +842,9 @@ Réinitialisation des valeurs par défaut Délai du protocole Intervalle de PING - Vous et votre contact êtes tous deux en mesure de supprimer de manière irréversible les messages envoyés. (24 heures) + Vous et votre contact pouvez supprimer de manière irréversible les messages envoyés. (24 heures) La suppression irréversible de message est interdite dans ce chat. - Vous et votre contact êtes tous deux en mesure d\'envoyer des messages vocaux. + Vous et votre contact pouvez envoyer des messages vocaux. Seul votre contact peut envoyer des messages vocaux. Les messages vocaux sont interdits dans ce chat. Les messages éphémères sont interdits dans cette discussion. @@ -853,7 +853,7 @@ Autorise l’envoi de messages éphémères. Interdire l’envoi de messages éphémères. Interdire la suppression irréversible des messages. - Les membres du groupe peuvent envoyer des messages directs. + Les membres peuvent envoyer des messages directs. Les messages directs entre membres sont interdits dans ce groupe. Les destinataires voient les mises à jour au fur et à mesure que vous les tapez. Vérifier la sécurité de la connexion @@ -872,7 +872,7 @@ Les messages envoyés seront supprimés après une durée déterminée. Messages dynamiques Accepter - Demandes de contact auto-acceptées + Acceptation automatique des demandes de contact Quoi de neuf \? Les admins peuvent créer les liens qui permettent de rejoindre les groupes. Définir 1 jour @@ -916,11 +916,11 @@ Vous avez déjà un profil de chat avec ce même nom affiché. Veuillez choisir un autre nom. Nom d\'affichage en double ! Interface en français - Par profil de chat (par défaut) ou par connexion (BETA). + Par profil de discussion (par défaut) ou par connexion (BETA). Interface en italien Brouillon de message - Plus d\'améliorations à venir ! - Différents profils de chat + D\'autres améliorations sont à venir ! + Profils de discussion multiples Conserver le brouillon du dernier message, avec les pièces jointes. Réduction de la consommation de batterie Noms de fichiers privés @@ -947,7 +947,7 @@ Ajouter un message d\'accueil Modération de groupe Cacher - Mute en cas d\'inactivité ! + Mise en sourdine en cas d\'inactivité ! Confirmer le mot de passe Réduction accrue de l\'utilisation de la batterie Interface en chinois et en espagnol @@ -1035,7 +1035,7 @@ SimpleX Lock n\'est pas activé ! Authentification du système Authentification - Echec de l\'authentification + Échec de l’authentification Modifier le code d\'accès Code d\'accès actuel %d minutes @@ -1055,18 +1055,17 @@ Code d\'accès défini ! Système Authentification annulée - Mauvais ID de message - Le hash du message précédent est différent. + ID du message incorrect + Le hash du message précédent est différent.\" L\'ID du message suivant est incorrect (inférieur ou égal au précédent). \nCela peut se produire en raison d\'un bug ou lorsque la connexion est compromise. Erreur de déchiffrement Cela peut se produire lorsque vous ou votre contact avez utilisé une ancienne sauvegarde de base de données. - Mauvais hash de message + Mauvais hachage du message Autoriser les appels que si votre contact les autorise. Autorise vos contacts à vous appeler. Appels audio/vidéo - " -\nDisponible dans la v5.1" + \nDisponible dans la v5.1 Interdire les appels audio/vidéo. Le fichier sera supprimé des serveurs. Révoquer @@ -1075,7 +1074,7 @@ L\'envoi du fichier sera interrompu. Veuillez le signaler aux développeurs. Soumettre - Vous et votre contact pouvez tous deux passer des appels. + Vous et votre contact pouvez passer des appels. Vous seul pouvez passer des appels. Les appels audio/vidéo sont interdits. Confirmer le code d\'accès @@ -1119,7 +1118,7 @@ Partager l\'adresse Vous pouvez partager cette adresse avec vos contacts pour leur permettre de se connecter avec %s. Aperçu - Fond + Arrière-plan Thème sombre Exporter le thème Importer un thème @@ -1133,7 +1132,7 @@ Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d\'autres personnes. La mise à jour du profil sera envoyée à vos contacts. Secondaire supplémentaire Tous vos contacts resteront connectés. La mise à jour du profil sera envoyée à vos contacts. - Auto-accepter + Acceptation automatique Créer une adresse SimpleX Personnaliser le thème Continuer @@ -1144,7 +1143,7 @@ Bonjour ! \nContactez-moi via SimpleX Chat : %s Si vous ne pouvez pas vous rencontrer en personne, montrez le code QR lors d\'un appel vidéo ou partagez le lien. - Ouvrir les profils de chat + Changer de profil de discussion Menus et alertes Message reçu Assurez-vous que le fichier a une syntaxe YAML correcte. Exporter le thème pour avoir un exemple de la structure du fichier du thème. @@ -1181,8 +1180,8 @@ Vous seul pouvez ajouter des réactions aux messages. Autoriser les réactions aux messages. Interdire les réactions aux messages. - Les membres du groupe peuvent ajouter des réactions aux messages. - Les réactions aux messages sont interdites dans ce groupe. + Les membres peuvent ajouter des réactions aux messages. + Les réactions aux messages sont interdites. heures minutes secondes @@ -1250,8 +1249,8 @@ Délai d\'attente du protocole par KB Fichiers et médias interdits ! Permet l\'envoi de fichiers et de médias. - Les membres du groupe peuvent envoyer des fichiers et des médias. - Les fichiers et les médias sont interdits dans ce groupe. + Les membres peuvent envoyer des fichiers et des médias. + Les fichiers et les médias sont interdits. Correction non prise en charge par un membre du groupe ENVOYER DES ACCUSÉS DE RÉCEPTION AUX Le chiffrement fonctionne et le nouvel accord de chiffrement n\'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! @@ -1273,7 +1272,7 @@ Activer pour tous Activer (conserver les remplacements) chiffrement accepté pour %s - Filtrer les messages non lus et favoris. + Filtrer les favoris et les messages non lus. Recherche de message plus rapide En réponse à - une diffusion plus stable des messages. @@ -1424,7 +1423,7 @@ Les messages de %s seront affichés ! Erreur lors de l\'envoi de l\'invitation Vous avez partagé un chemin de fichier non valide. Signalez le problème aux développeurs de l\'application. - Bloquer ce membre ? + Bloquer ce membre ? %d événements de groupe Nom invalide ! %1$s !]]> @@ -1480,7 +1479,7 @@ Se déconnecter auteur Connecté au portable - Mauvaise adresse de bureau + Adresse de bureau incorrecte Coller l\'adresse du bureau Vérifier le code avec le bureau Scannez le code QR du bureau @@ -1680,7 +1679,7 @@ %s envoyé Envoi de l\'archive Finaliser le transfert - Transfert terminé + Migration terminée Démarrer le chat ne devez pas utiliser la même base de données sur deux appareils.]]> Vérifier la phrase secrète de la base de données @@ -1691,7 +1690,7 @@ Échec de l\'importation Lien invalide Transférer ici - Transfert + Migration Ou coller le lien de l\'archive Ou partagez en toute sécurité le lien de ce fichier Coller le lien de l\'archive @@ -1717,10 +1716,10 @@ tous les membres Autorise l\'envoi de liens SimpleX. Activé pour - Les membres du groupe peuvent envoyer des liens SimpleX. + Les membres peuvent envoyer des liens SimpleX. propriétaires Interdire l\'envoi de liens SimpleX - Les liens SimpleX sont interdits dans ce groupe. + Les liens SimpleX sont interdits. Pas de connexion au réseau WiFi Ethernet câblé @@ -1848,7 +1847,7 @@ Erreur de fichier Erreur de fichier temporaire Statut du fichier - Statut du fichier: %s + Statut du fichier : %s Statut du message Statut du message: %s Erreur de copie @@ -1951,8 +1950,7 @@ Infos serveurs Afficher les informations pour À partir de %s. - À partir de %s. -\nToutes les données restent confinées dans votre appareil. + À partir de %s. \nToutes les données restent confinées dans votre appareil. Statistiques Total Serveur XFTP @@ -2001,7 +1999,7 @@ Pas de contacts filtrés Coller le lien Vos contacts - Barre d\'outils accessible + Barre d\'app accessible Le contact est supprimé. Les appels ne sont pas autorisés ! Vous devez autoriser votre contact à appeler pour pouvoir l\'appeler. @@ -2017,7 +2015,7 @@ Veuillez demander à votre contact d\'autoriser les appels. Envoyer un message pour activer les appels. Archiver les contacts pour discuter plus tard. - Rendez les images floues et protégez-les contre les regards indiscrets. + Flouter pour une meilleure confidentialité. Connectez-vous à vos amis plus rapidement. État de la connexion et des serveurs. Exportation de la base de données des discussions @@ -2114,4 +2112,213 @@ Protocoles SimpleX audité par Trail of Bits. Passer de l\'audio à la vidéo pendant l\'appel. Changer de profil de chat pour les invitations à usage unique. - \ No newline at end of file + rapport archivé + Ajoutez les membres de votre équipe aux conversations. + L\'application tourne toujours en arrière-plan + rapport archivé par %s + Autre raison + Archive + 1 rapport + Paramètres de l\'adresse + Barres d\'outils de l\'application + invitation acceptée + Archiver le signalement + Demander + Rapports du membre archivés + Ajouter à la liste + Toutes les discussions seront supprimées de la liste %s, et la liste sera supprimée + Ajouter des membres à l\'équipe + Conditions acceptées + Ajouter des amis + Accepter les conditions + Ajouter une liste + Tous + À propos des opérateurs + Archiver le signalement ? + Adresse professionnelle + Discussions professionnelles + Flouter + Conditions acceptées le : %s. + Violation des directives communautaires + Conditions d\'utilisation + Créer un lien unique + %s.]]> + Les conditions seront acceptées le : %s. + %s, acceptez les conditions d\'utilisation.]]> + chiffrés de bout en bout, avec une sécurité post-quantique dans les messages directs.]]> + Réception des messages toutes les 10 minutes + %s.]]> + %s.]]> + %s.]]> + La discussion existe déjà ! + La connexion est bloquée par l\'opérateur du serveur :\n%1$s. + Les conditions seront automatiquement acceptées pour les opérateurs activés le : %s. + Le contenu viole les conditions d\'utilisation + Supprimer le rapport + La connexion n\'est pas prête. + Connexion bloquée + %s.]]> + Contacts + Continuer + Créer une liste + Supprimer + Supprimer la liste ? + Supprimer la discussion + Discussions + %s.]]> + %s.]]> + Le texte sur les conditions actuelles n\'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien : + Les messages directs entre membres sont interdits. + %1$s.]]> + Supprimer la discussion ? + Ajout de serveurs de médias et de fichiers + Ajout de serveurs de messages + %s.]]> + Appareils Xiaomi : veuillez activer le démarrage automatique dans les paramètres du système pour que les notifications fonctionnent.]]> + La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! + Le discussion sera supprimé pour vous - il n\'est pas possible de revenir en arrière ! + Les conditions seront acceptées pour les opérateurs activés après 30 jours. + La connexion nécessite une renégociation du chiffrement. + avec un seul contact - partagez en personne ou via n\'importe quelle messagerie.]]> + Adresse ou lien unique ? + Sécurité des connexions + Professionnels + Le fichier est bloqué par l\'opérateur du serveur :\n%1$s. + Favoris + %d rapports + modérateur(trice) + Décentralisation du réseau + Seuls vous et les modérateurs le voient + Seuls l\'expéditeur et les modérateurs le voient + Erreur d\'enregistrement des serveurs + Pas de serveurs pour recevoir des messages. + Pas de message + Pour les réseaux sociaux + Vérifier plus tard + Serveurs prédéfinis + Signaler autre : seuls les modérateurs du groupe le verront. + Activer les journaux (logs) + Notifications et batterie + demande à se connecter + Pas de serveurs de médias et de fichiers. + Pas de serveurs de messages. + Pas de serveurs pour recevoir des fichiers. + Spam + Erreur d\'enregistrement des paramètres + Erreur lors de la création du rapport + Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n\'importe quelle messagerie. + Spam + Signaler + Pas de discussions non lues + Groupes + Signalements + Signaler le profil d\'un membre : seuls les modérateurs du groupe le verront. + Signaler le spam : seuls les modérateurs du groupe le verront. + Ouvrir le lien + Ouvrir des liens depuis la liste de discussion + Erreur de mise à jour du serveur + Les serveurs pour les nouveaux fichiers de votre profil de discussion actuel + Serveur de l\'opérateur + Le protocole du serveur a été modifié. + Activer Flux + Ce message a été supprimé ou n\'a pas encore été reçu. + Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. + Partager publiquement votre adresse + Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. + Seuls les propriétaires peuvent modifier les préférences. + Le rôle deviendra %s. Toutes les personnes présentes dans le discussion en seront informées. + Erreur lors de la création d\'une liste de discussion + Erreur de chargement des listes de discussion + Erreur de mise à jour de la liste des discussions + Pas de discussions + Pas de discussions trouvées + Ouvrir avec %s + Sauvegarder la liste + Modifier + Partagez votre adresse SimpleX sur les réseaux sociaux. + Pas de service d\'arrière-plan + Barre de discussion accessible + Vérifier les conditions + Opérateur + Serveurs %s + Ou importer un fichier d\'archive + Ouvrir les conditions + Pour le routage privé + Pas de serveurs pour le routage privé des messages. + Erreur lors de la validation des conditions + Erreurs dans la configuration des serveurs. + Pour le profil de discussion %s : + Pas de serveurs pour envoyer des fichiers. + Veuillez réduire la taille du message et envoyer le à nouveau. + Veuillez réduire la taille du message ou supprimer le média et renvoyer le message. + pour une meilleure protection des métadonnées. + - Premier message non lu à l\'ouverture.\n- Sauter aux messages cités. + Respect de la vie privée de vos clients. + Le deuxième opérateur prédéfini de l\'application ! + Erreur lors de l\'ajout du serveur + Erreur d\'enregistrement de la base de données + Signaler un contenu : seuls les modérateurs du groupe le verront. + Opérateurs de serveur + Pas de discussions dans la liste %s. + L\'opérateur du serveur a changé. + Ouvrir le lien web ? + Signaler une infraction : seuls les modérateurs du groupe le verront. + La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. + Serveur ajouté à l\'opérateur %s. + L\'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. + Sélectionnez les opérateurs de réseau à utiliser. + SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l\'application. + Notes + Ou à partager en privé + Adresse SimpleX ou lien unique ? + Renégociation du chiffrement en cours. + Réparer + Réparer la connexion ? + Partager un lien unique avec un ami + Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. + Comment il contribue à la protection de la vie privée + Motif du signalement ? + Le rapport sera archivé pour vous. + Contenu inapproprié + Nouveau serveur + Vous pouvez configurer les serveurs via les paramètres. + Pour envoyer + Profil inapproprié + Site web + Liste + Signalement des membres + Modifier l\'ordre + Mise à jour + Les messages directs entre membres sont interdits dans cette discussion. + Non + Oui + Opérateur de réseau + Utiliser pour les fichiers + Utiliser pour les messages + Voir les conditions + Opérateurs de réseau + Inviter à discuter + Le nom de liste et l\'emoji doivent être différents pour toutes les listes. + Nom de la liste... + Quitter la discussion ? + Vous ne recevrez plus de messages de cette discussion. L\'historique sera préservé. + Le membre sera retiré de la discussion - cela ne peut pas être annulé ! + Votre profil de discussion sera envoyé aux autres membres + Vos serveurs + Utiliser %s + Utiliser les serveurs + Pour recevoir + Ouvrir les modifications + Messages non distribués + Vous pouvez copier et réduire la taille du message pour l\'envoyer. + Quitter la discussion + Voir les conditions mises à jour + Navigation améliorée dans les discussions + Lorsque plusieurs opérateurs sont activés, aucun d\'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. + Le message est trop volumineux ! + Mobiles à distance + Modifier la liste + Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. + Transparence + Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index bd1875a788..fffe525e0f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -1,5 +1,852 @@ - Pretraži - Isključeno - \ No newline at end of file + Pretraži + Isključeno + Prekini + Prosledi do 20 poruka odjednom. + Sakriti kontakt i poruku + %1$d dokument greska(e):\n%2$s + %1$d dokument(a) se i dalje preuzima. + %1$d dokument(a) neuspešno preuzet. + %1$d dokument(a) su odstranjena. + %1$d dokument(a) nije preuzet. + završen + %1$s želi da se poveže sa tobom putem + Prihvatiti + Pozivnica za grupu je istekla + Grupni link + Sakriti + Dobar dan! + Istorija nije poslana novim članovima. + Sakriti + 5 minuta + Ceo Link + Prosledi poruke… + Prekini menjanje adrese? + Ako se ne možeš sastati, pokaži QR kod u video pozivu ili podeli link. + Sakriti profil + a + b + prihvati poziv + Dodeliti dozvolu + Slušalice + POMOĆ + Grupa će biti obrisana za tebe – ovo ne može da se poništi! + Akcenat + Grupni linkovi + Madjarski i Turski UI + Kako koristiti markdown + Iz Galerije + 1 izveštaj + O SimpleX adresama + iznad, onda: + Prihvati zahtev za vezu? + Zdravo!\nPoveži se sa mnom pomoću SimpleX Razgovora: %s + Grupa neaktivna + U potpunosti decentralizovan - vidljivo samo za članove grupe. + Celo ime grupe: + pomoć + Celo Ime: + Prekini poziv + Prihvatiti + Francuski interfejs + Sakriti ekran aplikacije u nedavnim aplikacijama. + sati + 30 sekundi + Sakriti: + Sakriti + Prosledi poruke bez dokumenata? + Kako da + Prihvatiti + %1$d poruke preskočene. + %1$d preskočena poruka(e) + 1 mesec + 1 nedelja + Pozivnica za grupu više nije validna. Pošiljalac je uklonio. + Grupa nije pronadjena! + Prihvati uslove + Grupa već postoji! + Prekini razgovor + Istorija + Dodeliti dozvolu(e) za uspostavljanje poziva + Kako radi + %1$s ČLANOVI + Upiši ime grupe: + Kako koristiti svoje servere + ICE serveri (jedan po liniji) + Prekini menjanje adrese + Nadji Radnu Površinu + Skriveno + %1$s poruke nisu prosledjene + Grupe + Kako se koristi + O SimpleX Razgovoru + %1$d neuspešno u dešifrovanje poruke. + grupni profil ažuriran + grupa odstranjena + Grupa + Ako izabereš da odbiješ pošiljalac NEĆE biti obavešten. + Skriveni razgovori + Grupa će biti obrisana za sve članove – ovo ne može da se poništi! + Domaćin + Kako utiče na bateriju + Kako pomaže privatnosti + Kako SimpleX radi + Grupni profil je uskladnjen na uredjajima korisnika, ne na serverima. + O operatorima + Skrivena šifra profila + 1 dan + Upiši ispravnu pristupnu frazu. + 1 minut + Prihvatiti + Dobro jutro! + Dodatno smanjena potrošnja baterije. + O SimpleX + prihvaćena pozivnica + poruka + Odstraniti i obavestiti kontakte + Odstraniti kontakt? + Odstraniti bazu podataka sa ovog uređaja + Odstraniti kontakt + Greška + Odstranjeno u + Šifra + Ažuriranje dostupno: %s + Napravi adresu + odstranjen kontakt + Dodaj poruku dobrodošlice + Poslano u: %s + Ažuriraj + Šifra profila + Administratori mogu da blokiraju + Greška + Napravi jednokratnu poveznicu + Nalepiti + PODEŠAVANJE + Profilne slike + Razumeo + Odstranjeno + odstranjeno + Napraviti + PORUKE I DOKUMENTA + Poruka + SERVERI + Odstraniti profil razgovora + administratori + Nasumično + greška + Greška: %1$s + Odstraniti %d poruke? + Odstraniti + Podešavanje + Ažuriraj + Odstraniti sve dokumente + Odstraniti profil razgovora? + Poruke + administrator + Odstranjeno u: %s + Oblikuj profilne slike + Aktivne veze + Dodaj na drugi uredjaj + Greška + Greška + Adresa + Greške pri potvrdi + Lista + Ime liste... + Odstraniti + Odstraniti nakon + Dodaj adresu na svoj profil da bi tvoji kontakti mogli da dele sa ostalima. Ažuriranje profila biće poslano tvojim kontaktima. + Dodaj kontakt + Dodaj listu + Prikazati šifru + Odstraniti + Promena adrese će biti prekinuta. Biće upotrebljena stara adresa. + Poslano direktno + Sve + Poslano u + poslano + Odstraniti profil razgovora za + Prihvati anonimno + Podeli + Dodaj na listu + Pristup na servere pomoću SOCKS proxy na portu %d? Proxy mora da bude uključen pre omogućavanja ove opcije. + Prihvaćeni uslovi + Dodaj server + Dodaj unapred postavljene servere + Odstraniti adresu? + odstranjena grupa + Dodaj nalog + Odstraniti profil razgovora? + Napraviti + Odstraniti razgovor? + Odstranjena adresa + Odstranjena baza podataka + Arhiviraj i otpremi + Odstraniti razgovor + Ime profila: + Dodaj server skeniranjem QR koda. + Ažuriraj + Dodatni akcent + Odstraniti + Tema profila + Dodat serveri za poruke + Dodatni akcent 2 + Podešavanje + Podešavanje adrese + Dodaj tvoje članove tima u konverzaciju. + Dodaj prijatelja + Dodaj člana tima + Arhiva + poveži + Kamera + Više + Otključaj + Nezaštićeno + Nepoznati serveri + Bluetooth + Pozivi na zaključanom ekranu: + %d dana + Poveži automatski + Povezano + Greške + Preuzeto + povezivanje… + Kamera + %d mesec + Dokument nije pronađen + %d poruke blokirane administratorom + %d izvještaji + poziv + Kamera nije dostupna + pozivanje… + %d min + %d sec + Preuzeti dokumenti + blokirano administratorom + vi + nepoznat status + Poveži se anonimno + greška u pozivu + Poveži + nepoznat format poruke + Povezano + %ds + %d sata(i) + Povezivanje + povezivanje… + Poveži + Poveži + povezano + povezano + Preuzimanje + Napredna podešavanja + Poziv u toku + POZIVI + Blokiraj članove grupe + Nepoznati serveri! + Dokument + Dokument + Uključiti zvuk + Kamera i mikrofon + vi: %1$s + Povezan na telefon + blokirano + %d poruka blokirano + povezivanje + Povezan telefon + VI + Zamagljenost za bolju privatnost. + %d meseca(i) + Poziv završen + Poništiti + Osim ako vaš kontakt nije obrisao vezu ili je ova pozivnica već korišćena, moguće je da je u pitanju greška - molimo vas da je prijavite.\nDa biste se povezali, molimo vas da zamolite vaš kontakt da kreira novu pozivnicu i proverite da li imate stabilnu mrežnu konekciju. + povezivanje… + Uključiti zvuk + povezano + Povezano + Preuzimanje + Povezati direktno? + %d dan + %dh + %dm + %dmon + povezivanje… + nepročitano + poziv završen %1$s + poziv u toku + povezano direktno + uključeno + %d sat + podebljano + blokirano + Napredna podešavanja + blokirano %s + Blokiraj za sve + povezano + Poziv već završen! + Nepoznata greška + povezivanje + povezivanje (prihvaćeno) + Blokiraj člana za sve? + Blokiraj člana + Blokirano administratorom + Blokiraj člana? + Zamagljenje + Otkači + %d minut(a) + Proveri ažuriranje + Stabilno + DOKUMENTA + %s otpremljeno + Onemogućiti obavještenja + %s nije verifikovan + Sve poruke biće odstranjene - ovo ne može biti vraćeno! + Profil razgovora + Onemogućiti + %d nedelja + - opcionalno obavestiti o obrisanim kontaktima.\n- imena profila sa razmacima.\n- i još mnogo toga!\" + Uvoženje arhive + Dokumenta + Otpremljeno + %s je verifikovan + Konzola razgovora + Skenirati QR kod + Server + Onemogućiti + BAZA PODATAKA RAZGOVORA + onemogućeno + Greška pri uvoženju teme + Dokumenta i medijski sadržaji su zabranjeni. + Ili nalepiti link arhive + Onemogućeno za sve grupe + Nestaje u + Dokumenta i medijski sadržaji + svi članovi + Poruke koje nestaju + onemogućeno + Dozvoliti pozive? + %d sekunde(i) + Ili skenirati QR kod + Onemogućeno + Aplikacija + RAZGOVORI + Dokumenta i medijski sadržaji su zabranjeni! + Poruke koje nestaju su zabranjene u ovom razgovoru. + Razgovor je zaustavljen + Dokument sačuvan + Verzija aplikacije + Verzija aplikacije: v%s + Tema razgovora + %s sekunda(i) + Baza podataka razgovora izvezena + Operator + Operacioni server + Poruke koje nestaju + Poruke koje nestaju su zabranjene. + Adresa servera + Dokumenta i medijski sadržaji nisu dozvoljeni + Dozvoliti + Razgovor sa kreatorima + Razgovori + %d nedelja(e) + Računar + Direktne poruke između članova su zabranjene. + Direktne poruke između članova su zabranjene u ovoj grupi. + %s serveri + Dozvoliti + Direktne poruke + Onemogućeno za sve + Razgovor migriran! + %s, %s i %d članovi + Prekinuti + Razgovor već postoji! + Baza podataka razgovora odstranjena + Proveri ažuriranje + Razgovor je zaustavljen. Ako već koristiš ovu bazu podataka na drugom uređaju, trebala bi se vratiti pre nego što se pokrene razgovor. + Proveri poruke svakih 10 minuta + %s povezan + %s, %s i %s povezani + direktno + Razgovor + QR kod + Razgovor je pokrenut + Uvesti bazu podataka + BAZA PODATAKA RAZGOVORA + Razgovor je zaustavljen + %s, %s i %d ostali članovi povezani + Uvoz neuspešan + Razgovor će biti odstranjen za tebe - ovo se ne može vratiti! + Uvesti temu + Baza podataka razgovora uvezena + %s (sadašnji) + Nestaje u: %s + Uvesti + Boje razgovora + %s: %s + Ili uvesti arhivirani dokument + Razgovor će biti odstranjen za sve članove - ovo se ne može vratiti! + Direktne poruke između članova su zabranjene u ovim porukama. + Prekinuti + %s preuzeto + Već ste povezani na %1$s. + Glasovne poruke nisu dozvoljenje + Greška u vezi + Test server + audio poziv (nije e2e šifrovan) + Novi razgovor + Sačuvati i obavestiti kontakt + Sačuvati i obavestiti članove grupe + tajna + video poziv (nije e2e šifrovan) + Video uključen + Audio isključeno + Audio uključeno + Crna + isključeno` + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Započeti razgovor + italic + Urediti + Sačuvati + Video + Blok + sekunde + Test serveri + Vaša podešavanja + Mikrofon + Ugao + Popraviti + Obezbeđeno + Veze + nova poruka + Email + Koristi SOCKS proxy? + Veza blokirana + Obaveštenja + Video + Prihvatili ste vezu + Očistiti + SimpleX adresa + SimpleX Logo + Prikazati: + UREĐAJ + Nova poruka + Sekundarni + Kontakti + Isključeno + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Omogući + Vaši serveri + Sačuvati + sek + Glasovne poruke + OK + Audio poziv + Video poziv + Koristi server + Adresa vašeg servera + Vaši SMP serveri + Vaši XFTP serveri + Obaveštenja i baterija + Prikazati + e2e šifrovano + Otvoriti + Omogući automatsko brisanje poruka? + šifrovanje ok za %s + Isključiti zvuk + SimpleX + Teme + Glasovne poruke + Audio/video pozivi su zabranjeni. + Otvoriti grupu + Svi profili + Započeti novi razgovor + Očistiti + Očistiti razgovor + Tvoj server + šifrovanje prihvaćeno za %s + Glasovne poruke su zabranjene. + veza %1$d + Video isključen + ništa + Veza + Omiljeno + Kontakti + Omiljen + Nikada + Veza + TEME + Audio/video pozivi + ne + šifrovanje ok + šifrovanje prihvaćeno + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Nazad + Vaša SimpleX adresa + Sačuvati i obavestiti kontakte + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + uređeno + Ne + Ne + Zadržite konverzaciju + SimpleX Adresa + Sačuvati + simplexmq: v%s (%2s) + EKSPERIMENTALNO + nikada + Očistiti + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + e2e šifrovan zvučni poziv + video poziv + Koristi %s + Koristi servere + Vaši kontakti + Ne + Započeti razgovor? + otvoriti + Nastaviti + Otvoriti + Pozadina + audio poziv + Audio & video pozivi + Nastaviti + Audio i video pozivi + Urediti + isključeno + Glasovne poruke su zabranjene u ovom razgovoru. + Isključiti zvuk + Zadržati + Popraviti + Očistiti razgovor? + Očistiti privatne beleške? + Nastaviti + arhivirani izveštaj + Uspešno instalirano + Skenirati telefonom + PING interval + Na čekanju + Poslane poruke + Bezbednostni kod + Poslati pitanja i ideje + Ponoviti uvoz + Trenutna obaveštenja! + Poslati Poruku + Trenutno + Ukloniti člana + Poslati + Sačuvano + Slika + jednokratna veza + Koristi nasumične pristupne podatke + Port + Korisničko ime + port %d + Sačuvati podešavanja? + Privatna obaveštenja + Dolazni audio poziv + bez e2e šifrovanja + Okrenuti kameru + odblokiran %s + uklonjena kontakt adresa + prihvatiti šifrovanje za %s… + uklonjeno + levo + Napustiti grupu + Uloga + Svetlo + %dn + Nevažeća veza + Statistika + Ponovo povezati + Veličina + Odstraniti sliku + propušten poziv + Odbijeni poziv + Poziv na čekanju + Poslana poruka + Anonimne grupe + Izabrati + Pozvati + arhivirani izveštaj od %s + nevažeći razgovor + Opis + Trenutna obaveštenja + Zaustaviti razgovor + Poruka koja nestaje + bez detalja + Skenirati kod + Pristupiti + Pristupiti grupi? + član %1$s promenjen u %2$s + Poruka dobrodošlice + Ukloniti sliku + Privatne beleške + Nevažeća veza + Dobrodošli %1$s! + Periodična obaveštenja + Otvoriti konzolu razgovora + pretraži + video + peer-to-peer + Anonimni režim štiti Vašu privatnost koristeći novi nasumični profil za svaki kontakt. + nedelje + Interna greška + Sačuvano od %s + sačuvano + pozvan + Sačuvana poruka + Obnoviti boje + Povezati telefon + Arhiviraj izveštaj? + Dostava + Izveštaj + U odgovoru na + Izveštaji + Beleške + Poslati živu poruku + Odstraniti veze na čekanju? + Skenirati / Nalepiti vezu + Neispravan bezbednostni kod! + odbijeni poziv + Pozvati + neaktivan + Sačuvati i ponovo povezati + k + Pristupiti grupi? + nevažeći format poruke + Uključiti + Odstraniti poruku? + uklonjena profilna slika + profilna slika + Tamno + Povezani telefoni + Pretplaćen + Pitati + Pozvati u grupu + Sistem + Sistem + Svetlo + Obnoviti boju + Svetlosni režim + Tamni režim + Pristupiti grupnoj konverzaciji + Adresa računara + Pristupiti Vašoj grupi? + Trenutna obaveštenja su onemogućena! + Zaustaviti dokument + pristupiti kao %s + Informacije + Odgovoriti + Član neaktivan + SOCKS proxy podešavanje + SOCKS proxy + Propušten poziv + Odstraniti poruke nakon + Odstraniti poruke + levo + bezbednostni kod promenjen + Lokalno ime + (trenutno) + Pogledati uslove + Sistem + Tamno + Tamna tema + Povećati veličinu slova. + Detalji + Obnoviti + Obnoviti statistiku + Sistem + Poslati direktnu poruku za povezivanje + Slika + Na čekanju + SMP serveri + Upišite Vaše ime: + Otvoriti razgovor + pozvan %1$s + prihvatiti šifrovanje… + Slanje pomoću + Odstraniti redosled + Privatne beleške + nevažeći podaci + Zaustaviti + Popraviti vezu + Zaustaviti + poslati direktnu poruku + Priložiti + Sačuvati listu + Samouništenje + Pogrešna adresa računara + Kritična greška + uklonjeno %1$s + član + Početna uloga + posredan (%1$s) + Sačuvano od + Periodična obaveštenja su onemogućena! + Poruka dobrodošlice + Otvoriti pomoću %s + Ukloniti arhivu? + Izabrano %d + Dobrodošli! + Previše video snimaka! + Poslati + Obnoviti + Nevažeća veza! + Izabrati profil razgovora + Skenirati QR kod servera + Napredna mrežna podešavanja + SOCKS PROXY + Anonimni režim + broj PING + Obnoviti statistiku? + Pozvati članove + TCP veza + Informacije o serveru + Popraviti vezu? + Arhivirani kontakti + Arhiviraj izveštaj + Skenirati QR kod sa radne površine + Napustiti razgovor? + Dolazni video poziv + Pozvati članove + Tamni režim boja + %dd + Povezane radne površine + Šta je novo + Ukloniti + pokušaji + Anonimno + Odstraniti profil + Video poslan + Sačuvati servere + Sačuvati servere? + Ukloniti člana + bez teksta + Poruka dobrodošlice je preduga + Arhiviraj bazu podataka + Periodično + Ukloniti + ČLAN + Pristupanje grupi + SMP server + Pozvati u razgovor + Odblokirati + Italijanski interfejs + Dokument: %s + Pristupiti anonimno + Napustiti grupu? + Uređaji + Izabrati + Poslati priznanice + Popraviti vezu? + Napustiti + Napustiti razgovor + Izabrati kontakte + Poslati direktnu poruku + Ukloniti člana? + Ponoviti + Veličina teksta + Videa i dokumenta do 1gb + Nekompatibilna verzija + Ponoviti preuzimanje + Zaustaviti razgovor? + Odstraniti vezu + Kreirano u: %s + vlasnici + Dovršiti migraciju na drugom uređaju. + Drugo + Kreirano + Ostale greške + Ponoviti + Šifrovati bazu podataka? + %s i %s + vlasnik + Kopirati + Predati + Ostali XFTP serveri + Napraviti SimpleX adresu + Šifrovati + Kreirano u + Otvoriti uslove + Kontrolisanje vaše mreže + Kopirano + Poruka je prevelika + Otpremiti dokument + Napraviti dokument + Napraviti tajnu grupu + kreator + Otkriti + Napraviti listu + Greška pri kopiranju + %s i %s su povezani + Napraviti grupu + Napraviti tajnu grupu + Napokon ih imamo! 🚀 + Dovršiti migraciju + Ništa nije izabrano + Konverzacija odstranjena! + Ostali SMP serveri + Verzija jezgra: v%s + Šifrovati bazu podataka + Odstraniti vezu? + Pretplata ignorisana + Napraviti profil razgovora + Napraviti profil + Greške u pretplati + Pasent + Odstraniti listu? + Za primanje + drugo + Napraviti profil + Vi odlučujete ko se može povezati. + Odstraniti grupu? + Obnoviti + Prebaciti + Beta + Urediti sliku + Mala grupa (max 20) + Primljeno u + otkazano %s + primljena potvrda… + Bolje grupe + Drugi razlog + Arhivirati kontakte za kasniji razgovor. + Primljene poruke + Bolja sigurnost ✅ + Napraviti vezu + Primljena poruka + Dodirnuti za Povezivanje + Dodirnuti dugme + Otvoriti port u firewallu + Primljeno u: %s + Pomoću pretraživača + Podeliti adresu javno + Preskočiti ovu verziju + Podseti kasnije + Povezivanje poziva + Odgovoriti na poziv + Mrežna veza + Decentralizacija mreže + Biznis razgovori + Završeno + Proširiti + Prikazati QR kod + Preuzeti dokument + Napraviti grupni link + Biznisi + Bolje korisničko iskustvo + Spora funkcija + Urediti profil grupe + Dodirnuti za aktivaciju profila. + Tema aplikacije + Dodirnuti za pristup + Dodirnuti za anonimni pristup + Podeliti adresu + Rep + Bolji pozivi + Biznis adresa + Primljena poruka + završiti + Proširiti selekciju uloga + Otvaranje baze podataka… + 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 531e29de20..abf13f2142 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -14,14 +14,14 @@ Megszakítás 30 másodperc Egyszer használható meghívó-hivatkozás - %1$s szeretne kapcsolatba lépni Önnel ezen keresztül: - SimpleX Chat névjegye + %1$s szeretne kapcsolatba lépni Önnel a következőn keresztül: + A SimpleX Chat névjegye 1 nap Címváltoztatás megszakítása A SimpleXről Kiemelés fogadott hívás - Hozzáférés a kiszolgálókhoz SOCKS proxyn keresztül a(z) %d porton? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. + Hozzáférés a kiszolgálókhoz SOCKS-proxyn keresztül a(z) %d porton? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. Elfogadás Elfogadás gombra fent, majd: @@ -29,7 +29,7 @@ Kapcsolatkérés elfogadása? Elfogadás Elfogadás - Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés elküldésre kerül az ismerősök számára. + Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára. További kiemelés híváshiba Csoporttagok letiltása @@ -42,17 +42,17 @@ hivatkozás előnézetének visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. - Az Android Keystore-t a jelmondat biztonságos tárolására használják - lehetővé teszi az értesítési szolgáltatás működését. - Hibás az üzenet hasító értéke + Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. + Érvénytelen az üzenet hasító-értéke Háttér - Megjegyzés: az üzenet- és fájlközvetítő-kiszolgálók SOCKS proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> + Megjegyzés: az üzenet- és fájltovábbító-kiszolgálók SOCKS-proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél. + Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. - Az alkalmazás build száma: %s + Az alkalmazás összeállítási száma: %s Hang- és videóhívások Speciális hálózati beállítások A hangüzenetek küldése engedélyezve van az ismerősei számára. @@ -66,18 +66,17 @@ Alkalmazás verzió Üdvözlőüzenet hozzáadása titkosítás elfogadása %s számára… - " -\nElérhető a v5.1-ben" + \nElérhető az v5.1-es kiadásban Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok - Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. Hívás befejeződött HÍVÁSOK és további %d esemény Cím Csatlakozás folyamatban! Automatikus elfogadás - A háttérszolgáltatás mindig fut - az értesítések megjelennek, amint az üzenetek elérhetővé válnak. + A háttérszolgáltatás mindig fut – az értesítések megjelennek, amint az üzenetek elérhetővé válnak. Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) Mindkét fél küldhet hangüzeneteket. Téves üzenet ID @@ -85,7 +84,7 @@ A hangüzenetek küldése engedélyezve van. Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. Vissza - Kikapcsolható a beállításokban – az értesítések továbbra is megjelenítésre kerülnek amíg az alkalmazás fut.]]> + Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. Hívások a zárolási képernyőn: titkosítás elfogadása… @@ -99,23 +98,23 @@ Az üzenetreakciók küldése engedélyezve van. Fájlelőnézet visszavonása Az összes csoporttag kapcsolatban marad. - Több akkumulátort használ! Az alkalmazás mindig fut a háttérben - az értesítések azonnal megjelennek.]]> + Több akkumulátort használ! Az alkalmazás mindig fut a háttérben – az értesítések azonnal megjelennek.]]> Letiltás adminisztrátor Képelőnézet visszavonása - A jelkód megadása után az összes adat törlésre kerül. + A jelkód megadása után az összes adat törölve lesz. Felkérték a videó fogadására Letiltás - Még néhány dolog + Néhány további dolog Hitelesítés visszavonva A fájlok- és a médiatartalmak küldése engedélyezve van. - Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza! hanghívás félkövér - Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal. - Arab, bulgár, finn, héber, thai és ukrán - köszönet a felhasználóknak és a Weblate-nek. + Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. + Arab, bulgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. Hangüzenetek engedélyezése? - Mindig használjon közvetítő-kiszolgálót + Mindig használjon továbbító-kiszolgálót mindig A hívás már befejeződött! Engedélyezés @@ -123,9 +122,9 @@ Élő csevegési üzenet visszavonása Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra) Hang- és videóhívások - hibás az üzenet hasító értéke + érvénytelen az üzenet hasító-értéke Mindig fut - Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatás után - lehetővé teszi az értesítések fogadását. + Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi az értesítések fogadását. Az összes alkalmazásadat törölve. Legjobb akkumulátoridő. Csak akkor kap értesítéseket, amikor az alkalmazás meg van nyitva. (NINCS háttérszolgáltatás.)]]> Megjelenés @@ -136,11 +135,11 @@ szerző Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra) Mégse - Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem indul el + Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem fog elndúlni Továbbfejlesztett üzenetek - A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra. + A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva. Engedélyezés - Hibás számítógép cím + Érvénytelen számítógépcím Profil hozzáadása Csatolás Alkalmazás jelkód @@ -161,22 +160,22 @@ Mindkét fél küldhet üzenetreakciókat. Mindkét fél tud hívásokat kezdeményezni. Sikertelen hitelesítés - Az összes %s által írt új üzenet elrejtésre kerül! + Az összes %s által írt új üzenet el lesz rejtve! Alkalmazás verzió: v%s A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva hanghívás (nem e2e titkosított) letiltva - Adatbázis-jelmondat megváltoztatása? + Az adatbázis jelmondatának megváltoztatása? kapcsolódva Jelkód megváltoztatása - %s szerepkörét megváltoztatta erre: %s - A fogadó cím megváltoztatása - Változtatás + %s szerepkörét megváltoztatta a következőre: %s + A fogadócím megváltoztatása + Megváltoztatás Jelkód megerősítése Jelszó megerősítése - Csoport szerepkör megváltoztatása? + Tag szerepkörének megváltoztatása? Zárolási mód megváltoztatása Kapcsolódott kapcsolódott @@ -187,10 +186,10 @@ Szerepkör megváltoztatása Kapcsolódva Hitelesítőadatok megerősítése - Megváltoztatja a fogadó címet? - cím megváltoztatva - Önmegsemmisítő mód megváltoztatása - megváltoztatta az Ön szerepkörét erre: %s + Megváltoztatja a fogadócímet? + megváltoztatta a címet az Ön számára + Önmegsemmisítő-mód megváltoztatása + megváltoztatta az Ön szerepkörét a következőre: %s Kapcsolódás Közvetlen kapcsolódás? Kapcsolódás @@ -198,7 +197,7 @@ kapcsolat %1$d az ismerős e2e titkosítással rendelkezik Csoport létrehozása véletlenszerű profillal. - Az ismerős és az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az ismerős és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. Kapcsolódás egyszer használható meghívó-hivatkozással? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül @@ -217,7 +216,7 @@ Kapcsolatkérés elküldve! Kapcsolódás a számítógéphez Kapcsolat - Név helyesbítése erre: %s? + Név helyesbítése a következőre: %s? Időtúllépés kapcsolódáskor Kapcsolódás a következővel: %1$s? Létrehozás @@ -253,7 +252,7 @@ Törölve ekkor: Kínai és spanyol kezelőfelület Nem lehet meghívni az ismerősöket! - A csevegés leállt + A csevegés megállt Sötét Profil létrehozása törölt csoport @@ -272,12 +271,12 @@ Jelenleg támogatott legnagyobb fájl méret: %1$s. Fájl törlése Hamarosan! - cím megváltoztatása nála: %s … + cím megváltoztatása %s számára… Csevegési adatbázis importálva Üzenetek törlése Kiürítés Bezárás gomb - A csevegés leállt + A csevegés megállt (jelenlegi) Témák személyre szabása és megosztása. Csevegési profil törlése? @@ -302,7 +301,7 @@ A fájlok- és a médiatartalmak törlése? befejezett CSEVEGÉSI ADATBÁZIS - Önmegsemmisító jelkód megváltoztatása + Önmegsemmisítő-jelkód megváltoztatása Sorbaállítás létrehozása színezett kapcsolódás… @@ -345,15 +344,15 @@ Üzenetek kiürítése? Adatbázis visszafejlesztése Üzenetek kiürítése - Adatbázis titkosítási jelmondat frissítve lesz. + Az datbázis titkosítási jelmondata frissítve lesz. Kapcsolódás automatikusan Adatbázishiba - Adatbázis titkosítási jelmondat frissül és eltárolásra kerül a beállításokban. + 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. - Az adatbázis-titkosítási jelmondat megváltoztatásra és mentésre kerül a Keystore-ban. - Az adatbázis titkosításra kerül és a jelmondat eltárolásra a beállításokban. + 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 Az eszközön nincs beállítva a képernyőzár. A SimpleX-zár ki van kapcsolva. Letiltás @@ -383,7 +382,7 @@ Adatbázis-jelmondat %d nap Kapcsolat bontva - Az adatbázis egy véletlenszerű jelmondattal van titkosítva, ami megváltoztatható. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet megváltoztathat. %dó %dhét Felfedezés helyi hálózaton keresztül @@ -399,12 +398,12 @@ közvetlen Számítógép %d perc - %d ismerős kiválasztva + %d ismerős kijelölve Engedélyezés %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. %d perc - Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg a jelmondatot. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg. Kézbesítés jelentések letiltása a csoportok számára? nap %d nap @@ -421,9 +420,9 @@ letiltva %d másodperc Az összes fájl törlése - Az adatbázis titkosításra kerül. + Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás - Az adatbázis titkosításra kerül és a jelmondat a Keystore-ban lesz tárolva. + Az adatbázis titkosítva lesz, a jelmondat pedig a Keystore-ban lesz tárolva. Automatikus üzenet törlés engedélyezése? Törlés az adatbázis verziója újabb, mint az alkalmazásé, visszafelé átköltöztetés nem lehetséges a következőhöz: %s @@ -458,9 +457,9 @@ A kézbesítési jelentések le vannak tiltva! Kibontás Hiba az üzenet küldésekor - Jelkód megadása + Adja meg a jelkódot Mindenkinél - Titkosítás újraegyeztetési hiba + Titkosítás-újraegyeztetési hiba Hiba az adatbázis titkosításakor Hiba a csoport törlésekor Kilépés mentés nélkül @@ -478,7 +477,7 @@ Üdvözlöm!\nCsatlakozzon hozzám a SimpleX Chaten keresztül: %s A megjelenített név nem tartalmazhat szóközöket. Csoport - Üdvözlőüzenet megadása… (nem kötelező) + Adja meg az üdvözlőüzenetet… (nem kötelező) Hiba a csevegési adatbázis exportálásakor Hiba a fájl mentésekor Helyi fájlok titkosítása @@ -494,7 +493,7 @@ Végre, megvannak! 🚀 Hiba a csevegés elindításakor A csoport profilja a tagok eszközein tárolódik, nem a kiszolgálókon. - Jelmondat megadása… + Adja meg a jelmondatot… Hiba a felhasználói adatvédelem frissítésekor Titkosít Csoport nem található! @@ -507,12 +506,12 @@ Csoport moderáció Fájl Csoporthivatkozás - titkosítás-újraegyeztetés szükséges ehhez: %s + a titkosítás újraegyeztetése szükséges %s számára Hiba a profilváltáskor! Kísérleti funkciók Engedélyezés (felülírások megtartásával) Adja meg a helyes jelmondatot. - A csoport törlésre kerül az Ön számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza! Adatbázis titkosítása? A zárolási képernyőn megjelenő hívások engedélyezése a Beállításokban. titkosítás elfogadva @@ -524,7 +523,7 @@ Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés. Zárolás engedélyezése SÚGÓ - Teljesen decentralizált - csak a tagok számára látható. + Teljesen decentralizált – csak a tagok számára látható. Fájl: %s Hívás befejezése Hiba a csoporthivatkozás törlésekor @@ -532,16 +531,16 @@ Kapcsolat javítása? Fájlok és médiatartalmak KONZOLHOZ - Sikertelen titkosítás-újraegyeztetés. + Nem sikerült a titkosítást újraegyeztetni. Hiba a felhasználó-profil törlésekor Csoporttag általi javítás nem támogatott - Üdvözlőüzenet megadása… + Adja meg az üdvözlőüzenetet… Titkosított adatbázis - Jelszó megadása a keresőben + Adja meg a jelszót a keresőben A fájl akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Fájl letöltése A csevegés betöltése sikertelen - Kiszolgáló megadása kézzel + Adja meg a kiszolgálót kézzel A fájl akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! Hiba a csoporthivatkozás létrehozásakor A galériából @@ -556,7 +555,7 @@ Hiba a tag(ok) hozzáadásakor Fájl A tagok küldhetnek fájlokat és médiatartalmakat. - Törlés ennyi idő után + Törlés ennyi idő után: Hiba a beállítás megváltoztatásakor Hiba a csoporthivatkozás frissítésekor a csoport törölve @@ -569,7 +568,7 @@ Hiba a tag eltávolításakor befejeződött A csoport üdvözlőüzenete - Csoport neve: + Adja meg a csoport nevét: Hiba a meghívó küldésekor Adjon meg egy nevet: Hiba a felhasználó jelszavának mentésekor @@ -579,7 +578,7 @@ A csoportmeghívó már nem érvényes, a küldője eltávolította. A csoport teljes neve: súgó - Önmegsemmisítő jelkód engedélyezése + Önmegsemmisítő-jelkód engedélyezése KÍSÉRLETI Hiba a cím megváltoztatásának megszakításakor Hiba a fájl fogadásakor @@ -595,7 +594,7 @@ Tovább csökkentett akkumulátor-használat Hiba a csevegés megállításakor titkosítás rendben %s számára - A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az összes tag számára – ez a művelet nem vonható vissza! Titkosítás javítása az adatmentések helyreállítása után. Hiba a csevegési adatbázis törlésekor Teljes hivatkozás @@ -610,7 +609,7 @@ engedélyezve Hiba a részletek betöltésekor Hiba történt a kapcsolatkérés elfogadásakor - titkosítás újraegyeztetése engedélyezett %s számára + a titkosítás újraegyeztetése engedélyezve van %s számára titkosítás-újraegyeztetés szükséges Rejtett csevegési profilok Fájlok és médiatartalmak @@ -663,14 +662,14 @@ Hiba az ismerőssel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) beolvashatja a QR-kódot a videohívásban, vagy az ismerőse megoszthat egy meghívó-hivatkozást.]]> - Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül! + Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> Megerősítés esetén az üzenetküldő-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. A kép akkor érkezik meg, amikor a küldője befejezte annak feltöltését. QR-kód beolvasásával.]]> A kapott SimpleX Chat-meghívó-hivatkozását megnyithatja a böngészőjében: - Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: + Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot: Megtalált számítógép Számítógépek A markdown használata @@ -679,7 +678,7 @@ Hordozható eszközök leválasztása Különböző nevek, profilképek és átvitel-izoláció. Elutasítás esetén a feladó NEM kap értesítést. - Szerepkörválasztás bővítése + 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! meghíva Érvénytelen kapcsolattartási hivatkozás @@ -687,16 +686,16 @@ nincsenek részletek Nem fogadott hívás Világos - Az üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az üzenet törölve lesz – ez a művelet nem vonható vissza! Markdown súgó új üzenet Régi adatbázis-archívum Speciális beállítások Nincs kézbesítési információ moderált - A tag eltávolítása a csoportból - ez a művelet nem vonható vissza! + A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy az XFTP-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. - Nincs kiválasztva ismerős + Nincs kijelölve ismerős Nincsenek fogadott, vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben @@ -776,7 +775,7 @@ érvénytelen adat Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. - A fájl betöltése + A fájl betöltése… Nincs hozzáadandó ismerős Üzenetvázlat meghívta, hogy csatlakozzon @@ -793,9 +792,9 @@ Hordozható eszköz társítása Értesítési szolgáltatás Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. - A felhasználói profilok, névjegyek, csoportok és üzenetek csak az eszközön kerülnek tárolásra a kliensen belül. + A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás - Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat. + Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat. Nincsenek előzmények Érvénytelen QR-kód Megjelölés olvasottként @@ -803,7 +802,7 @@ Megjelölés olvasatlanként Továbbiak Bejelentkezés hitelesítőadatokkal - érvénytelen üzenet formátum + érvénytelen üzenet-formátum Csatlakozás Az értesítések az alkalmazás elindításáig nem fognak működni kikapcsolva` @@ -819,7 +818,7 @@ Menük és figyelmeztetések Tagok meghívása csatlakozás mint %s - Nincs kiválasztva csevegés + Nincs kijelölve csevegés Csak helyi profiladatok inkognitó egy egyszer használható hivatkozáson keresztül Moderálva lett ekkor: %s @@ -850,7 +849,7 @@ moderálva lett %s által Győződjön meg arról, hogy a fájl helyes YAML-szintaxist tartalmaz. Exportálja a témát, hogy legyen egy példa a témafájl szerkezetére. dőlt - Érvénytelen fájl elérési útvonal + Érvénytelen a fájl elérési útvonala Csatlakozik a csoporthoz? nincs e2e titkosítás Új adatbázis-archívum @@ -878,7 +877,7 @@ SimpleX-kapcsolattartási-cím Megállítás Előre beállított kiszolgáló - Új csevegés kezdése + Új csevegés indítása Bárki üzemeltethet kiszolgálókat. Megnyitás Protokoll időtúllépése @@ -889,7 +888,7 @@ a csoporthivatkozáson keresztül Időtartam a PING-ek között Eltűnő üzenet küldése - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód Mentés és a csoportprofil frissítése Adatvédelem Profil SimpleX-címe @@ -899,11 +898,11 @@ Csak Ön tud hangüzeneteket küldeni. Frissítés Videó elküldve - Adatbázis-jelmondat megváltoztatása + Az adatbázis jelmondatának megváltoztatása Alkalmazás beállítások megnyitása - A jelkód nem változott! + A jelkód nem változott meg! Frissítés - Kiválasztás + Kijelölés Csak Ön tud hívásokat indítani. Biztonságos sorbaállítás Értékelje az alkalmazást @@ -923,7 +922,7 @@ TÉMÁK Túl sok videó! Üdvözöljük! - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód (beolvasás, vagy beillesztés a vágólapról) Várakozás a videóra Válasz @@ -952,7 +951,7 @@ Eltávolítás Keresés Titkosítás újraegyeztetése? - Az önmegsemmisítési jelkód engedélyezve! + Az önmegsemmisítő-jelkód engedélyezve! Biztonsági kiértékelés Cím Üzenet elküldése @@ -991,7 +990,7 @@ %s, %s és %d tag Csevegési szolgáltatás megállítása SimpleX-hivatkozások - Az elküldött üzenetek törlésre kerülnek a beállított idő után. + Az elküldött üzenetek törölve lesznek a beállított idő után. Némítás megszüntetése Elküldve ekkor: %s Jelenlegi profil használata @@ -1015,7 +1014,7 @@ Rendszer Elküldés Biztonsági kód - Adja meg a helyes, jelenlegi jelmondatát. + Adja meg a helyes, jelenlegi jelmondatot. Az elküldött üzenetek végleges törlése le van tiltva. Az üzenetreakciók küldése le van tiltva. Véletlenszerű jelmondat használata @@ -1034,12 +1033,12 @@ Eltávolítás A jelkód beállítva! Elküldött üzenet - Ismerősök kiválasztása + Ismerősök kijelölése ismeretlen üzenetformátum Kiszolgálók mentése Üdvözlőüzenet mp - A profilfrissítés elküldésre került az ismerősök számára. + A profilfrissítés el lesz küldve az Ön ismerősei számára. Egyszerűsített inkognitómód Üdvözlőüzenet mentése? Új csevegési fiók létrehozásához indítsa újra az alkalmazást. @@ -1075,9 +1074,9 @@ Hívások nem sikerült elküldeni KEZELŐFELÜLET SZÍNEI - Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. + Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. Másodlagos - SOCKS PROXY + SOCKS-PROXY Mentés Újraindítás SMP-kiszolgálók @@ -1098,13 +1097,13 @@ Biztonsági kód megtekintése Tag feloldása? A küldő törölhette a kapcsolatkérést. - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat Saját SMP-kiszolgálók A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása - egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívó-hivatkozáson keresztül Csoportbeállítások megadása - ezen keresztül: %1$s + a következőn keresztül: %1$s igen Hangüzenet Társítás számítógéppel @@ -1135,7 +1134,7 @@ Csoport megnyitása Elküldve ekkor: A hangüzenetek küldése le van tiltva. - Szobák utolsó üzeneteinek megjelenítése a listanézetben + Legutóbbi üzenet előnézetének megjelenítése Az előre beállított kiszolgáló címe Rendszeres értesítések letiltva! A jelkód megváltozott! @@ -1145,7 +1144,7 @@ simplexmq: v%s (%2s) Szétkapcsolás Véletlenszerű profil - Hibás jelmondat! + Érvénytelen jelmondat! Az üzenetreakciók küldése le van tiltva. Rendszer olvasatlan @@ -1174,7 +1173,7 @@ indítás… Leállítás elküldve - SOCKS proxy használata + SOCKS-proxy használata Élő üzenet küldése Adatvédelem újraértelmezve Hangüzenet… @@ -1187,7 +1186,7 @@ Koppintson a Mentés és az ismerős értesítése Elutasított hívás - SOCKS proxy beállítások + SOCKS-proxy beállítások QR-kód Titkosítás újraegyeztetése Eltávolítás @@ -1199,14 +1198,14 @@ A fájlok- és a médiatartalmak küldése le van tiltva. Fájl megosztása… Mentés - közvetítő-kiszolgálón keresztül + egy továbbító-kiszolgálón keresztül Megosztás megállítása Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása Beállítások mentése? Nincsenek felhasználó-azonosítók. A közvetlen üzenetek küldése a tagok között le van tiltva. - SOCKS proxy használata? + SOCKS-proxy használata? Hangszóró kikapcsolva hét Megjelenítés @@ -1258,15 +1257,15 @@ Felfedés Fogadott üzenetbuborék színe Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) - Az önmegsemmisítési jelkód megváltozott! + Az önmegsemmisítő-jelkód megváltozott! SimpleX Chat-kiszolgálók használatban. SimpleX Chat-kiszolgálók használata? Csevegési profil felfedése Videók és fájlok 1Gb méretig TCP kapcsolat időtúllépése - A(z) %1$s nevű profiljának SimpleX-címe megosztásra fog kerülni. + A(z) %1$s nevű profiljának SimpleX-címe meg lesz osztva. Ön már kapcsolódott a következőhöz: %1$s. - Jelenlegi csevegési adatbázis TÖRLÉSRE és FELCSERÉLÉSRE kerül az importált által!\nEz a művelet nem vonható vissza - profiljai, ismerősei, csevegési üzenetei és fájljai véglegesen törölve lesznek. + A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, ismerősei, csevegési üzenetei és fájljai véglegesen törölve lesznek. Ötletek és javaslatok Figyelmeztetés: néhány adat elveszhet! Koppintson ide az új csevegés indításához @@ -1276,16 +1275,16 @@ Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat hitelesítése fájlok küldése egyelőre még nem támogatott - cím megváltoztatva nála: %s + Ön megváltoztatta a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. videóhívás (nem e2e titkosított) Alkalmazás új kapcsolatokhoz - Az új üzenetek rendszeresen letöltésre kerülnek az alkalmazás által – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem kerülnek elküldésre a kiszolgálóknak. + Az új üzeneteket az alkalmazás rendszeresen lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése - kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási cím-hivatkozáson keresztül a SimpleX a háttérben fut a push értesítések használata helyett.]]> Az ismerősének online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt az ismerőskérelmet és eltávolíthatja az ismerőst (ezt később ismét megpróbálhatja egy új hivatkozással). A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonságimentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. @@ -1299,10 +1298,10 @@ Értesítések fogadásához adja meg az adatbázis jelmondatát A teszt a(z) %s lépésnél sikertelen volt. Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. - Az üzenet az összes tag számára törlésre kerül. + Az üzenet az összes tag számára törölve lesz. A videó nem dekódolható. Próbálja ki egy másik videóval, vagy lépjen kapcsolatba a fejlesztőkkel. Ez a szöveg a „Beállításokban” érhető el - A profilja elküldésre kerül az ismerőse számára, akitől ezt a hivatkozást kapta. + A profilja el lesz küldve az ismerőse számára, akitől ezt a hivatkozást kapta. Az alkalmazás 1 perc után bezárható a háttérben. meghívást kapott a csoportba Engedélyezze a következő párbeszédpanelen az azonnali értesítések fogadásához.]]> @@ -1316,7 +1315,7 @@ Az alkalmazás összeomlott Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat. A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel. - Érvénytelen fájl elérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. + Érvénytelen fájlelérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %1$s). A fájl fogadása le fog állni. @@ -1332,35 +1331,35 @@ Ez nem egy érvényes kapcsolattartási hivatkozás! A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal. A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől. - Ez a beállítás csak a jelenlegi csevegési profiljában lévő üzenetekre vonatkozik + Ez a beállítás csak az Ön jelenlegi csevegő-profiljában lévő üzenetekre vonatkozik Meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Meghívást kapott a csoportba Az ismerőse a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. - Az ismerősei és az üzenetek (kézbesítés után) nem kerülnek tárolásra a SimpleX-kiszolgálókon. + Az ismerősei és az üzenetek (kézbesítés után) nem a SimpleX-kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> - A csevegési profilja elküldésre kerül\naz ismerőse számára + A csevegési profilja el lesz küldve\naz ismerőse számára Egy olyan ismerősét próbálja meghívni, akivel inkognitó-profilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban %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 + 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ó - Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, ha a kapcsolatké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.]]> - Biztonságos kvantumrezisztens-protokollon keresztül. + Biztonságos kvantumálló-protokollon keresztül. - 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. - Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> + Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS-proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később - A profilja az eszközén van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját. - Ön megváltoztatta %s szerepkörét erre: %s + A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. + Ön megváltoztatta %s szerepkörét a következőre: %s Csoportmeghívó elutasítva Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. (a megosztáshoz az ismerősével) @@ -1368,122 +1367,122 @@ Átvitel-izoláció módjának frissítése? Átvitel-izoláció 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. + A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. Közvetlen internet kapcsolat használata? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. - A fő csevegési profilja elküldésre kerül a csoporttagok számára + A fő csevegési profilja el lesz küldve a csoporttagok számára Később engedélyezheti őket az „Adatvédelem és biztonság” menüben. - Rejtett profilja felfedéséhez írja be a teljes jelszavát a keresőmezőbe a „Csevegési profilok” menüben. + Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a „Csevegési profilok” menüben. Fejlesztés és a csevegés megnyitása Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak. %1$s nevű csoport tagja.]]> - cím megváltoztatva + Ön megváltoztatta a címet Az ismerősei engedélyezhetik a teljes üzenet törlést. - A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra. + A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. Ha engedélyezni szeretné a hordozható eszköz-alkalmazás társítását a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, miután engedélyezte azt - A profilja, az ismerősei és az elküldött üzenetei az eszközén kerülnek tárolásra. + A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> Ez a karakterlánc nem egy meghívó-hivatkozás! - Új csevegés kezdése + Új csevegés indításához A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül! Nem veszíti el az ismerőseit, ha később törli a címét. A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! - saját szerepköre megváltozott erre: %s + Ön megváltoztatta a saját szerepkörét a következőre: %s A csevegési szolgáltatás elindítható a „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával. Kód hitelesítése a hordozható eszközön - Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. + Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. 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. - Inkognitómód használata kapcsolódáskor. - Megoszthat egy hivatkozást vagy QR-kódot - így bárki csatlakozhat a csoporthoz. Ha a csoport később törlésre kerül, akkor nem fogja elveszíteni annak tagjait. - Csatlakozott ehhez a csoporthoz + 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 %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. Ön irányítja csevegését! Kód hitelesítése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. - A kapcsolatkérés elküldésre kerül ezen csoporttag számára. + A kapcsolatkérés el lesz küldve ezen csoporttag számára. Inkognitó-profil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott. Már küldött egy kapcsolatkérést ezen a címen keresztül! Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: %s. Amikor az emberek kapcsolatot kérnek, Ön elfogadhatja vagy elutasíthatja azokat. Megjelenítendő üzenet beállítása az új tagok számára! - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! - A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül. + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz. Protokoll időtúllépése KB-onként Az adatbázis-jelmondat megváltoztatására tett kísérlet nem fejeződött be. - Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet. - A profilja csak az ismerőseivel kerül megosztásra. + Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + A profilja csak az ismerőseivel van megosztva. Néhány kiszolgáló megbukott a teszten: Koppintson ide a csatlakozáshoz - Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerülnek. Az alacsony felbontású képek viszont megmaradnak. + Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. A kézbesítési jelentések engedélyezve vannak %d ismerősnél - Küldés ezen keresztül: - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! - A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára. + Küldés a következőn keresztül: + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára. Bluetooth támogatás és további fejlesztések. Ez a funkció még nem támogatott. Próbálja meg a következő kiadásban. A bejegyzés frissítve: %s Tagok meghívásának kihagyása Ezek felülbírálhatók az ismerős- és csoportbeállításokban. Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! - A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEz később megváltoztatható. + A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEzt később megváltoztathatja. Koppintson ide az inkognitóban való kapcsolódáshoz Jelmondat beállítása az exportáláshoz 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 - hozzájárulás a Weblate-en! - A közvetítő-kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet. + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + 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. Rendszerhitelesítés helyetti beállítás. - A fogadó cím egy másik kiszolgálóra változik. A címváltoztatás a feladó online állapotba kerülése után fejeződik be. + A fogadócím egy másik kiszolgálóra változik. A cím megváltoztatá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. Jelmondat mentése a Keystore-ba - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! 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 kerülnek elküldésre. + 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 közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. + A továbbító-kiszolgáló megvédi az IP-címet, 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 eltávolításra kerülnek. + A mentett WebRTC ICE-kiszolgálók el lesznek távolítva. A kézbesítési jelentések engedélyezve vannak %d csoportban - A szerepkör meg fog változni erre: %s. A csoport tagjai értesítést fognak kapni. + A szerepkör meg fog változni a következőre: %s. A csoport tagjai értesítést fognak kapni. Profil és kiszolgálókapcsolatok Egy üzenetküldő- és alkalmazásplatform, amely védi az adatait és biztonságát. A profil aktiválásához koppintson az ikonra. A kézbesítési jelentések le vannak tiltva %d ismerősnél Munkamenet kód - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! Kis csoportok (max. 20 tag) Az Ön által elfogadott kérelem vissza lesz vonva! - Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja + Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet A KÉZBESÍTÉSI JELENTÉSEKET A KÖVETKEZŐ CÍMRE KELL KÜLDENI - A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. - Az eszköz neve megosztásra kerül a társított hordozható eszközön használt alkalmazással. + A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. + Az eszköz neve meg lesz osztva a társított hordozható eszközön használt alkalmazással. A címzettek a beírás közben látják a szövegváltozásokat. Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni. - A jelmondat a beállításokban egyszerű szövegként kerül tárolásra, miután megváltoztatta vagy újraindította az alkalmazást. - A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói - Fogadás ezen keresztül: + A jelmondat a beállításokban egyszerű szövegként lesz tárolva, miután megváltoztatta azt vagy újraindította az alkalmazást. + A jelenlegi csevegőprofiljához tartozó új kapcsolatok kiszolgálói + Fogadás a következőn keresztül: Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez. - A szerepkör meg fog változni erre: %s. A tag új meghívót fog kapni. - profilkép helyőrző + A szerepkör meg fog változni a következőre: %s. A tag új meghívót fog kapni. + profilkép helyőrzője A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! - Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai véglegesen törlésre kerülnek. + Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek. A bejegyzés frissítve Használati útmutatóban olvasható.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban Az előző üzenet hasító értéke különbözik. - Ezek a beállítások csak a jelenlegi profiljára vonatkoznak + Ezek a beállítások csak a jelenlegi csevegő-profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub tárolónkban.]]> hiba a tartalom megjelenítésekor hiba az üzenet megjelenítésekor Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”. - Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás. A beillesztett szöveg nem egy SimpleX-hivatkozás. A meghívó-hivatkozását újra megtekintheti a kapcsolat részleteinél. @@ -1495,18 +1494,18 @@ Koppintson ide a hivatkozás beillesztéséhez Ismerős hozzáadása: új meghívó-hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.]]> Csoport létrehozása: új csoport létrehozásához.]]> - A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt. - Az előzmények nem kerülnek elküldésre az új tagok számára. + A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. + Az előzmények nem lesznek elküldve az új tagok számára. Újrapróbálkozás A kamera nem elérhető Az utolsó 100 üzenet elküldése az új tagok számára. - Az előzmények ne kerüljenek elküldésre az új tagok számára. + Az előzmények ne legyenek elküldve az új tagok számára. Vagy mutassa meg ezt a kódot Kamera hozzáférés engedélyezése Fel nem használt meghívó megtartása? Ennek az egyszer használható meghívó-hivatkozásnak a megosztása Új csevegés - Csevegések betöltése… + A csevegések betöltése… Hivatkozás létrehozása… Vagy QR-kód beolvasása Érvénytelen QR-kód @@ -1516,7 +1515,7 @@ Kritikus hiba Belső hiba Nem támogatott a számítógép által használt alkalmazás verziója. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja - Számítógép-alkalmazásban hibás meghívókód szerepel + A számítógépes alkalmazásban érvénytelen meghívókód szerepel Számítógép elfoglalt Számítógép inaktív Csevegés újraindítása @@ -1531,7 +1530,7 @@ %s hordozható eszköz által használt alkalmazás verziója nem támogatott. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja]]> %s nevű hordozható eszközzel]]> Érvénytelen megjelenítendő név! - Ez a megjelenített név érvénytelen. Válasszon egy másik nevet. + Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet. %s nevű hordozható eszközzel, a következő okból: %s]]> Kapcsolat bontva a következő okból: %s %s hordozható eszköz nem található]]> @@ -1546,13 +1545,13 @@ %s hordozható eszköz elfoglalt]]> Már nem tag %1$s ismeretlen állapot - %1$s megváltoztatta a nevét erre: %2$s + %1$s megváltoztatta a nevét a következőre: %2$s eltávolította a kapcsolattartási címet eltávolította a profilképét új kapcsolattartási cím beállítása új profilképet állított be frissített profil - %1$s megváltoztatta a nevét erre: %2$s + %1$s megváltoztatta a nevét a következőre: %2$s Privát jegyzetek Hiba a privát jegyzetek törlésekor Hiba az üzenet létrehozásakor @@ -1560,7 +1559,7 @@ Létrehozva ekkor: Mentett üzenet Megosztva ekkor: %s - Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza! + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Továbbfejlesztett üzenetkézbesítés Csatlakozás csoportos beszélgetésekhez Hivatkozás beillesztése a kapcsolódáshoz! @@ -1598,7 +1597,7 @@ Feltöltés megerősítése Hiba az adatbázis törlésekor Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra. + Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra. Alkalmazásadatok átköltöztetése Adatbázis archiválása Átköltöztetés visszavonása @@ -1610,13 +1609,13 @@ Archívum letöltése Letöltési hivatkozás részletei Engedélyezés a közvetlen csevegésekben (BÉTA)! - Jelmondat megadása + Adja meg a jelmondatot Hiba a beállítások mentésekor Hiba az archívum letöltésekor Hiba az archívum feltöltésekor Hiba a jelmondat hitelesítésekor: Az exportált fájl nem létezik - A fájl törlésre került, vagy érvénytelen hivatkozás + A fájl törölve lett, vagy érvénytelen a hivatkozás %s letöltve Archívum importálása Feltöltés előkészítése @@ -1651,9 +1650,9 @@ Átköltöztetés ide Eszköz átköltöztetése Átköltöztetés egy másik eszközre - Figyelmeztetés: az archívum törlésre kerül.]]> + Figyelmeztetés: az archívum törölve lesz.]]> Átköltöztetés egy másik eszközről - Kvantumrezisztens titkosítás + Kvantumálló titkosítás Megpróbálhatja még egyszer. Átköltöztetés befejezve Átköltöztetés egy másik eszközre QR-kód használatával. @@ -1661,12 +1660,12 @@ Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését.]]> Megpróbálhatja még egyszer. Érvénytelen hivatkozás - végpontok közötti kvantumrezisztens titkosítás + végpontok közötti kvantumálló titkosítás 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 kvantumrezisztens tikosítással védett. + Ez a csevegés végpontok közötti kvantumálló tikosítással védett. végpontok közötti titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> - végpontok közötti kvantumrezisztens titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> + végpontok közötti kvantumálló titkosítással sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve.]]> Hiba 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 @@ -1719,16 +1718,16 @@ Bejövő hívás csengőhangja Az üzenet forrása titokban marad. Profilképek - Profilkép alakzat + Profilkép alakzata Négyzet, kör vagy bármi a kettő között. Célkiszolgáló-hiba: %1$s Továbbító kiszolgáló: %1$s\nHiba: %2$s - Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt. + Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. A kiszolgáló verziója nem kompatibilis a hálózati beállításokkal. - Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött. + Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. Továbbító-kiszolgáló: %1$s\nCélkiszolgáló hiba: %2$s Hiba: %1$s - Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket. + Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. Üzenetkézbesítési figyelmeztetés A kiszolgáló címe nem kompatibilis a hálózati beállításokkal. Soha @@ -1740,7 +1739,7 @@ Nem Nem védett Igen - Ne használjon privát útválasztást. + NE használjon privát útválasztást. Privát útválasztás Használjon privát útválasztást ismeretlen kiszolgálókkal. Mindig használjon privát útválasztást. @@ -1751,13 +1750,13 @@ Ü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. - Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Tor vagy VPN nélkül az IP-címe látható lesz 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). + 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 IP-címe látható lesz az XFTP-közvetítő-kiszolgálók számára:\n%1$s. + Tor vagy VPN nélkül az IP-címe látható lesz az XFTP-továbbító-kiszolgálók számára:\n%1$s. Összes színmód Fekete Színmód @@ -1784,12 +1783,12 @@ Válaszüzenet-buborék színe Alapértelmezett téma beállítása Rendszer - Háttérkép kiemelés + Háttérkép kiemelőszíne Háttérkép háttérszíne További kiemelés 2 Alkalmazás téma Perzsa kezelőfelület - Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. + Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. Ismeretlen kiszolgálókról származó fájlok megerősítése. Javított üzenetkézbesítés Alkalmazás témájának visszaállítása @@ -1804,12 +1803,12 @@ nincs Kézbesítési hibák felderítése a kiszolgáló sorbaállítási információi: %1$s\n\nutoljára kézbesített üzenet: %2$s - Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött. + Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. Ideiglenesfájl-hiba Üzenetállapot Üzenetállapot: %s Fájlhiba - A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták. + A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták. Fájlkiszolgáló-hiba: %1$s Fájlállapot Fájlállapot: %s @@ -1817,7 +1816,7 @@ Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel. Nem lehet üzenetet küldeni - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag @@ -1835,7 +1834,7 @@ Kapcsolódás Hibák Függőben - Statisztikagyűjtés kezdete: %s.\nAz összes adat biztonságban van az eszközén. + Statisztikagyűjtés kezdete: %s.\nAz összes adat privát módon van tárolva az Ön eszközén. Elküldött üzenetek Proxyzott kiszolgálók Újrakapcsolódás a kiszolgálókhoz? @@ -1846,7 +1845,7 @@ Visszaállítás Az összes statisztika visszaállítása Az összes statisztika visszaállítása? - A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza! + A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! Részletes statisztikák Letöltve lejárt @@ -1871,17 +1870,17 @@ Törlési hibák Méret Feltöltött fájlok - Letöltött fájltöredékek + Letöltött töredékek Letöltött fájlok Kiszolgáló-beállítások megnyitása Kiszolgáló címe Feltöltési hibák - Nyugtázva - Nyugtázott hibák + Visszaigazolt + Visszaigazolási hibák próbálkozások - Törölt fájltöredékek + Törölt töredékek Összes profil - Feltöltött fájltöredékek + Feltöltött töredékek Elkészült Kapcsolódott kiszolgálók Konfigurált XFTP-kiszolgálók @@ -1906,7 +1905,7 @@ Munkamenetek átvitele Összes kapcsolat Statisztikák - Információk megjelenítése ehhez: + Információk megjelenítése a következőhöz: A kiszolgáló verziója nem kompatibilis az alkalmazással: %1$s. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. Aktív kapcsolatok száma @@ -1919,15 +1918,15 @@ Frissítések keresése Frissítések keresése Alkalmazásfrissítés letöltése, ne zárja be az alkalmazást - Letöltés - %s (%s) + Letöltés – %s (%s) Sikeresen telepítve Frissítés telepítése Fájl helyének megnyitása Indítsa újra az alkalmazást. Emlékeztessen később - Hagyja ki ezt a verziót + Ezen verzió kihagyása Ha értesítést szeretne kapni az új kiadásokról, kapcsolja be a stabil vagy béta verziók időszakos ellenőrzését. - Frissítés érhető el: %s + Új verzió érhető el: %s A frissítés letöltése megszakítva Béta Letiltás @@ -1945,7 +1944,7 @@ A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. hívás - Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza! + Az ismerős törölve lesz – ez a művelet nem vonható vissza! Csak a beszélgetés törlése megnyitás Beszélgetés törölve! @@ -1977,13 +1976,13 @@ Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást. A(z) %1$s nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. Üzenet… - Kiválasztás + Kijelölés Az üzenetek az összes tag számára moderáltként lesznek megjelölve. - Nincs kiválasztva semmi + Nincs kijelölve semmi Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Törli a tagok %d üzenetét? - %d kiválasztva - Az üzenetek az összes tag számára törlésre kerülnek. + %d kijelölve + Az üzenetek az összes tag számára törölve lesznek. Csevegési adatbázis exportálva Kapcsolatok- és kiszolgálók állapotának megjelenítése. Kapcsolódjon gyorsabban az ismerőseihez. @@ -1994,8 +1993,8 @@ Védi az IP-címét és a kapcsolatait. Könnyen elérhető eszköztár Üzenetkiszolgálók - SOCKS proxy - Néhány fájl nem került exportálásra: + SOCKS-proxy + Néhány fájl nem lett exportálva: Az exportált adatbázist átköltöztetheti. Mentés és újrakapcsolódás Használja az alkalmazást egy kézzel. @@ -2004,11 +2003,11 @@ Az exportált archívumot elmentheti. Tippek visszaállítása Csevegőlista átváltása: - Ezt a „Megjelenés” menüben módosíthatja. + Ezt a „Megjelenés” menüben változtathatja meg. Új médiabeállítások Lejátszás a csevegési listából. Elhomályosítás a jobb adatvédelemért. - Automatikus frissítés + Automatikus alkalmazás-frissítés Létrehozás Új verziók letöltése a GitHubról. Betűméret növelése. @@ -2019,13 +2018,13 @@ Ellenőrizze, hogy a SimpleX-hivatkozás helyes-e. Hiba a profilváltáskor A kapcsolata át lett helyezve ide: %s, de egy váratlan hiba történt a profilra való átirányításkor. - Az üzenetek törlésre kerülnek - ez a művelet nem vonható vissza! + Az üzenetek törölve lesznek – ez a művelet nem vonható vissza! Archívum eltávolítása? - A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról. + A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. CSEVEGÉSI ADATBÁZIS Profil megosztása Rendszerbeállítások használata - Csevegési profil kiválasztása + Csevegési profil kijelölése Ne használja a hitelesítőadatokat proxyval. Különböző proxy-hitelesítőadatok használata az összes profilhoz. Különböző proxy-hitelesítőadatok használata az összes kapcsolathoz. @@ -2049,7 +2048,7 @@ %1$s üzenet nem lett továbbítva %1$s üzenet továbbítása? Üzenetek továbbítása fájlok nélkül? - Az üzeneteket törölték miután kiválasztotta őket. + Az üzeneteket törölték miután kijelölte őket. %1$s üzenet mentése Hiba az üzenetek továbbításakor Hang elnémítva @@ -2093,12 +2092,12 @@ Kézbesítetlen üzenetek A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van. Nincs üzenet - Ez az üzenet törlésre került vagy még nem érkezett meg. + Ez az üzenet törölve lett vagy még nem érkezett meg. Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. Egyszer használható meghívó-hivatkozás megosztása egy baráttal - egyetlen ismerőssel használható - személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> + egyetlen ismerőssel használható – személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. Kapcsolatbiztonság A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. @@ -2111,7 +2110,7 @@ Hálózati üzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőt használ. Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. - Válassza ki a használni kívánt hálózati üzemeltetőket. + Jelölje ki a használni kívánt hálózati üzemeltetőket. Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. @@ -2120,7 +2119,7 @@ Folytatás Feltételek felülvizsgálata Elfogadott feltételek - A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltetők számára: %s. + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %s. Az Ön kiszolgálói %s.]]> %s.]]> @@ -2131,7 +2130,7 @@ A feltételek ekkor lesznek elfogadva: %s. Kiszolgálók használata %s használata - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: %s.]]> %s.]]> %s.]]> @@ -2166,14 +2165,14 @@ Javított csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése - Az Ön jelenlegi csevegőprofiljához tartozó új fájlok kiszolgálói + A jelenlegi csevegőprofiljához tartozó új fájlok kiszolgálói Vagy archívumfájl importálása Távoli hordozható eszközök Xiaomi eszközök: engedélyezze az automatikus indítást a rendszerbeállításokban, hogy az értesítések működjenek.]]> A küldéshez másolhatja és csökkentheti az üzenet méretét. - Adja hozzá csapattagjait a beszélgetésekhez. + Adja hozzá a munkatársait a beszélgetésekhez. Üzleti cím - végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumrezisztens titkosítással is rendelkeznek.]]> + végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek.]]> Hogyan segíti az adatvédelmet Nincs háttérszolgáltatás Értesítések és akkumulátor @@ -2183,17 +2182,17 @@ Csevegés törlése Meghívás a csevegésbe Barátok hozzáadása - Csapattagok hozzáadása - A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni! - A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni! + Munkatársak hozzáadása + A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza! + A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! Csevegés törlése? Csevegés elhagyása - Csak a csevegés tulajdonosai módosíthatják a beállításokat. + Csak a csevegés tulajdonosai változtathatják meg a beállításokat. Könnyen elérhető csevegési eszköztár - A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni! + A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! Csevegés A szerepkör meg fog változni a következőre: %s. A csevegés tagjai értesítést fognak kapni. - Az Ön csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára + A csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára A tagok közötti közvetlen üzenetek le vannak tiltva. Üzleti csevegések Az Ön ügyfeleinek adatvédelme. @@ -2209,4 +2208,91 @@ kérelmezve a kapcsolódáshoz Az üzemeltetőkről A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba. - \ No newline at end of file + A titkosítás újraegyeztetése folyamatban van. + A kapcsolat titkosítása újraegyeztetést igényel. + Javítás + Kapcsolat javítása? + Naplózás engedélyezése + Hiba az adatbázis mentésekor + áthúzott + A következő csevegési profil törlése: + Üzenetek törlése ennyi idő után: + a + b + A kapcsolat nem áll készen. + Megnyitás a következővel: %s + Lista + Csoportok + Lista hozzáadása + Összes + Hozzáadás listához + Hiba a csevegési lista létrehozásakor + Hiba a csevegési lista betöltésekor + Hiba a csevegési lista frissítésekor + Üzleti + Ismerősök + Kedvencek + Nincsenek csevegések + Nem találhatók csevegések + Nincsenek olvasatlan csevegések + Lista létrehozása + Lista mentése + Az összes csevegés el lesz távolítva a következő listáról, és a lista is törlődik: %s + Törlés + Lista törlése? + Szerkesztés + Listanév… + Az összes lista nevének és emodzsiának különbözőnek kell lennie. + Nincsenek csevegések a(z) %s nevű listában. + Jegyzetek + Lista megváltoztatása + Elrendezés megváltoztatása + Hiba a jelentés létrehozásakor + Hiba a beállítások mentésekor + A jelentés archiválva lesz az Ön számára. + Tartalom jelentése: csak a csoport moderátorai látják. + Archívum + Jelentés archiválása? + archivált jelentés + moderátor + Közösségi irányelvek megsértése + Kéretlen tartalom jelentése: csak a csoport moderátorai látják. + Csak a küldő és a moderátorok látják + Csak Ön és a moderátorok látják + Jelentés indoklása? + Kéretlen tartalom + Egyéb indoklás + Kifogásolt tartalom + Kifogásolt profil + Jelentés + Tag profiljának jelentése: csak a csoport moderátorai látják. + Egyéb jelentés: csak a csoport moderátorai látják. + Szabálysértés jelentése: csak a csoport moderátorai látják. + Jelentés archiválása + Jelentés törlése + Tagok jelentései + 1 jelentés + Jelentések + %s által archivált jelentés + %d jelentés + Tagok archivált jelentései + Kéretlen tartalom + A tartalom sérti a használati feltételeket + A kapcsolat le van tiltva + A kiszolgáló üzemeltetője letiltotta a fájlt:\n%1$s. + A kiszolgáló üzemeltetője letiltotta a kapcsolatot:\n%1$s. + Mindig kérdezzen rá + Igen + Hivatkozás megnyitása + Hivatkozás megnyitása a csevegési listából + Nem + Webhivatkozás megnyitása? + Csevegési név beállítása… + Automatikus üzenettörlés letiltása? + Üzenetek törlésének letiltása + Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. + 1 év + alapértelmezett (%s) + Csevegési üzenetek törlése az Ön eszközéről. + Automatikus üzenettörlés módosítása? + Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + 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 eeedcaa450..8d38995590 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -216,7 +216,7 @@ TIdak pernah Mati Hanya 10 video dapat dikirim pada saat bersamaan - enkripsi ujung-ke-ujung 2 lapis.]]> + Hanya perangkat klien yang menyimpan profil pengguna, kontak, grup, dan pesan. Hanya pemilik grup yang dapat mengaktifkan pesan suara. Seluruh pesan akan dihapus - ini tidak bisa dibatalkan! Izinkan turun versi @@ -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 @@ -487,8 +487,8 @@ Hapus pesan tidak dapat dibatalkan dilarang dalam obrolan ini. Panggilan audio/video dilarang. Reaksi pesan dilarang. - Anggota grup dapat mengirim pesan sementara. - Pesan sementara dilarang di grup ini. + Anggota dapat mengirim pesan sementara. + Pesan sementara dilarang. %d minggu %d minggu Keamanan SimpleX Chat diaudit oleh Trail of Bits. @@ -561,11 +561,11 @@ Pesan sementara dilarang dalam obrolan ini. Pesan suara dilarang dalam obrolan ini. Anda dan kontak dapat menambahkan reaksi pesan. - Anggota grup dapat hapus pesan terkirim secara permanen. (24 jam) - Anggota grup dapat mengirim pesan suara. - Hapus pesan yang tidak dapat dibatalkan dilarang di grup ini. + Anggota dapat hapus pesan terkirim secara permanen. (24 jam) + Anggota dapat mengirim pesan suara. + Hapus pesan yang tidak dapat dibatalkan dilarang. Pesan pribadi antar anggota dilarang di grup ini. - Anggota grup dapat kirim tautan SimpleX. + Anggota dapat kirim tautan SimpleX. %d jam %d jam %d hari @@ -715,7 +715,7 @@ Kata sandi profil tersembunyi Mikrofon Privasi didefinisikan ulang - Ini dapat diubah nanti di pengaturan. + Hal yang mempengaruhi baterai Saat aplikasi sedang berjalan Notifikasi pribadi Instan @@ -960,8 +960,8 @@ Ukuran huruf Kirim pesan pribadi ke anggota dilarang. Kirim tautan SimpleX dilarang - Reaksi pesan dilarang di grup ini. - Tautan SimpleX dilarang di grup ini. + Reaksi pesan dilarang. + Tautan SimpleX dilarang. Server Tak terlindungi Sesi aplikasi @@ -997,7 +997,7 @@ Panggilan pada layar terkunci: Speaker Izin dalam pengaturan - Generasi baru\ndari perpesanan pribadi + Perpesanan masa depan Temukan izin ini di pengaturan Android dan ubah secara manual. Earpiece Matikan @@ -1052,9 +1052,9 @@ Kirim hingga 100 pesan terakhir untuk anggota baru. Kirim pesan sementara dilarang. Jangan perlihat pesan riwayat ke anggota baru. - Anggota grup dapat mengirim pesan pribadi. - Pesan suara dilarang di grup ini. - Anggota grup dapat memberi reaksi pesan. + Anggota dapat mengirim pesan pribadi. + Pesan suara dilarang. + Anggota dapat memberi reaksi pesan. %d bulan pemilik %d dtk @@ -1064,8 +1064,8 @@ %db %dbln Apa yang baru - Anggota grup dapat kirim berkas dan media. - Berkas dan media dilarang di grup ini. + Anggota dapat kirim berkas dan media. + Berkas dan media dilarang. Riwayat pesan tidak dikirim ke anggota baru. Sembunyikan layar aplikasi di aplikasi terbaru. Kontak Anda dapat mengizinkan hapus semua pesan. @@ -1160,7 +1160,7 @@ Gagal perbarui privasi pengguna Fungsi lambat Notifikasi instan - izinkan SimpleX berjalan di latar belakang pada dialog berikutnya. Jika tidak, notifikasi akan dimatikan.]]> + Izinkan pada dialog berikutnya untuk menerima pemberitahuan secara instan.]]> Optimalisasi baterai aktif, mematikan layanan latar belakang dan permintaan pesan baru secara berkala. Anda dapat aktifkan kembali di pengaturan. Buka pengaturan aplikasi Notifikasi berkala dinonaktifkan! @@ -1265,7 +1265,7 @@ Server perlu otorisasi untuk mengunggah, periksa kata sandi Buat antrian Dapat dimatikan melalui pengaturan – notifikasi akan tetap ditampilkan saat aplikasi berjalan.]]> - layanan latar belakang SimpleX – yang gunakan beberapa persen baterai per hari.]]> + SimpleX berjalan di latar belakang alih-alih gunakan notifikasi push.]]> Aplikasi terima pesan baru secara berkala — aplikasi ini memakai beberapa persen baterai per hari. Aplikasi ini tidak gunakan notifikasi push — data dari perangkat tidak dikirim ke server. Layanan SimpleX Chat Nama kontak @@ -1347,4 +1347,942 @@ langsung Kirim via Terima via + Kontak diperiksa + Tak dapat undang kontak! + enkripsi disetujui untuk %s + pembuat + menghubungkan + Hapus grup? + Grup akan dihapus untuk semua anggota - ini tidak dapat dibatalkan! + Buat tautan grup + menghubungkan (diperkenalkan) + kode keamanan berubah + %d kontak dipilih + Tautan grup + Anggota lama %1$s + enkripsi end-to-end standar + enkripsi e2e quantum resistant + menghubungkan (undangan perkenalan) + tidak dikenal + Hapus obrolan + Tambah teman + Tambah anggota tim + Keluar grup + menghubungkan (diumumkan) + menghubungkan (diterima) + Hapus grup + Grup akan dihapus untuk Anda - ini tidak dapat dibatalkan! + Hapus obrolan? + Obrolan akan dihapus untuk semua anggota - ini tidak dapat dibatalkan! + Obrolan akan dihapus untuk Anda - ini tidak dapat dibatalkan! + Tinggalkan obrolan + Edit profil grup + Pesan sambutan + Undang anggota + anda: %1$s + Anda mencoba mengundang kontak yang telah Anda bagikan profil samaran ke grup tempat Anda menggunakan profil utama + Undang ke grup + Undang ke obrolan + Pilih kontak + Lewati undang anggota + Alamat atau tautan 1-kali? + Opsi pengembang + Operator server + Operator jaringan + Aplikasi ini melindungi privasi Anda dengan gunakan operator yang berbeda setiap percakapan. + Ketentuan akan diterima untuk operator yang diaktifkan setelah 30 hari. + Alat pengembang + Warna obrolan + terhubung langsung + Koneksi ke desktop dalam kondisi buruk + Konfirmasi hapus kontak? + Waktu kustom + Pilih berkas + terhubung ke pengembang SimpleX Chat untuk mengajukan pertanyaan dan menerima pembaruan.]]> + Hapus obrolan? + Jangan gunakan kredensial dengan proxy. + Gunakan host .onion ke Tidak jika proxy SOCKS tidak mendukung.]]> + Harap diperhatikan: relay pesan dan berkas terhubung melalui proxy SOCKS. Panggilan dan pengiriman pratinjau tautan menggunakan koneksi langsung.]]> + JANGAN mengirim pesan secara langsung, meskipun server Anda atau server tujuan tidak mendukung routing pribadi. + Nama tampilan tidak boleh terdapat spasi. + Frasa sandi acak disimpan dalam pengaturan sebagai teks biasa.\nAnda dapat mengubahnya nanti. + Pasang frasa sandi basis data + Basis data akan dienkripsi. + Peningkatan basis data + Konfirmasi peningkatan basis data + Turunkan dan buka obrolan + mengubah hak %s menjadi %s + Dihapus pada: %s + Hapus profil + %1$s!]]> + Batal pindah + Potongan diunggah + hubungkan + Percakapan dihapus! + Pesan sementara + Hapus catatan pribadi? + Obrolan dihentikan + Frasa sandi basis data + Konfirmasi frasa sandi baru… + Ubah frasa sandi basis data? + Tidak dapat mengakses Keystore untuk menyimpan kata sandi basis data + Penurunan basis data + Obrolan dihentikan + Buat tautan + Dihapus di + Pesan pribadi antar anggota dilarang dalam obrolan ini. + Tanda terima pengirim! + (perangkat ini v%s)]]> + Perangkat + Koneksi terputus + Gagal salin + Temukan melalui jaringan lokal + Kode undangan desktop salah + Desktop terputus + %1$s.]]> + Konfirmasi pengaturan jaringan + Potongan dihapus + Potongan diunduh + Berkas terunduh + Gagal unduh + Hapus semua berkas + Hapus berkas dan media? + %d berkas dengan total ukuran %s + Lanjutkan + kontak dihapus + Tak dapat undang kontak! + Terhapus pada + Terhapus pada: %s + (saat ini) + %s.]]> + Buram + Transparansi + Bilah alat aplikasi + Status koneksi dan server. + Desktop memiliki versi tidak didukung. Pastikan Anda gunakan versi yang sama di kedua perangkat + Obrolan dipindahkan! + Hapus basis data dari perangkat ini + Hapus berkas untuk semua profil obrolan + Dibuat di + Terbaik untuk baterai. Anda akan menerima notifikasi saat aplikasi sedang berjalan (TANPA layanan latar belakang).]]> + Baik untuk baterai. Aplikasi memeriksa pesan setiap 10 menit. Anda mungkin melewatkan panggilan atau pesan penting.]]> + Tema obrolan + BASIS DATA OBROLAN + Basis data dienkripsi menggunakan frasa sandi acak. Harap ubah frasa sandi sebelum mengekspor. + Basis data obrolan diekspor + Frasa sandi saat ini… + Harap diperhatikan: Anda TIDAK akan dapat pulihkan atau ubah frasa sandi jika hilang.]]> + Basis data dienkripsi menggunakan frasa sandi acak, Anda dapat mengubahnya. + Basis data akan dienkripsi dan frasa sandi disimpan dalam pengaturan. + versi database lebih baru daripada aplikasi, tetapi tidak ada penurunan migrasi untuk: %s + %d acara grup + mengubah alamat untuk Anda + ID basis data + Menghubungkan ke kontak, harap tunggu atau periksa nanti! + Hapus profil obrolan? + Gagal hapus + Perangkat Xiaomi: harap aktifkan Autostart di pengaturan sistem agar notifikasi berfungsi.]]> + Hapus obrolan + Hubungkan via tautan / kode QR + Profil acak baru akan dibagikan. + Permintaan koneksi terkirim! + Keamanan koneksi + Nama telah benar untuk %s? + Anda dapat konfigurasi operator di pengaturan Jaringan dan server. + Kesalahan basis data + Frasa sandi basis data berbeda dengan yang disimpan di Keystore. + Ubah hak grup? + %s.]]> + Ketentuan Penggunaan + %s.]]> + %s, terima ketentuan penggunaan.]]> + Menambahkan server pesan + Hapus profil obrolan + Obrolan bisnis + Terputus dikarenakan: %s + %s dengan alasan: %s]]> + Desktop sedang sibuk + grup dihapus + kontak %1$s diubah menjadi %2$s + Panggilan dilarang! + %s.]]> + %s.]]> + Teks ketentuan saat ini tidak dapat dimuat, Anda dapat meninjau ketentuan via tautan ini: + Pesan pribadi antar anggota dilarang. + Pindah dari perangkat lain pada perangkat baru dan pindai kode QR.]]> + Kontak dan semua pesan akan dihapus - ini tidak dapat dibatalkan! + Kontak akan dihapus - ini tidak dapat dibatalkan! + Hapus kontak? + Kontak dihapus! + Hapus tanpa notifikasi + Ubah alamat penerima? + Terputus + Tambahkan kontak: untuk buat tautan undangan baru, atau terhubung via tautan yang Anda terima.]]> + Hapus koneksi yang tertunda? + Kontak belum terhubung! + Hapus alamat + Hapus basis data + Basis data obrolan dihapus + Basis data obrolan diimpor + Dimatikan + Kontrol jaringan Anda + Menghubungkan ke desktop + Versi aplikasi desktop %s tidak kompatibel dengan aplikasi ini. + %s dalam kondisi buruk]]> + Autentikasi perangkat tidak diaktifkan. Anda dapat aktifkan SimpleX Lock via Pengaturan, setelah mengaktifkan autentikasi perangkat. + Autentikasi tidak tersedia + Konfirmasi kredensial Anda + Autentikasi perangkat mati. Matikan SimpleX Lock. + Matikan Kunci SimpleX + Kesalahan server tujuan: %1$s + Hapus dan beritahu kontak + Hapus kontak + Batal pesan langsung + Konfirmasi + Buat grup rahasia + Kamera tidak tersedia + Pindai kode QR.]]> + Buka di aplikasi seluler, lalu ketuk Hubungkan di aplikasi.]]> + Hapus + Hapus + batal pratinjau tautan + Tombol tutup + Email + pindai kode QR dalam panggilan video, atau kontak Anda dapat bagikan tautan undangan.]]> + Panduan Pengguna.]]> + Aktifkan + Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. + Obrolan sedang berjalan + Basis data akan dienkripsi dan frasa sandi disimpan di Keystore. + mengubah hak Anda jadi %s + mengubah alamat… + mengubah alamat untuk %s… + ID basis data: %d + Menambahkan media dan berkas server + Obrolan sudah ada! + Basis data dienkripsi! + Frasa sandi enkripsi basis data akan diperbarui dan disimpan di pengaturan. + Frasa sandi enkripsi basis data akan diperbarui dan disimpan di Keystore. + migrasi berbeda di aplikasi/basis data: %s / %s + tunjukkan kode QR dalam panggilan video, atau bagikan tautan.]]> + Jangan buat alamat + Rincian + Koneksi + Terhubung ke ponsel + Jangan aktifkan + Gunakan dari desktop di aplikasi seluler dan pindai kode QR.]]> + Desktop + undangan diterima + diminta untuk terhubung + Ubah profil obrolan + Kapasitas terlampaui - penerima tidak menerima pesan yang dikirim sebelumnya. + hanya dengan satu kontak - bagikan secara langsung atau melalui messenger apa pun.]]> + Disalin ke papan klip + Buat tautan undangan satu-kali + Saat ini maksimal ukuran berkas adalah %1$s. + Buka di ponsel.]]> + Pengaturan alamat + Tambahkan anggota tim Anda ke percakapan. + Alamat bisnis + dengan enkripsi end-to-end, dengan keamanan post-quantum dalam pesan pribadi.]]> + Buat tautan 1-kali + Notifikasi dan baterai + Tiada layanan latar belakang + Aplikasi selalu berjalan di latar belakang + Periksa pesan setiap 10 menit + Pilih operator jaringan yang akan digunakan. + Misalnya, jika kontak Anda menerima pesan melalui server SimpleX Chat, aplikasi Anda akan mengirimkannya melalui server Flux. + Bagaimana ini membantu privasi + Lanjutkan + Anda dapat konfigurasi server di pengaturan. + repositori GitHub kami.]]> + Nilai nanti + Perbarui + Rincian tautan unduhan + untuk setiap profil obrolan yang Anda miliki di aplikasi.]]> + untuk setiap kontak dan anggota grup.\nHarap diperhatikan: jika Anda memiliki banyak koneksi, konsumsi baterai dan lalu lintas dapat jauh lebih tinggi dan beberapa koneksi mungkin gagal.]]> + Mengunduh pembaruan aplikasi, jangan tutup aplikasi + Mati + Buat alamat SimpleX + Buat alamat agar orang dapat terhubung dengan Anda. + Lanjutkan + Hapus gambar + Edit gambar + Mengonsumsi banyak baterai! Aplikasi selalu berjalan di latar belakang – notifikasi ditampilkan secara instan.]]> + Sudut + Jangan tampilkan lagi + Konfirmasi unggahan + Arsip dan unggah + Dihapus + Koneksi dihentikan + Alamat desktop + Perangkat desktop + Tentang operator + SimpleX Chat dan Flux membuat kesepakatan untuk sertakan server yang dioperasikan Flux ke aplikasi. + Hapus profil obrolan? + Obrolan dihentikan. Jika Anda sudah gunakan basis data ini di perangkat lain, Anda harus transfer kembali sebelum memulai obrolan. + Frasa sandi enkripsi basis data akan diperbarui. + Frasa sandi basis data diperlukan untuk membuka obrolan. + %s.]]> + tidak boleh menggunakan basis data yang sama pada dua perangkat.]]> + Terhubung ke desktop + Hubungkan ke desktop + hari + kesalahan dekripsi + Putuskan ponsel + Dapat ditemukan melalui jaringan lokal + duplikat + Keamanan yang lebih baik ✅ + Bentuk pesan yang dapat disesuaikan. + Harap diperhatikan: menggunakan database yang sama pada dua perangkat akan merusak dekripsi pesan dari koneksi Anda, sebagai perlindungan keamanan.]]> + %1$s.]]> + mengubah alamat… + Hapus tautan? + Hapus tautan + Putuskan + Server terhubung + Hapus pesan anggota? + Tanggal pesan lebih baik. + (baru)]]> + %1$s.]]> + Dibuat + Hapus %d pesan anggota? + Gagal simpan server + Galat: %1$s + Gambar akan diterima setelah kontak Anda selesai mengunggah. + Gagal simpan berkas + Tautan tidak valid! + Instal pembaruan + Sembunyikan: + Untuk media sosial + Fitur eksperimental + Aktifkan Kunci SimpleX + Berkas tidak ditemukan - kemungkinan besar telah dihapus atau dibatalkan. + Kesalahan berkas server: %1$s + Balasan untuk + Berkas + gambar + Favorit + Keluar tanpa menyimpan + Berkas: %s + undangan ke grup %1$s + Grup tidak ditemukan! + Gagal kirim undangan + Undang + Status berkas + Gagal ganti hak + Gagal hapus anggota + Samaran + Mode samaran melindungi privasi Anda dengan menggunakan profil acak baru untuk setiap kontak. + Gagal aktifkan tanda terima pengirim! + Masukkan nama perangkat ini… + Impor gagal + Gagal unduh arsipan + Berkas yang diekspor tidak ada + Selesaikan migrasi + Gagal atur ulang statistik + Gagal impor basis data obrolan + Gagal hapus basis data obrolan + Berkas dan media + Enkripsi basis data? + Versi basis data tidak kompatibel + UNTUK KONSOL + Grup sudah ada! + Masukkan frasa sandi + Aktifkan hapus pesan otomatis? + Basis data terenkripsi + Konfirmasi migrasi tidak valid + Undangan grup kedaluwarsa + negosiasi ulang enkripsi diperbolehkan + negosiasi ulang enkripsi diperlukan + negosiasi ulang enkripsi diizinkan untuk %s + Aktifkan flux + untuk privasi metadata lebih baik. + Navigasi obrolan ditingkatkan + Galat + Selesaikan migrasi pada perangkat lain. + Gagal verifikasi frasa sandi: + Gagal hubungkan ulang server + Gagal hubungkan ulang server + EKSPERIMENTAL + Ekspor basis data + Impor basis data + Gagal hentikan obrolan + Gagal ganti pengaturan + Galat: %s + Grup tidak aktif + Undangan kedaluwarsa! + profil grup diperbarui + Hak awal + Gagal membuat tautan grup + Gagal hapus tautan grup + Gagal perbarui tautan grup + Gagal membuat kontak anggota + Sembunyikan + Gambar akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Kesalahan berkas + Dari Galeri + Cara menggunakan markdown + Undangan grup tidak lagi berlaku, telah dihapus oleh pengirim. + Untuk routing pribadi + Gagal perbarui server + Masukkan kata sandi dalam pencarian + Masukkan frasa sandi yang benar. + jam + Versi tidak kompatibel + Galat + Server penerusan: %1$s\nKesalahan: %2$s + Server penerusan: %1$s\nKesalahan server tujuan: %2$s + Berkas dan media dilarang! + Berkas akan diterima setelah kontak Anda selesai mengunggah. + Berkas akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Aktifkan akses kamera + Jika Anda menerima tautan undangan SimpleX Chat, Anda dapat buka di peramban Anda: + bantuan + Jika Anda tidak dapat bertemu langsung, tunjukkan kode QR dalam panggilan video, atau bagikan tautan. + Aktifkan TCP keep-alive + Gagal memulai obrolan + Mode samaran + Gagal hapus basis data + Kode QR tidak valid + Gagal enkripsi basis data + Galat + Impor + kedaluwarsa + Jika Anda memilih menolak, pengirim TIDAK akan diberitahu. + Kesalahan dalam konfigurasi server. + Untuk profil obrolan %s: + Gagal menerima ketentuan + Berhasil diinstal + Sembunyikan profil + Masukkan frasa sandi… + Berkas telah dihapus atau tautan tidak valid + Masukkan pesan sambutan… (opsional) + Nama lengkap: + Untuk melanjutkan, obrolan harus dihentikan. + negosiasi ulang enkripsi diperlukan untuk %s + Perluas pemilihan hak + Ditemukan desktop + Gagal simpan pengaturan + enkripsi ok untuk %s + Gagal ekspor basis data obrolan + Impor basis data obrolan? + tidak langsung (%1$s) + Gagal menambah server + Gagal perbarui arsipan + Gagal blokir anggota untuk semua + Gagal simpan kata sandi pengguna + Tautan tidak valid + Tiada server pesan. + Tiada server untuk routing pesan pribadi. + Tiada server media dan berkas. + Tiada server untuk menerima pesan. + Atau bagikan secara pribadi + Simpan dan beritahu anggota grup + %s (saat ini) + Pesan dari %s akan ditampilkan! + Masuk dengan kredensial Anda + Kirim + Simpan dan beritahu kontak + ID pesan salah + Kesalahan Keychain + Simpan frasa sandi dan buka obrolan + Buka obrolan + Frasa sandi tidak ditemukan di Keystore, silakan masukkan secara manual. Hal ini mungkin terjadi jika Anda pulihkan data aplikasi menggunakan alat cadangan. Jika tidak, silakan hubungi pengembang. + Toolbar aplikasi yang dijangkau + Anggota akan dihapus dari grup - ini tidak dapat dibatalkan! + Batas waktu protokol per KB + Menerima konkurensi + Kata sandi profil + Antarmuka Italia + Hubungkan ponsel + Anda dapat aktifkan nanti di pengaturan Privasi dan Keamanan aplikasi. + Opsi desktop tertaut + Anda dapat mencoba lagi. + Reset + Mulai ulang + Mulai ulang aplikasi untuk gunakan basis data obrolan yang diimpor. + Pulihkan cadangan basis data + Gagal pulihkan basis data + Anda dapat bagikan tautan atau kode QR - siapa pun dapat bergabung ke grup. Anda tidak akan kehilangan anggota grup jika nanti Anda hapus. + Tanda terima dimatikan + Anda dapat bagikan alamat ini dengan kontak agar dapat terhubung dengan %s. + Anda dapat aktifkan nanti di Pengaturan + Kirim tanda terima pengirim akan diaktifkan untuk semua kontak di semua profil obrolan yang terlihat. + Atau tempel tautan arsip + Mempersiapkan unduhan + Pindah ke sini + Impor ulang + Harap konfirmasi bahwa pengaturan jaringan sudah benar untuk perangkat ini. + Mempersiapkan unggahan + Hubungkan ulang semua server yang terhubung untuk paksa kirim pesan. Ini menggunakan lalu lintas tambahan. + Hubungkan ulang server? + Reset semua statistik + Reset seluruh statistik? + Hubungkan ulang semua server + Simpan frasa sandi di Keystore + Nama lokal + Dikirim pada + Diterima pada + Reset ke bawaan + Jaringan desentralisasi + Kirim tanda terima pengirim akan diaktifkan untuk semua kontak. + Desktop terhubung + %s diunduh + Pesan diterima + Catatan diperbarui pada + PESAN DAN BERKAS + Tema profil + Gambar profil + Harap masukkan frasa sandi saat ini yang benar. + Migrasi: %s + %s terhubung + anggota %1$s berubah menjadi %2$s + Status pesan + Dimoderasi pada + Kirim pesan pribadi + Batas waktu protokol + Simpan dan hubungkan ulang + Memungkinkan adanya banyak koneksi anonim tanpa ada data bersama di antara mereka dalam satu profil obrolan. + Hubungkan ulang server? + Pesan terlalu besar! + Mohon kurangi ukuran pesan atau hapus media dan kirim lagi. + Mohon kurangi ukuran pesan dan kirim lagi. + Anda dapat salin dan kurangi ukuran pesan untuk kirim. + Reset + Kirim pesan langsung - pesan akan diperbarui pada penerima saat Anda mengetik + Kirim Pesan + Tandai belum dibaca + Anda dapat setel nama koneksi, untuk mengingat dengan siapa tautan dibagikan. + Buka lokasi berkas + Silakan mulai ulang aplikasi. + Gabung ke samaran + %s pada %s + Anggota akan dihapus dari obrolan - ini tidak dapat dibatalkan! + Server operator + Server ditambah ke operator %s. + Operator server berubah. + Protokol server berubah. + Jumlah PING + Interval PING + - Buka obrolan pada pesan belum dibaca pertama.\n- Lompat ke pesan yang dikutip. + Pilih + Hubungkan ulang server untuk paksa kirim pesan. Ini menggunakan lalu lintas tambahan. + Bilah alat obrolan yang dijangkau + foto profil dihapus + %s dan %s terhubung + Privasi untuk pelanggan Anda. + Unduh ulang + Anda dapat mencoba lagi. + Hapus arsip? + Unggah ulang + Simpan percakapan + Anda dapat kirim pesan ke %1$s dari kontak yang diarsip. + Alamat penerima akan diubah ke server lain. Perubahan alamat akan selesai setelah pengirim online. + Minta kontak Anda untuk aktifkan kirim pesan suara. + gambar profil + Simpan pengaturan terima otomatis + Catatan diperbarui pada: %s + dtk + Alamat desktop salah + Pindai kode QR dari desktop + Pesan terkirim + dimoderasi oleh %s + Buka konsol obrolan + Peringatan kirim pesan + Kesalahan kirim pesan + Penerima tidak dapat melihat siapa pengirim pesan ini. + Silakan hubungi admin grup. + Memuat berkas + Harap tunggu sementara berkas sedang dimuat dari ponsel yang terhubung + Tertunda + Negosiasi ulang enkripsi? + Negosiasi ulang + Kirim pesan sementara + Kirim + Pindai kode QR + (pindai atau tempel dari papan klip) + Izin Ditolak! + Tandai dibaca + tautan pratinjau gambar + Anda dapat bagikan alamat sebagai tautan atau kode QR - siapa pun dapat terhubung dengan Anda. + Simpan + Jadikan profil pribadi! + Ponsel jarak jauh + JALANKAN OBROLAN + Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengakses obrolan jika hilang. + dihapus %1$s + Dikirim pada: %s + ID pesan salah + Hapus frasa sandi dari Keystore? + Hapus frasa sandi dari pengaturan? + Simpan frasa sandi di pengaturan + Diproxy + Tempel tautan yang Anda terima untuk terhubung dengan kontak… + lainnya + Kirim pesan langsung + Pesan langsung! + Kode QR + Memindah + Alamat server + Buka pengaturan server + alamat kontak dihapus + Putar dari daftar obrolan. + Ponsel terhubung + Pindai dari ponsel + Lainnya + Tiada server untuk menerima berkas. + Tiada server untuk kirim berkas. + Tiada pesan + Buka layar migrasi + Pesan akan dihapus - ini tidak dapat dibatalkan! + tempat penampung gambar profil + Alamat server tidak kompatibel dengan pengaturan jaringan. + Kata sandi ditampilkan + Ingatkan nanti + Harap masukkan kata sandi sebelumnya setelah memulihkan cadangan basis data. Tindakan ini tidak dapat dibatalkan. + Pulihkan + Anda dapat mulai obrolan via Pengaturan aplikasi / Basis Data atau dengan mulai ulang aplikasi. + Hanya pemilik obrolan yang dapat ubah preferensi. + Kirim tanda terima + detik + Harap laporkan ke pengembang:\n%s\n\nDisarankan untuk memulai ulang aplikasi. + menit + Kredensial SOCKS baru akan digunakan setiap kali Anda memulai aplikasi. + Mari bicara di SimpleX Chat + Simpan dan beritahu kontak + Simpan preferensi? + Anda dapat buat nanti + Gabung ke grup? + Bergabung dengan grup + Minta kontak Anda untuk aktifkan panggilan. + Anda dapat sembunyikan atau matikan profil pengguna - tekan tahan untuk buka menu. + Penggunaan baterai sedikit + Berhasil dipindah + Rekam pesan suara + Penerima pesan + Hash pesan salah + Keluar dari obrolan? + Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengubahnya jika hilang. + Pulihkan cadangan basis data? + Dimoderasi pada: %s + Diterima pada: %s + Atau impor berkas arsip + Harap laporkan ke pengembang:\n%s + Nama profil: + Tertunda + Server yang terhubung sebelumnya + Server proxy + Pesan akan ditandai sebagai dihapus. Penerima tetap dapat melihat pesan tersebut. + Pesan akan ditandai sebagai dihapus. Penerima tetap dapat melihat pesan ini. + Perbaiki + Perbaiki koneksi? + Server baru + Info antrian pesan + Bentuk pesan + ROUTING PESAN PRIBADI + info antrean server: %1$s\n\npesan terakhir diterima: %2$s + Hanya data profil lokal + Buka perubahan + Ketentuan terbuka + Atau bagikan tautan berkas dengan aman + Tempel alamat desktop + Profil dan koneksi server + Kesalahan lainnya + Aman + Kesalahan kirim + Dikirim langsung + Dikirim via proxy + Pembaruan profil akan dikirim ke kontak Anda. + Tempel tautan arsip + Harap periksa apakah perangkat seluler dan desktop terhubung ke jaringan lokal yang sama, dan firewall desktop mengizinkan koneksi.\nHarap sampaikan masalah lain kepada pengembang. + Koneksi memerlukan negosiasi ulang enkripsi. + Negosiasi ulang enkripsi sedang berlangsung. + Pesan yang tidak terkirim + Pesan ini telah dihapus atau belum diterima. + Untuk terhubung via tautan + Untuk mendapatkan pemberitahuan tentang rilis baru, aktifkan pemeriksaan berkala untuk versi Stabil atau Beta. + Alamat SimpleX atau tautan 1-kali? + Matikan + Lihat konsol di jendela baru + Bunyikan + Putuskan + Buka + Kunci salah atau alamat potongan berkas tidak dikenal - kemungkinan berkas dihapus. + Versi server tidak kompatibel dengan pengaturan jaringan. + Anda adalah pengamat + Untuk memulai obrolan baru + Video + Koneksi yang Anda terima akan dibatalkan! + Kode QR ini bukan tautan! + Bunyikan + Isolasi transport + Perbarui mode isolasi transport? + Untuk melindungi alamat IP, routing pribadi menggunakan server SMP untuk mengirim pesan. + Perlihat kesalahan internal + Lihat panggilan API lambat + Kami tidak menyimpan kontak atau pesan Anda (setelah terkirim) di server. + Profil, kontak, dan pesan terkirim Anda disimpan di perangkat Anda. + Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. + Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. + PROXY SOCKS + Alihkan daftar obrolan: + Tingkatkan dan buka obrolan + Ketuk untuk gabung ke samaran + Anda memblokir %s + %s, %s dan %d anggota lainnya terhubung + Grup ini memiliki lebih dari %1$d anggota, tanda terima kiriman tidak dikirimkan. + Permintaan koneksi akan dikirim ke anggota grup ini. + Perlihat profil obrolan + Profil random Anda + %s diunggah + Aktifkan log + Profil hanya dibagikan dengan kontak Anda. + Ekor + Berhenti + Tindakan ini tidak dapat dibatalkan - profil, kontak, pesan, dan berkas Anda akan hilang secara permanen. + Beberapa kesalahan tidak fatal terjadi selama impor: + Anda dapat menyimpan arsip yang diekspor. + Terima kasih kepada pengguna – kontribusi via Weblate! + Ini adalah tautan 1-kali milik Anda! + Pesan akan ditandai sebagai dimoderasi untuk semua anggota. + Pesan akan dihapus untuk semua anggota. + Pesan akan ditandai sebagai dimoderasi untuk semua anggota. + Video akan diterima saat kontak Anda selesai mengunggah. + Kontak Anda mengirim berkas yang lebih besar dari ukuran maksimal (%1$s). + Kesalahan berkas sementara + Pesan suara… + Anda menerima koneksi + Anda harus menggunakan versi terbaru basis data obrolan Anda pada satu perangkat SAJA, jika tidak, Anda mungkin berhenti menerima pesan dari beberapa kontak. + Beberapa berkas tidak diekspor + Anda dapat memindahkan basis data yang diekspor. + Frasa sandi akan disimpan dalam pengaturan sebagai teks biasa setelah Anda mengubahnya atau memulai ulang aplikasi. + Upaya untuk mengubah frasa sandi basis data tidak selesai. + Waktu koneksi TCP habis + Terima kasih kepada pengguna – kontribusi via Weblate! + Arsip basis data yang diunggah akan dihapus secara permanen dari server. + Peringatan: memulai obrolan di beberapa perangkat tidak didukung dan akan menyebabkan gagal kirim pesan + Verifikasi frasa sandi + Kesalahan unggah + Setel frasa sandi + Tindakan ini tidak dapat dibatalkan - pesan yang dikirim dan diterima sebelum waktu yang dipilih akan dihapus. Mungkin perlu waktu beberapa menit. + Anda dapat ubah di pengaturan Tampilan. + Grup ini tidak ada lagi. + Anda menolak undangan grup + Anda mengubah hak Anda menjadi %s + Anda mengubah hak %s menjadi %s + Lihat ketentuan + Server untuk berkas baru dari profil obrolan Anda saat ini + Perbarui aplikasi secara otomatis + Operator prasetel kedua dalam aplikasi! + Lihat ketentuan yang diperbarui + Nama perangkat akan dibagikan dengan klien seluler yang terhubung. + Batas waktu tercapai saat menghubungkan ke desktop + Anda sudah terhubung melalui tautan 1-kali ini! + Gunakan dari desktop + Tindakan ini tidak dapat dibatalkan - semua berkas dan media yang diterima dan dikirim akan dihapus. Gambar beresolusi rendah akan tetap ada. + Hak akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. + Basis data obrolan Anda tidak dienkripsi - setel frasa sandi untuk melindunginya. + Anda diundang ke grup + Menunggu ponsel terhubung: + Setel frasa sandi basis data + Perbarui + Perbarui frasa sandi basis data + Anda menghapus %1$s + %s, %s dan %d anggota + %s: %s + Perbarui + Verifikasi koneksi + Berlangganan + Gagal simpan basis data + Frasa sandi disimpan di pengaturan sebagai teks biasa. + Video tidak dapat didekodekan. Silakan coba video lain atau hubungi pengembang. + Enkripsi berfungsi dan perjanjian enkripsi baru tidak diperlukan. Hal ini dapat mengakibatkan kesalahan koneksi! + Bagikan alamat secara publik + Bagikan alamat SimpleX di media sosial. + Bagikan tautan 1-kali dengan teman + Alamat SimpleX dan tautan 1-kali aman untuk dibagikan ke messenger lain. + Anda dapat gunakan markdown untuk format pesan: + Anda akan berhenti menerima pesan dari obrolan ini. Riwayat obrolan akan disimpan. + Gunakan untuk pesan + Gunakan untuk berkas + Koneksi TCP + Anda masih akan menerima panggilan dan notifikasi dari profil yang dibisukan ketika profil tersebut aktif. + Saat Anda berbagi profil samaran dengan seseorang, profil ini akan digunakan untuk grup tempat Anda diundang. + Tautan ini digunakan dengan perangkat seluler lain, silakan buat tautan baru di desktop. + Kontak Anda akan tetap terhubung. + Putuskan desktop? + Peringatan: Anda akan kehilangan beberapa data! + Kesalahan berlangganan + Pesan akan dihapus untuk semua anggota. + Verifikasi kode di ponsel + Anda diundang ke grup. Bergabung untuk terhubung dengan anggota grup. + Profil Anda disimpan di perangkat dan hanya dibagikan dengan kontak. Server SimpleX tidak dapat melihat profil Anda. + Anda mengubah alamat untuk %s + profil grup diperbarui + %s, %s dan %s terhubung + Hak akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. + minggu + Gagal unggah + Mengunggah arsip + Anda tidak terhubung ke server ini. Routing pribadi digunakan untuk kirim pesan ke server ini. + Video akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Verifikasi kode keamanan + Anda masih dapat melihat percakapan dengan %1$s dalam daftar obrolan. + Pesan suara dilarang! + Tombol ketuk + Batal favorit + Anda mengundang kontak + Alamat SimpleX + ingin terhubung dengan Anda! + Lihat kode QR + Bagikan alamat dengan kontak? + Basis data obrolan Anda saat ini akan DIHAPUS dan DIGANTI dengan yang diimpor.\nTindakan ini tidak dapat dibatalkan - profil, kontak, pesan, dan berkas Anda akan hilang secara permanen. + Kesalahan tidak diketahui + Kesalahan basis data tidak diketahui: %s + Frase sandi salah! + Anda telah meminta koneksi melalui alamat ini! + Hentikan obrolan + Anda tidak dapat kirim pesan! + Pesan suara (%1$s) + Lihat kode keamanan + Anda perlu mengizinkan kontak mengirim pesan suara agar dapat mengirimkannya. + (untuk dibagikan dengan kontak Anda) + Ketuk untuk pindai + Terima kasih telah memasang SimpleX Chat! + Setel nama kontak + Kontak Anda harus online agar koneksi dapat selesai.\nAnda dapat batalkan koneksi ini dan hapus kontak (dan coba lagi nanti dengan tautan baru). + Logo SimpleX + Tim SimpleX + Anda akan terhubung saat perangkat kontak Anda online, harap tunggu atau periksa nanti! + Anda akan terhubung saat permintaan koneksi Anda diterima. Harap tunggu atau periksa nanti! + Bagikan tautan 1-kali + Saat orang meminta untuk terhubung, Anda dapat terima atau menolaknya. + Anda tidak akan kehilangan kontak jika menghapus alamat Anda nanti. + Matikan? + Hentikan obrolan? + Anda bergabung ke grup ini + Hentikan obrolan untuk ekspor, impor, atau hapus basis data obrolan. Anda tidak akan dapat terima dan kirim pesan saat obrolan dihentikan. + Pengaturan ini berlaku untuk pesan di profil obrolan Anda saat ini + Basis data obrolan Anda + buka blokir %s + Anda mengubah alamat + Untuk kirim + Menghentikan obrolan + Perlihat profil + Anda harus masukkan frasa sandi setiap aplikasi dibuka - frasa sandi tidak disimpan di perangkat. + Server SMP + Profil obrolan Anda akan dikirim\nke kontak Anda + Bagikan alamat + Hak akan diubah menjadi %s. Anggota akan menerima undangan baru. + Perbarui pengaturan jaringan? + Ketuk untuk aktifkan profil. + Mulai dari %s. + Anda akan terhubung ke grup saat perangkat pemilik grup sedang online, harap tunggu atau periksa nanti! + Lihat: + Frasa sandi basis data salah + setel foto profil baru + profil diperbarui + Ukuran + Berkas diunggah + Koneksi mencapai batas pesan yang tidak terkirim, kontak Anda mungkin sedang offline. + Kunci SimpleX tidak diaktifkan! + Anda dapat aktifkan Kunci SimpleX di Pengaturan. + Kontak yang Anda bagikan tautan ini TIDAK akan dapat terhubung! + Gambar tidak dapat didekodekan. Silakan coba gambar lain atau hubungi pengembang. + Kunci salah atau koneksi tidak dikenal - kemungkinan besar koneksi ini dihapus. + Mulai obrolan baru + Ketuk Buat alamat SimpleX di menu untuk membuatnya nanti. + Untuk terhubung, kontak Anda dapat pindai kode QR atau gunakan tautan di aplikasi. + Profil Anda %1$s akan dibagikan. + Untuk melindungi tautan Anda dari penggantian, Anda dapat membandingkan kode keamanan kontak. + Pembaruan unduhan dibatalkan + Profil Anda saat ini + Anda menggunakan profil samaran untuk grup ini - untuk mencegah berbagi profil utama Anda, undang kontak tidak diizinkan + Lihat daftar obrolan di jendela baru + Anda mengirim undangan grup + Tautan ini bukan tautan koneksi yang valid! + Anda akan berhenti menerima pesan dari grup ini. Riwayat obrolan akan disimpan. + Anda bergabung ke grup ini. Menghubungkan untuk undang anggota grup. + Anda buka blokir %s + Menunggu desktop… + Ganti profil obrolan untuk undangan 1-kali. + Untuk perlihat profil tersembunyi Anda, masukkan kata sandi lengkap di kolom pencarian di halaman Profil obrolan Anda. + Anda mengendalikan obrolan Anda! + Untuk terima + Perlihat + Perbarui pengaturan akan menghubungkan ulang klien ke semua server. + Verifikasi frasa sandi basis data + Server XFTP + Langganan diabaikan + Berhenti berbagi + Berhenti berbagi alamat? + Tampilkan opsi pengembang + Untuk mengizinkan aplikasi seluler terhubung ke desktop, buka port ini di firewall Anda, jika Anda mengaktifkannya + coret + Hapus pesan setelah + a + b + Hapus obrolan profil untuk + Koneksi belum siap + Kesalahan pada pembuatan daftar percakapan + Kesalahan memuat daftar percakapan + Kesalahan memperbaharui daftar percakapan + Daftar + Kontak + Favorit + Tidak ada pesan + Tidak ditemukan pesan + Tidak ada pesan dalam daftar %s + Tidak ada chat belum terbaca + Tambahkan daftar + Bisnis + Grup + Buka dengan %s + Tambahkan dalam daftar + Buat daftar + Daftar nama + Daftar nama dan emoji harus berbeda untuk semua daftar + Simpan daftar + Semua percakapan akan dipindahkan dari daftar %s, dan daftar akan dihapus + Hapus + Hapus daftar + Sunting + Semua + 1 laporan + Arsip + Laporan anggota arsip + Alasan lain + laporan arsip oleh %s + Arsip laporan + Hapus laporan + Laporan + Laporkan lainnya: hanya moderator grup yang akan melihat. + Gagal membuat laporan + Koneksi diblokir + Koneksi diblokir oleh operator server:\n%1$s. + Konten melanggar ketentuan penggunaan + Spam + Alasan laporan? + Laporkan + Laporan Anggota + Catatan + Laporkan spam: hanya moderator grup yang akan melihat. + Laporkan pelanggaran: hanya moderator grup yang akan melihat. + Gagal simpan pengaturan + %d laporan + Laporkan profil anggota: hanya moderator grup yang akan melihat. + Arsip laporan? + Berkas diblokir oleh operator server:\n\n%1$s. + Laporan akan diarsipkan untuk Anda. + moderator + Laporkan konten: hanya moderator grup yang akan melihat. + Ubah daftar + Ubah urutan + laporan arsip + Pelanggaran pedoman komunitas + Konten tidak pantas + Profil tidak pantas + Hanya pengirim dan moderator dapat melihat + Hanya Anda dan moderator dapat melihat + Spam \ No newline at end of file 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 ffdc377ceb..63307330cc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1069,7 +1069,7 @@ \nPuò accadere a causa di qualche bug o quando la connessione è compromessa. %1$d messaggi non decifrati. Hash del messaggio errato - L\'hash del messaggio precedente è diverso. + L\'hash del messaggio precedente è diverso.\" Si prega di segnalarlo agli sviluppatori. L\'invio del file verrà interrotto. Ferma file @@ -1947,8 +1947,7 @@ Dimensione carattere Totale inviato Messaggio inoltrato - Inizio da %s. -\nTutti i dati sono privati, nel tuo dispositivo. + Partendo da %s. \nTutti i dati sono privati, nel tuo dispositivo. Ancora nessuna connessione diretta, il messaggio viene inoltrato dall\'amministratore. Non sei connesso/a a questi server. L\'instradamento privato è usato per consegnare loro i messaggi. Altri server XFTP @@ -2248,5 +2247,89 @@ Quando più di un operatore è attivato, nessuno di essi ha metadati per capire chi comunica con chi. invito accettato richiesto di connettersi - SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell\'app - \ No newline at end of file + SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell\'app. + Info sugli operatori + La connessione richiede la rinegoziazione della crittografia. + Correggi + Correggere la connessione? + Rinegoziazione della crittografia in corso. + Attiva i log + Errore di salvataggio del database + Connessione non pronta. + Errore di aggiornamento dell\'elenco di chat + Tutte + Preferite + Gruppi + Nessuna chat + Nessuna chat trovata + Nessuna chat non letta + Aggiungi elenco + Apri con %s + Aggiungi ad un elenco + Nome elenco... + Salva elenco + Elimina + Tutte le chat verranno rimosse dall\'elenco %s, e l\'elenco eliminato + Eliminare l\'elenco? + Il nome dell\'elenco e l\'emoji dovrebbero essere diversi per tutte le liste. + Errore di caricamento dell\'elenco di chat + Errore di creazione dell\'elenco di chat + Elenco + Contatti + Lavorative + Crea elenco + Modifica + Nessuna chat nell\'elenco %s. + Cambia elenco + Note + Cambia ordine + Errore di salvataggio delle impostazioni + Errore nella creazione del resoconto + Archiviare la segnalazione? + segnalazione archiviata + Segnala contenuto: solo i moderatori del gruppo lo vedranno. + Archivia + Altro motivo + Segnala altro: solo i moderatori del gruppo lo vedranno. + Violazione delle linee guida della comunità + Contenuto inappropriato + Profilo inappropriato + Solo il mittente e i moderatori lo vedono + Solo tu e i moderatori lo vedete + Spam + Segnala + Motivo della segnalazione? + La segnalazione verrà archiviata per te. + Segnala profilo: solo i moderatori del gruppo lo vedranno. + Segnala spam: solo i moderatori del gruppo lo vedranno. + Segnala violazione: solo i moderatori del gruppo lo vedranno. + moderatore + Archivia la segnalazione + Elimina la segnalazione + Segnalazioni + Segnalazioni membro archiviate + 1 segnalazione + segnalazione archiviata da %s + %d segnalazioni + Segnalazioni membro + Spam + Connessione bloccata + Il file è bloccato dall\'operatore del server:\n%1$s. + La connessione è bloccata dall\'operatore del server:\n%1$s. + Il contenuto viola le condizioni di utilizzo + Aprire il link? + + Apri i link dall\'elenco delle chat + No + Apri link + Chiedi + Imposta il nome della chat… + Disattiva eliminazione messaggi + 1 anno + Disattivare l\'eliminazione automatica dei messaggi? + Cambiare l\'eliminazione automatica dei messaggi? + Questa azione non è reversibile: i messaggi inviati e ricevuti in questa chat prima della selezione verranno eliminati. + Elimina i messaggi di chat dal tuo dispositivo. + I messaggi in questa chat non verranno mai eliminati. + predefinito (%s) + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 2bb007b6e8..ab78cf51f3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1918,4 +1918,4 @@ שרת XFTP חלש אנשי קשר בארכיון - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index ff6b4e456c..8a92168d26 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -111,8 +111,8 @@ チャット読み込みに失敗 チャット読み込みに失敗 アプリを更新し、開発者にご連絡ください。 - 即時通知! - 即時通知が無効になってます! + 通知の常時受信! + 通知の常時受信が無効になってます! パスフレーズが必要 プライベート 連絡先の名前 @@ -150,7 +150,7 @@ グループのプロフィールはサーバではなく、メンバーの端末に保存されます。 グループのプロフィールが更新されました。 連絡先とメッセージ内容をプライベートにする。 - 即時通知 + 通知の常時受信 SMPサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 WebRTC ICEサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 SimpleX の仕様 @@ -480,7 +480,7 @@ 新しい繋がりのリクエスト コピー メッセージを削除しますか? - 編集する + 編集 プライベートにする メッセージが削除されます (※元に戻せません※)! メッセージが削除対象となります。宛先にはメッセージの解読ができます。 @@ -529,8 +529,8 @@ 応答 分散型 スパム耐性 - 即時 - 定期的 + 常時受信 + 定期的に受信 通話は既に終了してます! エンドツーエンド暗号化済みの音声通話 無視 @@ -740,7 +740,7 @@ 接続中… 次世代のプライベートメッセンジャー ビデオ通話 - アプリが稼働中に + アプリがアクティブ時のみ WebRTC ICEサーバ あなたのICEサーバ 設定 @@ -1267,7 +1267,7 @@ KB あたりのプロトコル タイムアウト グループメンバーはファイルやメディアを送信できます。 アドレス変更の中止 - このグループでは、ファイルとメディアは禁止されています。 + ファイルとメディアは禁止されています。 終了しますか? アプリを再起動するまで通知は機能しません。 未読とお気に入りをフィルターします。 @@ -1961,4 +1961,36 @@ 設定 情報がありません、リロードしてください SMPサーバ - \ No newline at end of file + メッセージ + ウェブサイト + ビデオ + 設定画面からサーバを構成できます。 + 改定履歴を開く + 全てのチャットが %s から削除され、リスト自体も削除されます + アプリのツールバー + ログを有効化 + メッセージ受信を10分毎に確認します + バックグラウンドでアプリが常時動作します + SimpleX ChatとFluxは、Fluxが運営するサーバをアプリに組み込むことに合意しました。 + 利用条件をレビュー + プリセットサーバ + 利用条件を承諾 + 透過度 + %s の利用条件が受け入れられます。]]> + 新しいメッセージ + メッセージの形 + リストを保存 + 削除 + リスト名... + 編集 + リストを削除しますか? + 利用条件 + 続ける + リストを作成 + サーバオペレータ + 利用条件を開く + バックグラウンドサービスを使用しない + 利用条件の承諾 + %s の利用条件に承諾しています。]]> + 後で作成する場合はメニューから「SimpleXのアドレスを作成」を選択してください。 + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index bf07c10a6f..b915586b90 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -281,7 +281,7 @@ %d 개의 파일 총 크기 %s 다이렉트 이 채팅에서는 사라지는 메시지를 사용할 수 없습니다. - 이 그룹에서는 사라지는 메시지를 사용할 수 없습니다. + 사라지는 메시지를 사용할 수 없습니다. %d분 %d 개월 %d 분 @@ -966,7 +966,7 @@ 활성 연결 모든 프로필 적용 - 모든 메시지가 삭제됩니다. 이 결정은 취소할 수 없습니다! + 모든 메시지가 삭제됩니다. 이 결정은 되돌릴 수 없습니다! 파일 및 미디어 전송을 허용합니다. 새로운 무작위 프로필이 공유됩니다. 모든 색상 모드 @@ -1444,10 +1444,10 @@ %s.]]> %s 이 현재 사용 중]]> %s.]]> - SimpleX 백그라운드 서비스를 제공합니다. - 이 기능은 하루에 몇 퍼센트의 배터리를 소모합니다.]]> + 개인 정보를 보호하기 위해 SimpleX는 푸시 알림을 사용하는 대신 백그라운드에서 실행됩니다.]]> %s 의 서버를 사용하려면 사용 약관에 동의하십시오.]]> %1$s 에 연결 중입니다.]]> - SimpleX의 백그라운드에서 실행되도록 허용하십시오. 그렇지 않으면 알림을 사용할 수 없습니다.]]> + 허용을 선택하면 알림을 즉시 받을 수 있습니다.]]> 앱 설정에서 앱 배터리 사용량 / 제한 없음 을 선택하십시오.]]> %s 이 현재 비활성화됨]]> SimpleX Chat 개발자에게 연결하여 질문하고 업데이트를 받을 수 있습니다.]]> @@ -1457,4 +1457,38 @@ %1$s 그룹에 가입하는 중 입니다.]]> %s 버전이 지원되지 않습니다. 두 기기에서 동일한 버전을 사용하는지 확인하십시오.]]> %1$s 그룹에 속해 있습니다.]]> + 약관을 수락하는 중 오류 발생 + 연결 보안 + 로그 활성화 + 비즈니스 주소 + 일회용 링크 생성 + 10분마다 메시지 확인 + %s:에서 활성화된 운영자에 대한 약관이 자동으로 수락됩니다. + 현재 약관 텍스트를 로드할 수 없습니다, 다음 링크를 통해 약관을 검토할 수 있습니다: + 사용 약관 + Flux 활성화 + 종단 간 암호화로 전송됩니다.]]> + 앱이 항상 백그라운드에서 실행 + 활성화된 운영자에 대한 약관은 30일 후에 수락됩니다. + 팀 멤버 추가하기 + 친구 추가 + 모든 멤버에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! + 채팅 삭제 + 채팅을 삭제하시겠습니까? + 채팅 + 서버 추가 중 오류 + 멤버 간의 다이렉트 메시지는 금지됩니다. + Xiaomi 기기: 알림이 작동하려면 시스템 설정에서 자동 시작을 사용하도록 설정하세요.]]> + 이 채팅에서는 멤버 간의 다이렉트 메시지가 금지됩니다. + 당신에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! + %s:에서 약관이 수락됩니다. + %1$s에 연결되어 있습니다.]]> + 채팅이 이미 존재합니다! + 계속 + 수락된 초대 + 비즈니스 채팅 + 운영자 소개 + 연결에 암호화 재협상이 필요합니다. + 암호화 재협상이 진행 중입니다. + 팀 멤버를 대화에 추가하세요. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index fbad2dc4e9..adf66650f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1774,4 +1774,4 @@ %1$d failas (-ai, -ų) vis dar atsisiunčiamas (-i, -a). Nepavyko atsisiųsti %1$d failo (-ų). %d pasirinkta - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index ced3b9a3b0..867638621b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -445,7 +445,7 @@ Berichten Nieuw wachtwoord… Keychain fout - Word lid van + Word lid Groep verlaten\? Nieuwe leden rol Geen contacten om toe te voegen @@ -694,7 +694,7 @@ Server test Beoordeel de app Gebruik server - Gebruik van SimpleX Chat servers. + Gebruik SimpleX Chat servers. Uw server adres Uw server Transport isolation @@ -854,7 +854,7 @@ Uw contacten kunnen volledige verwijdering van berichten toestaan. U moet elke keer dat de app start het wachtwoord invoeren, deze wordt niet op het apparaat opgeslagen. Verkeerd wachtwoord voor de database - Bewaar het wachtwoord en open je chats + Wachtwoord opslaan en open je chats De poging om het wachtwoord van de database te wijzigen is niet voltooid. Database back-up terugzetten Database back-up terugzetten\? @@ -1047,7 +1047,7 @@ Je kunt SimpleX Vergrendeling aanzetten via Instellingen. Vergrendeling inschakelen Vergrendeling modus - Indienen + Bevestigen Verificatie geannuleerd Wijzig de vergrendelings modus Bevestig toegangscode @@ -1448,7 +1448,7 @@ Gekoppelde mobiele apparaten Desktop Verbonden met desktop - Deze apparaatnaam + Naam van dit apparaat Het bestand laden Verbinding maken met desktop Desktop apparaten @@ -1603,7 +1603,7 @@ Deblokkeer voor iedereen geblokkeerd geblokkeerd door beheerder - %s geblokkeerd + blokkeerde %s %d berichten geblokkeerd door beheerder %s gedeblokkeerd Fout bij blokkeren van lid voor iedereen @@ -1872,8 +1872,7 @@ Details Berichten ontvangen Bericht ontvangst - Beginnend vanaf %s. -\nAlle gegevens zijn privé op uw apparaat. + Beginnend vanaf %s. \nAlle gegevens zijn privé op uw apparaat. Verbonden servers In behandeling Eerder verbonden servers @@ -2244,7 +2243,90 @@ Verklein de berichtgrootte of verwijder de media en verzend het bericht opnieuw. geaccepteerde uitnodiging Wanneer er meer dan één operator is ingeschakeld, beschikt geen enkele operator over metagegevens om te achterhalen wie met wie communiceert. - gevraagd om verbinding te maken + verzocht om verbinding te maken Over operatoren Simplex-chat en flux hebben een overeenkomst gemaakt om door flux geëxploiteerde servers in de app op te nemen. - \ No newline at end of file + Logs inschakelen + Fout bij het opslaan van de database + Verbinding vereist heronderhandeling over encryptie. + Er wordt opnieuw onderhandeld over de encryptie. + Verbinding herstellen? + Herstel + Verbinding nog niet klaar + Fout bij het bijwerken van de chatlijst + Geen chats in lijst %s. + Favorieten + Groepen + Geen chats + Geen chats gevonden + Lijst toevoegen + alle + Openen met %s + Maak een lijst + Verwijderen + Lijst verwijderen? + Bewerk + Naam van lijst... + De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn. + Lijst opslaan + Lijst + Fout bij het laden van chatlijsten + Alle chats worden verwijderd uit de lijst %s, en de lijst wordt verwijderd + Toevoegen aan lijst + bedrijven + Contacten + Fout bij het aanmaken van chatlijst + Geen ongelezen chats + Notities + Lijst wijzigen + Wijzig volgorde + ‐Fout bij het opslaan van instellingen + Fout bij maken van rapport + Archief + Schending van de communityrichtlijnen + Een andere reden + Rapporteer ledenprofiel: alleen groepsmoderators kunnen dit zien. + moderator + Inhoud melden: alleen groepsmoderators kunnen dit zien. + gearchiveerd rapport + Ongepaste inhoud + Ongepast profiel + Alleen de verzender en moderators zien het + Alleen jij en moderators zien het + Spam + Rapport archiveren? + Rapport + Reden melding? + Het rapport wordt voor u gearchiveerd. + Anders melden: alleen groepsmoderators kunnen het zien. + Spam melden: alleen groepsmoderators kunnen het zien. + Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. + Rapport archiveren + Rapport verwijderen + gearchiveerd rapport door %s + 1 rapport + Gearchiveerde ledenrapporten + %d rapporten + Ledenrapporten + Inhoud schendt de gebruiksvoorwaarden + Verbinding geblokkeerd + Verbinding is geblokkeerd door serveroperator:\n%1$s. + Bestand is geblokkeerd door server operator:\n%1$s. + Nee + Link openen + Open links van chatlijst + Vragen + Rapporten + Spam + Weblink openen? + Ja + Stel chatnaam in + Automatisch verwijderen van berichten wijzigen? + 1 jaar + Berichten in deze chat zullen nooit worden verwijderd. + Deze actie kan niet ongedaan worden gemaakt. De berichten die eerder in deze chat zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. + Automatisch verwijderen van berichten uitschakelen? + Verwijder chatberichten van uw apparaat. + Berichten verwijderen uitschakelen + standaard (%s) + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a748bf2741..1ebaf99ea8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -277,7 +277,7 @@ Zeskanuj kod bezpieczeństwa z aplikacji Twojego kontaktu. Kod bezpieczeństwa Ustawienia - Udostępnij 1-razowy link + Udostępnij link jednorazowy Pokaż kod QR %s jest zweryfikowany Ten kod QR nie jest linkiem! @@ -1129,9 +1129,9 @@ Podgląd Otwieranie bazy danych… Błąd ustawiania adresu - Otwórz profile czatu + Zmień profile czatu O adresie SimpleX - 1-razowy link + link jednorazowy Podręczniku Użytkownika.]]> Adres SimpleX Kiedy ludzie proszą o połączenie, możesz je zaakceptować lub odrzucić. @@ -1373,9 +1373,7 @@ Arabski, bułgarski, fiński, hebrajski, tajski i ukraiński - dzięki użytkownikom i Weblate. Utwórz nowy profil w aplikacji desktopowej. 💻 Przełącz incognito przy połączeniu. - - połącz się z usługą katalogową (BETA)! -\n- potwierdzenia dostaw (do 20 członków). -\n- szybszy i stabilniejszy. + - połącz się z usługą katalogową (BETA)!\n- potwierdzenia dostaw (do 20 członków).\n- szybszy i stabilniejszy. Otwórz Błąd tworzenia kontaktu członka Wyślij wiadomość bezpośrednią aby połączyć @@ -1499,15 +1497,15 @@ Rozłączyć komputer? Proszę poczekać na załadowanie pliku z połączonego telefonu Zweryfikuj połączenie - Odświerz + Odśwież Możesz ustawić go jako widoczny dla swoich kontaktów SimpleX w Ustawieniach. Losowy błąd wyświetlania zawartości błąd wyświetlania wiadomości - Aby umożliwić aplikacji mobilnej łączenie się z komputerem, otwórz ten port w zaporze sieciowej, jeśli jest ona włączona + Aby umożliwić aplikacji mobilnej połączenie z komputerem stacjonarnym, otwórz ten port w swojej zaporze sieciowej, jeśli jest włączona. Utwórz profil czatu Widok uległ awarii - Otwórz port w zaporze + Otwórz port w zaporze sieciowej Rozłącz telefony Brak połączonych telefonów Historia nie jest wysyłana do nowych członków. @@ -1523,7 +1521,7 @@ Włącz dostęp do kamery Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia. Zachować nieużyte zaproszenie? - Udostępnij ten jednorazowy link + Udostępnij ten jednorazowy link zaproszenia Utwórz grupę: aby utworzyć nową grupę.]]> Widoczna historia Pin aplikacji @@ -1851,8 +1849,7 @@ Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze. Błąd pliku Błąd serwera plików: %1$s - Sprawdź, czy telefon i komputer są podłączone do tej samej sieci lokalnej i czy zapora sieciowa komputera umożliwia połączenie. -\nProszę podzielić się innymi problemami z deweloperami. + Sprawdź, czy telefon i komputer są podłączone do tej samej sieci lokalnej i czy zapora sieciowa komputera umożliwia połączenie.\nProszę zgłoś wszystkie inne problemy deweloperom. Tymczasowy błąd pliku Zły klucz lub nieznany adres fragmentu pliku - najprawdopodobniej plik został usunięty. Nie można wysłać wiadomości @@ -1932,8 +1929,7 @@ Wyświetlanie informacji dla Statystyki Sesje transportowe - Zaczynanie od %s. -\nWszystkie dane są prywatne na Twoim urządzeniu. + Zaczynanie od %s. \nWszystkie dane są prywatne na Twoim urządzeniu. Połącz ponownie wszystkie serwery Połączyć ponownie serwer? Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -2114,4 +2110,79 @@ Usuń lub moderuj do 200 wiadomości. Przekazywanie do 20 wiadomości jednocześnie. Przełącz profil czatu dla zaproszeń jednorazowych. - \ No newline at end of file + Utwórz link jednorazowy + Udostępnij jednorazowy link znajomemu + Adres SimpleX czy link jednorazowy? + Adres lub link jednorazowy? + tylko z jednym kontaktem - udostępnij go osobiście lub przez dowolny komunikator.]]> + Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator. + raport zarchiwizowany przez %s + Inny powód + Zarchiwizuj raport + Ustawienia adresowe + O operatorach + Adres służbowy + Zaakceptowane warunki + %s.]]> + Dodane serwery wiadomości + %s.]]> + Zarchiwizować raport? + Archiwum + Wszystkie + Biznesy + raport + Zarchiwizowane raporty członków + Zapytaj + %s.]]> + %s.]]> + Rozmyj + Dodaj członków drużyny do konwersacji + Dodaj członków drużyny + Konwersacje służbowe + Dodaj listę + Dodaj do listy + "Wszystkie konwersacje zostaną usunięte z list %s, oraz listy." + zaszyfrowanej, z post-kwantowym bezpieczeństwem w bezpośrednich wiadomościach.]]> + Dodaj znajomych + Dodane serwery plików i mediów + Paski narzędziowe aplikacji + Aplikacja działa zawsze w tle + %s.]]> + %s.]]> + %s.]]> + Zaakceptuj warunki + zaproszenie zaakceptowane + zarchiwizowany raport + Urządzenia Xiaomi: Włącz Autostart w ustawieniach systemowych, aby powiadomienia działały.]]> + Usuń raport + Kontakty + Sprawdzaj wiadomości co 10 minut + Usuń czat + Naruszenie wytycznych społeczności + Warunki zaakceptowane na: %s. + Warunki zostaną automatycznie zaakceptowane dla włączonych operatorów na: %s. + %s.]]> + %s, Zaakceptuj warunki użytkowania.]]> + Warunki użytkowania + Treść narusza warunki użytkowania + Połączenie zablokowane + Połączenie jest zablokowane przez operatora serwera:\n%1$s. + Lista zmian + Utwórz listę + Usuń + Nie można załadować tekstu aktualnych warunków, możesz przejrzeć warunki za pomocą tego linku: + Kontynuuj + Czat + Czat już istnieje! + %1$s.]]> + Usunąć listę? + Połączenie nie jest gotowe. + Usunąć czat? + Czat zostanie dla Ciebie usunięty - nie można tego cofnąć! + Czat zostanie usunięty dla wszystkich członków - nie można tego cofnąć! + Warunki będą akceptowane w dniu: %s. + Bezpieczeństwo połączenia + Warunki będą akceptowane dla operatorów włączonych po 30 dniach. + Zmień kolejność + Połączenie wymaga renegocjacji szyfrowania. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 3b139013fc..ac3deb84db 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -391,7 +391,7 @@ Imagem salva na Galeria anônimo via link do grupo Chamada de vídeo recebida - permite que o SimpleX funcione em segundo plano na próxima caixa de diálogo. Caso contrário, as notificações serão desabilitadas.]]> + Permita na próxima caixa de diálogo para receber notificações instantaneamente]]> Gerar um link de convite de uso único Arquivo não encontrado Se você optar por rejeitar o remetente NÃO será notificado. @@ -1982,8 +1982,7 @@ Sessões de transporte Recepção de mensagem Pendente - Começando de %s. -\nTodos os dados são privados do seu dispositivo. + Começando de %s. \nTodos os dados são privados do seu dispositivo. Total Servidores proxiados Servidores conectados anteriormente @@ -2094,4 +2093,37 @@ Senha Nome de usuário Sessão do aplicativo - \ No newline at end of file + Endereço ou link de uso único? + Configurações de endereço + Adicione membros da sua equipe às conversas. + Melhores ligações + Servidores de mensagem adicionados + Adicionado servidores de mídia e arquivos + Barra de ferramentas + Aplicativo sempre roda em segundo plano + Adicionar amigos + Condições aceitas + Convite aceito + Adicionar membros da equipe + Sobre operadores + Aceite as condições + denúncia arquivada por %s + Outra razão + Adicionar lista + Todas as conversas serão removidas da lista %s, e a lista será apagada + Melhor segurança ✅ + Arquivar denúncia? + Arquivar denúncia + Todos + Adicionar à lista + Em dispositivos Xiaomi: por favor, ative a opção Autostart nas configurações do sistema para que as notificações funcionem.]]> + Datas de mensagens melhores. + Melhor experiência do usuário + %1$s.]]> + Arquivar + Pergunte + Desfoque + Denúncias arquivadas de membros + Endereço comercial + denúncia arquivada + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index c02e17f568..d4f9be50aa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -6,7 +6,7 @@ Acceptă Acceptă incognito Adaugă server - Setări de rețea avansate + Reglări avansate de rețea %1$s dorește să se conecteze cu tine prin Acceptă %1$d mesaje omise. @@ -17,7 +17,7 @@ Adițional secundar Acceptă 6 limbi noi pentru interfață - %1$d mesaje nu au putut fi decriptate. + %1$d mesaje nu au putut fi descifrate. %1$d mesaj(e) omis(e) %1$s MEMBRI 1 zi @@ -39,8 +39,8 @@ Adaugă servere prin scanare de coduri QR. Adaugă la un alt dispozitiv Adaugă mesaj de întâmpinare - acceptând criptarea… - acceptând criptarea pentru %s… + se acceptă cifrarea… + se acceptă cifrarea pentru %s… Administratorii pot crea linkuri de participare la grupuri. Câteva lucruri în plus Toate datele aplicației sunt șterse. @@ -50,7 +50,7 @@ Întrerupe schimbarea adresei Întrerupe Toate conversațiile și mesajele vor fi șterse - operațiunea este definitivă! - Toate mesajele vor fi șterse - operațiunea este definitivă! + Toate mesajele vor fi șterse - acest lucru nu poate fi anulat! Permite reacții la mesaje doar dacă și contactul tău le permite. Permite ștergerea ireversibilă a mesajelor trimise. (24 ore) Permite trimiterea de fișiere și media. @@ -122,7 +122,7 @@ Înapoi Creează grup secret Se creează link… - apel audio (necriptat e2e) + apel audio (necifrat e2e) Creează fișier Adaugă contact: pentru a crea un nou link de invitare, sau a te conecta printr-un link pe care l-ai primit.]]> Autentificare anulată @@ -150,7 +150,7 @@ creator Fundal Creează profil nou în aplicația desktop. 💻 - Aplicația criptează fișierele locale noi (cu excepția videoclipurilor). + Aplicația cifrează fișierele locale noi (cu excepția videoclipurilor). autor Arabă, Bulgară, Finlandeză, Ebraică, Thailandeză și Ucraineană - mulțumită utilizatorilor și Weblate. Apelurile audio/video sunt interzise. @@ -178,7 +178,7 @@ Repetă încărcarea apel respins Elimini fraza de acces din Keystore? - Elimini fraza de acces din setări? + Elimini parola din reglări? Repetă cererea de conectare? Necesar Reîncearcă @@ -188,10 +188,10 @@ Reîmprospătează Revoci fișierul? Revocă - Renegociezi criptarea? + Renegociezi cifrarea? Resetează Respinge - Salvează fraza de acces în setări + Salvează parola din reglări Salvează și actualizează profilul grupului Repetă cererea de alăturare? Repornește conversația @@ -206,7 +206,7 @@ Salvează parola profilului Salvează fraza de acces și deschide conversația %s și %s - Renegociază criptarea + Renegociază cifrarea Salvează și notifică contactul Salvează și notifică contactele Salvează și notifică membrii grupului @@ -214,7 +214,7 @@ Răspunde Revocă fișierul Salvează - Salvezi setările? + Salvezi reglările? Salvezi preferințe? Repornire Restabilește copia de rezervă a bazei de date @@ -241,8 +241,8 @@ Mesaj trimis Setează preferințele grupului trimis - Adresa serverului este incompatibilă cu setările de rețea. - Versiunea serverului este incompatibilă cu setările de rețea. + Adresa serverului este incompatibilă cu reglările rețelei. + Versiunea serverului este incompatibilă cu reglările rețelei. Trimițând prin trimiterea de fișiere nu este acceptată încă Scanează codul de securitate din aplicația contactului tău @@ -293,7 +293,7 @@ Arată: Arată erori interne secret - SETĂRI + REGLĂRI %s conectat setează imagine de profil Trimis către: %s @@ -307,7 +307,7 @@ Selectează Trimiterea de fișiere va fi oprită. Trimite - Setări + Reglări Scanează cod Cod de securitate Trimite-ne email @@ -361,7 +361,7 @@ Aplică pentru Începe o nouă conversație Stea pe GitHub - Criptare de la capăt la capăt standard + Cifrare standard de la un capăt la altul Pornește periodic Mereu Folosește mereu rutare privată. @@ -369,7 +369,7 @@ pornire… %s secunde Începi conversația? - Setări avansate + Reglări avansate Adresă desktop rea ID de mesaj incorect Hash de mesaj incorect @@ -391,7 +391,7 @@ Conectare incognito Contactul deja există Schimbă codul de acces - Poți porni Blocare SimpleX din Setări. + Poți porni Blocare SimpleX din Reglări. Conversații Alege un fișier Contactul tău trebuie să fie online pentru a se completa conexiunea. @@ -429,17 +429,17 @@ Crează Bluetooth Camera - Apeluri pe ecranul de blocare: - contactul are criptare e2e - contactul nu are criptare e2e + Apeluri pe ecranul blocat: + contactul are cifrare e2e + contactul nu are cifrare e2e Contacte - CONVERSAȚII + DISCUȚII BAZĂ DE DATE CONVERSAȚIE Baza de date a conversației ștearsă Conversația rulează Baza ta de date a conversațiilor - Baza ta de date a conversațiilor nu este criptată - setează frază de acces pentru a o proteja. - Fraza de acces de criptare a bazei de date va fi actualizată și stocată în setări. + Baza de date a conversațiilor tale nu este cifrată - pune o parolă pentru a o proteja. + Parola de cifrare a bazei de date va fi actualizată și stocată în reglări. ai schimbat rolul %s la %s Nu se pot invita contactele! Te-ai alăturat grupului @@ -462,7 +462,7 @@ Anulează mesajul live Conectare prin link / cod QR Contactele tale vor rămâne conectate. - Fraza de acces de criptare a bazei de date va fi actualizată. + Parola de cifrare a bazei de date va fi actualizată. Contactele tale pot permite ștergerea totală a mesajelor. Trebuie să permiți contactului tău să trimită mesaje vocale pentru a le putea trimite. Versiunede bază: v%s @@ -474,7 +474,7 @@ se schimbă adresa pentru %s… Și tu și contactul tău puteți trimite mesaje temporare. Nu se pot primi fișiere - Poate fi dezactivat în setări – notificările vor fi încă afișate cât timp aplicația rulează.]]> + Poate fi dezactivat din reglări – notificările vor fi afișate dacă aplicația este în funcțiune.]]> Verifică mesajele noi la fiecare 10 minute timp de până la 1 minut Nu ai conversații Contactul și toate mesajele vor fi șterse - acest lucru nu poate fi anulat! @@ -487,7 +487,7 @@ Profil conversație aldin Poți folosi markdown pentru a formata mesaje: - Apelând… + se apelează… Schimbă modul de autodistrugere Baza de date a conversației importată Trebuie să folosești cea mai recentă versiune a bazei de date a conversațiilor DOAR pe un singur dispozitiv, altfel se poate să nu mai primești mesajele de la unele contacte. @@ -500,10 +500,10 @@ Contact ascuns: Nume contact Crează adresă - Bază de date criptată! + Bază de date cifrată! Schimbi fraza de acces a bazei de date? Conversația este oprită - Poți porni conversația prin Setările aplicației / Bază de date sau repornind aplicația. + Poți porni discuția din Reglările aplicației / Baza de date sau repornind aplicația. ai ieșit Blochezi membrul? Blocat de admin @@ -523,9 +523,8 @@ Te conectezi la tine? Verifică conexiunea la internet și încearcă din nou Culori conversație - Temă conversație - Bun pentru baterie. -\nServiciul în fundal verifică mesaje la fiecare 10 minute. Ai putea rata apeluri sau mesaje urgente. + Aspectul discuției + Bun pentru baterie. Aplicația verifică mesajele la fiecare 10 minute. Pot fi pierdute apeluri sau mesaje urgente.]]> Negru Blochezi membrul pentru toți? Și tu și contactul tău puteți șterge ireversibil mesajele trimise. (24 de ore) @@ -554,7 +553,7 @@ Se conectează la desktop Rugat să primească imaginea Cerere de conexiune trimisă! - Vă rugăm să rețineți: mesajele și releurile pentru fișiere sunt conectate printr-un proxy SOCKS. Apelurile ți trimiterea de previzualizări a link-urilor folosesc o conexiune directă.]]> + De reținut: releele de mesaje și fișiere sunt conectate prin proxy SOCKS. Apelurile și trimiterea de previzualizări ale adreselor web utilizează conexiunea directă.]]> conectat Confirmați codul de access Confirmare actualizare bază de date @@ -573,16 +572,16 @@ Conexiunea la desktop este într-o stare proastă Conectare Conexiune - Ștergeți notițele private? + Ștergi notițele private? Conectare automată se conectează apelul… se conectează (invetație la introducere) se conectează… %s este într-o stare proastă]]> - Confirmați setările de rețea + Confirmă reglările rețelei se conectează… Eroare de conexiune (AUTENTIFICARE) - Optimizarea pentru baterie este activă, vom opri serviciile din fundal și cererile periodice pentru mesaje noi. Le puteți reactiva din setări. + Optimizarea bateriei este activă, dezactivând serviciul de funcționare în ascuns și solicitările periodice pentru mesaje noi. Pot fi reactivate din reglări. conectat Crează un grup: pentru a crea un nou grup.]]> Ștergeți conversația? @@ -592,7 +591,7 @@ Consolă conversație Confirmați parola colorat - Toate contactele, conversațiile și fișierele dumneavoastră vor fi encriptate într-un mod sigur și încărcate pe bucăți pe releurile XFTP configurate. + Toate contactele, conversațiile și fișierele tale vor fi cifrate într-un mod sigur și încărcate pe bucăți pe releurile XFTP configurate. Rugat să primească videoclipul Comparați fișierul Vă rugăm să rețineți: nu veți putea recupera sau schimba parola dacă o veți pierde.]]> @@ -609,7 +608,7 @@ Cel mai bun pentru baterie. Veți primi notificări doar când aplicația rulează (FĂRĂ servicii de fundal).]]> Se conectează apelul complet - Vă rugăm să rețineți: folosind aceeași bază de date pe două dispozitive, va intrerupe decripția mesajelor de la conexiunile dumneavoastră, ca protecție de securitate.]]> + De reținut: folosirea aceleiași baze de date în două aparate, va intrerupe descifrarea mesajelor din conexiunile tale, ca măsură de protecție.]]> Confirmați încărcarea Confirmați că țineți minte parola de la baza de date pentru a o migra. Conexiune @@ -635,7 +634,7 @@ Adresă desktop Dispozitive desktop Desktop - Eroare decriptare + Eroare la descifrare Șterge imagine Șterge după Șterge pentru toată lumea @@ -677,10 +676,10 @@ Contactul %1$s a schimbat la %2$s Contactul este șters. Controlează-ți rețeaua - Fraza de acces pentru criptarea bazei de date va fi actualizată și stocată în Keystore. + Parola pentru cifrarea bazei de date va fi actualizată și stocată în Keystore. Fraza de acces a bazei de date Servere XFTP configurate - Baza de date este criptată folosind o expresie de acces aleatorie. Schimbați-o înainte de a exporta. + Baza de date este cifrată folosind o parolă aleatorie. Trebuie schimbată înainte de exportare. apel conectare Contact șters! @@ -689,7 +688,7 @@ Verifică pentru actualizări Creează Estompează media - BAZĂ DE DATE CHAT + BAZĂ DE DATE DISCUȚIE Conectează-te cu prietenii mai ușor. încercări Completat @@ -714,10 +713,30 @@ Conectat Corectează numele la %s? Continuă - Baza de date este criptată folosind o expresie de acces aleatorie, o poți schimba + Baza de date este cifrată folosind o parolă aleatorie; o poți schimba. Nu se pot trimite mesaje membrului grupului Se conectează Servere conectate Nu se poate apela contactul - Se conectează la contact, te rog așteaptă sau verifică mai târziu! + Se conectează la contact, așteaptă sau verifică mai târziu! + Reglări avansate + Reglări + Reglările tale + apel vocal cifrat e2e + apel video cifrat e2e + APARAT + EXPERIMENTAL + Cifrează + erori la descifrare + TU + necifrat e2e + cifrat e2e + Apel video primit + Dezactivează + Permiți apeluri? + terminat + Sunete la apel + Apeluri îmbunătățite + Apel audio primit + Termină apelul \ No newline at end of file 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 f234a2cf0a..eef0870a5b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1132,7 +1132,7 @@ Ошибка расшифровки Блокировка SimpleX не включена! Ошибка хэш сообщения - Хэш предыдущего сообщения отличается. + Хэш предыдущего сообщения отличается\" Подтвердить код Неправильный код Заблокировать через @@ -2027,8 +2027,7 @@ Подключенные серверы Ранее подключенные серверы Проксированные серверы - Начиная с %s. -\nВсе данные хранятся только на вашем устройстве. + Начиная с %s.\nВсе данные хранятся только на вашем устройстве. Переподключить сервер для устранения неполадок доставки сообщений. Это использует дополнительный трафик. Ошибка Ошибка переподключения к серверу @@ -2297,8 +2296,8 @@ Серверы по умолчанию Роль будет изменена на %s. Все участники разговора получат уведомление. Ваш профиль будет отправлен участникам разговора. - %s.]]> - %s.]]> + %s.]]> + %s.]]> Условия использования Дополнительные серверы файлов и медиа Ошибка сохранения сервера @@ -2320,13 +2319,50 @@ Нет серверов для доставки сообщений. Вы можете настроить серверы позже. SimpleX Chat и Flux заключили соглашение добавить серверы под управлением Flux в приложение. - Приложение улучшает конфиденциальность используя разных операторов в каждом разговоре. + Приложение защищает вашу конфиденциальность, используя разные операторы в каждом разговоре. Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем. Ошибка сохранения серверов Условия будут приняты для включенных операторов через 30 дней. Ошибка приема условий Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности. - Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux. + Например, если ваш контакт получает сообщения через сервер SimpleX Chat, ваше приложение будет доставлять их через сервер Flux. Прямые сообщения между членами запрещены в этом разговоре. + Группы + Удалить + Удалить список? + Все + Изменить порядок + Избранное + запрошено соединение + Редактировать + Предприятия + Включить журналы + О операторах + Ошибка при сохранении базы данных + Соединение не готово. + Ошибка обновления списка чата + Ошибка создания списка чатов + Список + Никаких чатов в списке %s. + Без непрочитанных чатов + Никаких чатов + Чаты не найдены + Все чаты будут удалены из списка %s, а сам список удален + Добавить список + Примечания + Открыто с %s + Создать список + Добавить в список + Список изменений + Сохранить список + Имя списка... + Исправить соединение? + Соединение требует повторного согласования шифрования. + Исправление + Выполняется повторное согласование шифрования. + принятое приглашение + Ошибка при загрузке списков чатов + Контакты + Название списка и эмодзи должны быть разными для всех списков. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index ebf57836b5..b4d854c3d1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1328,4 +1328,4 @@ ในการตอบกลับถึง ไม่มีประวัติ encryptionใช้ได้ - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 503d82158f..5d15ef1750 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -93,7 +93,7 @@ Tercihleri kaydet\? Profil parolasını kaydet Profil sadece konuştuğun kişilerle paylaşılır. - Gizli iletişimin\ngelecek kuşağı + Mesajlaşmanın geleceği Ses kapalı Doğrulama iptal edildi Yeniden başlat @@ -1201,7 +1201,7 @@ Mobilden tara Bağlantıları onayla Mesaj paylaş… - Önceki mesajın hash\'i farklı. + Önceki mesajın hash\'i farklı.\" SimpleX Kilit aktif değil! SimpleX Kilit Doğrudan bağlanılsın mı? @@ -2124,4 +2124,18 @@ sadece bir kişiyle kullanılabilir - yüz yüze veya herhangi bir mesajlaşma programı aracılığıyla paylaşın]]> %s.]]> %s.]]> + Arkadaş ekle + İş konuşmaları + Takım üyesi ekle + kabul edilmiş davet + İş adresi + Takım üyelerini konuşmalara ekle + Uygulama her zaman arka planda çalışır + Operatörler hakkında + a+b + Liste ekle + Hepsi + Listeye ekle + %s listesindeki bütün sohbetler kaldırılacak ve liste silinecek + İşletmeler \ No newline at end of file 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 eff112717e..b592fec10c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -19,7 +19,7 @@ Про SimpleX Chat Додавайте сервери, скануючи QR-коди. Всі чати і повідомлення будуть видалені - цю дію неможливо скасувати! - Дозволяйте дзвінки тільки в разі дозволу вашого контакту. + Дозволити дзвінки тільки за умови, що ваш контакт дозволяє їх. Дозволити безповоротне видалення повідомлень, тільки якщо ваш контакт дозволяє вам. (24 години) Дозволити голосові повідомлення\? Пароль застосунку замінено паролем самознищення. @@ -29,21 +29,21 @@ Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. Адміністратори можуть створювати посилання для приєднання до групи. Збірка додатку: %s - Дозволяйте голосові повідомлення тільки в разі дозволу вашого контакту. - Дозволяйте надсилати повідомлення, які зникають. + Дозволити голосові повідомлення тільки за умови, що ваш контакт дозволяє їх. + Дозволити надсилати зникаючі повідомлення. прийнятий виклик Завжди використовувати реле ДОДАТОК - Дозволяйте надсилати прямі повідомлення учасникам. + Дозволити надсилання приватних повідомлень учасникам. Дозволити безповоротно видаляти надіслані повідомлення. (24 години) Дозволяйте надсилати голосові повідомлення. Дозволити реакції на повідомлення. Вся інформація стирається при його введенні. Пароль для додатка ІКОНКА ДОДАТКУ - Дозволяйте повідомлення, які зникають, тільки якщо ваш контакт дозволяє їх. + Дозволити зникаючі повідомлення тільки за умови, що ваш контакт дозволяє їх. Дозвольте вашим контактам додавати реакції на повідомлення. - Дозволяйте реакції на повідомлення тільки в разі дозволу вашого контакту. + Дозволити реакції на повідомлення тільки за умови, що ваш контакт дозволяє їх. Створений порожній профіль чату з наданим ім\'ям, і застосунок відкривається, як завжди. Додатковий акцент Дозволити вашим контактам безповоротно видаляти надіслані повідомлення. (24 години) @@ -224,12 +224,11 @@ Скасувати приглушення Ви запросили контакт Контакт, якому ви поділилися посиланням, НЕ зможе підключитися! - заповнювач зображення профілю + аватар не встановлено QR-код довідка покажіть QR-код у відеовиклику, або поділіться посиланням.]]> - Ваш профіль чату буде відправлено -\nвашому контакту + Ваш профіль буде відправлено \nвашому контакту Одноразове запрошення Невірний код безпеки! Для перевірки end-to-end шифрування порівняйте (або скануйте) код на своїх пристроях. @@ -271,7 +270,7 @@ Як це впливає на батарею Миттєво Виклик вже завершено! - Ваші виклики + Виклики Ваші сервери ICE Відкрити через реле @@ -376,7 +375,7 @@ так Налаштування контакту Безпека SimpleX Chat була перевірена компанією Trail of Bits. - Ваші контакти можуть дозволити повне видалення повідомлень. + Ваші контакти можуть дозволити остаточне видалення повідомлень. База даних буде зашифрована. Помилка сховища ключів Невідома помилка @@ -390,7 +389,7 @@ Стан мережі Оновити налаштування мережі\? Локальні дані профілю тільки - Ваш випадковий профіль + Випадковий профіль ввімкнено ввімкнено для вас Забороняйте надсилання прямих повідомлень учасникам. @@ -417,7 +416,7 @@ Пропущений виклик Підключення виклику Конфіденційність і безпека - Ваша конфіденційність + Конфіденційність НАЛАШТУВАННЯ ДОПОМОГА ПІДТРИМАЙТЕ SIMPLEX CHAT @@ -443,7 +442,7 @@ Створити посилання на групу Змінити роль у групі\? Помилка при вилученні учасника - Ваш профіль чату буде відправлений учасникам групи + Ваш профіль буде відправлений учасникам групи Видалення для всіх Голосові повідомлення Голосові повідомлення заборонені в цьому чаті. @@ -451,20 +450,20 @@ Встановіть його замість системної аутентифікації. Вимкнути\? Поділитися з контактами - Ваш профіль зберігається на вашому пристрої і обмінюється лише з ваших контактів. Сервери SimpleX не можуть його бачити. - Зберегти і повідомити контакти - Зберегти і повідомити учасників групи + Ваш профіль зберігається на вашому пристрої та ділиться лише з вашими контактами. Серверам SimpleX профіль недоступний. + Зберегти та сповістити контакти + Зберегти та сповістити учасників Вийти без збереження Сховати профіль Пароль для відображення Створити без зашифрування e2e контакт має зашифрування e2e - Хеш попереднього повідомлення відрізняється. + Хеш попереднього повідомлення інший.\" Підтвердити пароль Новий пароль Перезапустити - Ваша база даних чату + База даних чату Чат зупинено БАЗА ДАНИХ ЧАТУ Новий архів бази даних @@ -516,7 +515,7 @@ Зберігайте останню чернетку повідомлення із вкладеннями. Зникне повідомлення Надіслати зникне повідомлення - зображення профілю + аватар Більше Створити профіль GitHub.]]> @@ -554,7 +553,7 @@ Блокування SimpleX Щоб захистити вашу інформацію, увімкніть блокування SimpleX. \nВам буде запропоновано завершити аутентифікацію перед увімкненням цієї функції. - Увійти за допомогою своїх облікових даних + Пройдіть аутентифікацію Увімкнути блокування SimpleX Блокування SimpleX не увімкнено! Поділитися @@ -569,8 +568,8 @@ Файл буде видалено з серверів. Відкликати несанкціонована відправка - Ласкаво просимо, %1$s! - Ласкаво просимо! + Вітаємо, %1$s! + Вітаємо! Цей текст доступний у налаштуваннях вас запрошено в групу Поділитися повідомленням… @@ -580,7 +579,7 @@ Одночасно можна відправити лише 10 відео Помилка декодування зображення Неможливо декодувати зображення. Спробуйте інше зображення або зв\'яжіться з розробниками. - Зображення + Фото Зображення буде отримано, коли ваш контакт завершить його вивантаження. Зображення буде отримано, коли ваш контакт буде в мережі, будь ласка, зачекайте або перевірте пізніше! Зображення збережено в галереї @@ -609,8 +608,8 @@ Логотип SimpleX Електронна пошта Цей QR-код не є посиланням! - Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, зачекайте або перевірте пізніше! - Вас підключать, коли ваш запит на з\'єднання буде прийнятий, зачекайте або перевірте пізніше! + Ви будете підключені до групи, коли пристрій власник групи буде в мережі, зачекайте або перевірте пізніше! + Підключення відбудеться, коли ваш запит на підключення буде прийнято. Будь ласка, зачекайте або спробуйте пізніше! Поділитися 1-разовим посиланням Дізнатися більше Щоб підключитися, ваш контакт може сканувати QR-код або використовувати посилання у додатку. @@ -639,8 +638,8 @@ Ви можете створити його пізніше Ваш поточний профіль Видалити зображення - Зберегти уподобання? - Зберегти і повідомити контакт + Зберегти налаштування? + Зберегти та сповістити контакт Зберегти пароль профілю Пароль схованого профілю Профіль обмінюється лише з вашими контактами. @@ -721,7 +720,7 @@ Роль буде змінено на "%s". Всі учасники групи будуть сповіщені. Роль буде змінено на "%s". Учасник отримає нове запрошення. Група - Ласкаво просимо + Привітальне повідомлення Профіль групи зберігається на пристроях учасників, а не на серверах. Зберегти профіль групи Профіль і підключення до серверів @@ -733,7 +732,7 @@ Світлий Помилка імпорту теми Налаштування групи - Повідомлення зникнення + Зникаючі повідомлення ввімкнено для контакту вимкнено отримано, заборонено @@ -785,7 +784,7 @@ Ви вже підключені до %1$s через це посилання. Режим інкогніто СЕРВЕРИ - Зберегти ласкаво просимо? + Зберегти вітальне повідомлення? Отримання через Приглушено, коли неактивно! Видалити профіль @@ -827,11 +826,11 @@ Інший час Створити одноразове запрошення Сканувати QR-код - Зображення + Фото Відео Прийняте вами з\'єднання буде скасоване! Контакт ще не підключений! - Тестування серверів + Тестувати сервери Зберегти сервери Ваш сервер Тест сервера не вдався! @@ -938,7 +937,7 @@ Тільки власники груп можуть увімкнути голосові повідомлення. Відхилити Очистити чат\? - зображення попереднього перегляду посилання + зображення прев’ю посилання скасувати попередній перегляд посилання Налаштування Виклик у процесі @@ -1004,12 +1003,12 @@ Підключитися за посиланням / QR-кодом Очистити Неправильний QR-код - Вас підключать, коли пристрій вашого контакту буде в мережі, зачекайте або перевірте пізніше! + Підключення відбудеться, коли пристрій вашого контакту буде онлайн. Будь ласка, зачекайте або спробуйте пізніше! Ви не втратите свої контакти, якщо ви пізніше видалите свою адресу. Коли люди просять про з\'єднання, ви можете його прийняти чи відхилити. Посібнику користувача.]]> SimpleX-адреса - Очистити перевірку + Скинути підтвердження %s перевірено %s не перевірено Написати нам ел. листа @@ -1025,7 +1024,7 @@ Обов\'язково КОЛЬОРИ ІНТЕРФЕЙСУ Створіть адресу, щоб дозволити людям підключатися до вас. - Ваші контакти залишаться підключеними. + Контакти залишатимуться підключеними. Створити SimpleX-адресу Оновлення профілю буде відправлено вашим контактам. Зупинити поділ адреси? @@ -1069,12 +1068,12 @@ Привітання Вибрати контакти Поділитися адресою - Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите членів групи, якщо потім видалите її. + Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите учасників групи, якщо потім видалите її. Ви можете поділитися цією адресою зі своїми контактами, щоб вони могли підключитися до %s. Локальна назва Ідентифікатор бази даних Попередній перегляд - Введіть ласкаво просимо… + Введіть привітальне повідомлення… Змінити адресу отримання Створити секретну групу Повністю децентралізовано - видимо тільки для учасників. @@ -1088,7 +1087,7 @@ Ви дозволяєте ні вимк - Встановити налаштування групи + Налаштування групи Налаштування Прямі повідомлення Помилка @@ -1109,7 +1108,7 @@ Видалити контакт Встановити ім\'я контакту… Файл - З галереї + Галерея Команда SimpleX хоче підключитися до вас! ЕКСПЕРИМЕНТАЛЬНІ ФУНКЦІЇ @@ -1146,7 +1145,7 @@ Дзвінки чату SimpleX Служба сповіщень Показати попередній перегляд - Попередній перегляд сповіщень + Перегляд сповіщень Запускається, коли додаток відкритий Запускається періодично Текст повідомлення @@ -1200,16 +1199,14 @@ Експортувати тему Переконайтеся, що файл має правильний синтаксис YAML. Експортуйте тему, щоб мати приклад структури файлу теми. Скинути кольори - за замовчуванням (%s) + типово (%s) Тільки ви можете здійснювати дзвінки. Тільки ваш контакт може здійснювати дзвінки. Що нового Голосові повідомлення За профілем чату (типово) або за підключенням (BETA). Власні теми - - голосові повідомлення до 5 хвилин. -\n- власний час на зникнення. -\n- історія редагування. + - голосові повідомлення до 5 хвилин.\n- налаштування часу для зникнення повідомлень.\n- історія змін. Японський та португальський інтерфейс Натисніть, щоб приєднатися Натисніть, щоб приєднатися анонімно @@ -1439,32 +1436,32 @@ Підключений робочий стіл Новий мобільний пристрій Підключати автоматично - Адреса робочого столу + Адреса комп\'ютера Одночасно може працювати лише один пристрій Підключіть мобільний і десктопний додатки! 🔗 Через безпечний квантовостійкий протокол. - Використовувати з робочого столу у мобільному додатку і скануйте QR-код.]]> + Використовувати з комп\'ютера в мобільному додатку та відскануйте QR-код.]]> Щоб приховати небажані повідомлення. Несумісна версія (новий)]]> - Відсунути відсилання до робочого столу? + Відключити комп\'ютер? Кращі групи - Параметри пов\'язаних робочих столів - Пов\'язані робочі столи + Параметри пов\'язаних комп\'ютерів + Підключені комп\'ютери Виявити через локальну мережу Інкогніто групи Цей пристрій %s був відключений]]> - Очікування робочого столу… + Очікування комп\'ютера… Швидше приєднуйтесь та надійшовні повідомлення. - Пов\'язані мобільні + Під’єднані мобільні Робочий стіл - Підключено до робочого столу + Підключено до комп\'ютера Назва цього пристрою Завантаження файлу - Підключення до робочого столу + Підключення до комп\'ютера Знайдено робочий стіл - Пристрої робочого столу + Комп\'ютери Не сумісно! Зв\'язати з мобільним Використовувати з комп\'ютера @@ -1477,13 +1474,13 @@ Перевірте код на мобільному Введіть назву цього пристрою… Помилка - Підключитися до робочого столу + Підключитися до комп\'ютера Відключити автор Підключено до мобільного - Некоректна адреса робочого столу - Вставити адресу робочого столу - Перевірити код з робочим столом + Некоректна адреса комп\'ютера + Вставити адресу комп\'ютера + Перевірити код з комп\'ютером Сканувати QR-код з комп\'ютера Пристрої Виявлено через локальну мережу @@ -1494,7 +1491,7 @@ Перевірити підключення Відключити робочий стіл? Будь ласка, зачекайте, поки файл завантажується з підключеного мобільного - Версія робочого столу %s не сумісна з цим додатком. + Версія комп\'ютерного додатка %s несумісна з цим додатком. Перевірити підключення Чат зупинено. Якщо ви вже використовували цю базу даних на іншому пристрої, перенесіть її назад перед запуском чату. З зашифрованими файлами та медіа. @@ -1611,7 +1608,7 @@ учасник %1$s змінений на %2$s вилучено зображення профілю Установлено нову адресу контакту - Установлено нове зображення профілю + встановити новий аватар оновлений профіль вилучено адресу контакту Колишній учасник %1$s @@ -1620,7 +1617,7 @@ Аудіодзвінок Помилка відкриття браузера Для використання дзвінків потрібен браузер за замовчуванням. Будь ласка, налаштуйте браузер за замовчуванням в системі та надайте більше інформації розробникам. - Перехід з іншого пристрою + Перенести з іншого пристрою квантово-стійке шифрування e2e стандартне наскрізне шифрування Цей чат захищений наскрізним шифруванням. @@ -1648,8 +1645,8 @@ Повторний імпорт Завершіть міграцію на іншому пристрої. Застосувати - Перенести пристрій - Перехід на інший пристрій + Міграція пристрою + Перенести на інший пристрій Помилка експорту бази даних чату Налаштування збереження помилок Помилка завантаження архіву @@ -1660,7 +1657,7 @@ Помилка видалення бази даних Для того, щоб продовжити, чат слід зупинити. Зупинка чату - %s завантажено + %s вивантажено Завантаження архіву Створення архівного посилання Видалити базу даних з цього пристрою @@ -1751,10 +1748,10 @@ Надайте дозвіл(и) на здійснення дзвінків Відкрити налаштування ФАЙЛИ - Зображення профілю + Зображення профілів Підключення до мережі адміністратори - всі учасники + всіх учасників Литовський інтерфейс Надавати дозволи Кольори чату @@ -1829,7 +1826,7 @@ Так Отримання паралелізму Посилання SimpleX заборонені. - Сформуйте зображення профілю + Форма зображень профілю При підключенні аудіо та відеодзвінків. Скинути колір Система @@ -1846,7 +1843,7 @@ Налаштовані XFTP сервери Бета Статус файлу - Повідомлення надіслано + Надіслано повідомлень Статистика Попередньо підключені сервери Помилковий ключ або невідома адреса чанка файлу - найбільш імовірно, що файл було видалено. @@ -1948,13 +1945,12 @@ Поточний профіль Деталі Помилки - Отримання повідомлення - Повідомлення отримано + Отримання повідомлень + Отримані повідомлення В очікуванні Проксіровані сервери Показувати інформацію для - Починаючи з %s. -\nВсі дані зберігаються лише на вашому пристрою. + Починаючи з %s.\nУсі дані зберігаються приватно на вашому пристрої. Перепідключити сервер для примусової доставки повідомлень. Це використовує додатковий трафік. Скинути всю статистику Скинути всю статистику? @@ -1981,8 +1977,8 @@ Завантажені файли Помилки завантаження Частини видалені - Частини завантажено - Частини завантажено + Частин завантажено + Частин вивантажено Ця посилання було використано на іншому мобільному пристрої, створіть нове посилання на комп\'ютері. Помилка копіювання Будь ласка, перевірте, що мобільний пристрій і комп\'ютер підключені до однієї локальної мережі, і що брандмауер комп\'ютера дозволяє з\'єднання. @@ -2031,7 +2027,7 @@ Завершено Всі профілі Скинути - Завантажено + Вивантажено Видалити %d повідомлень учасників? Повідомлення будуть позначені для видалення. Одержувач(і) зможуть розкрити ці повідомлення. Вибрати @@ -2062,7 +2058,7 @@ Видалити архів? Поділитися профілем Завантажений архів бази даних буде остаточно видалено з серверів. - Ваше з\'єднання було перенесено на %s, але виникла несподівана помилка під час перенаправлення на профіль. + Підключення було перенесено до %s, але під час перенаправлення на профіль сталася непередбачена помилка. Режим системи Не використовуйте облікові дані з проксі. Аутентифікація проксі @@ -2140,24 +2136,24 @@ Прийняті умови Умови будуть автоматично прийняті для увімкнених операторів: %s. Оператор мережі - %s серверів + %s сервери Вебсайт Ваші сервери - Використовуйте %s - Використовуйте сервери + Використовувати %s + Використовувати сервери %s.]]> %s.]]> Прийняти умови - Умови перегляду + Переглянути умови Додано сервери повідомлень Для приватної маршрутизації - Щоб отримати - Використовуйте для файлів + Для отримання повідомлень + Використовувати для файлів Додано медіа та файлові сервери - Відкриті зміни + Відкрити зміни Відкриті умови Сервери для нових файлів вашого поточного профілю чату - Щоб відправити + Для відправки Помилка додавання сервера Сервер оператора Сервер додано до оператора %s. @@ -2195,7 +2191,7 @@ SimpleX адреса або одноразове посилання? Новий сервер Немає серверів для отримання файлів. - Умови перегляду + Переглянути умови Немає серверів для надсилання файлів. Попередньо встановлені сервери Протокол сервера змінено. @@ -2204,14 +2200,14 @@ З\'єднання досягло ліміту недоставлених повідомлень, ваш контакт може бути офлайн. Натисніть Створити адресу SimpleX у меню, щоб створити її пізніше. Додаток захищає вашу конфіденційність, використовуючи різних операторів у кожній розмові. - Використовуйте для повідомлень + Використовувати для повідомлень Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. Або імпортуйте архівний файл Віддалені мобільні Пристрої Xiaomi: будь ласка, увімкніть Автозапуск у налаштуваннях системи, щоб сповіщення працювали.]]> Повідомлення занадто велике! Будь ласка, зменшіть розмір повідомлення або видаліть медіа та надішліть знову. - Додайте учасників команди до розмов. + Додайте учасників команди до розмови. Бізнес адреса Перевіряти повідомлення кожні 10 хвилин. Без фонової служби @@ -2242,9 +2238,95 @@ Будь ласка, зменшіть розмір повідомлення та надішліть знову. Скопіюйте та зменшіть розмір повідомлення для відправки. Ви припините отримувати повідомлення з цього чату. Історія чату буде збережена. - Ваш профіль чату буде надіслано учасникам чату. + Ваш профіль буде надіслано учасникам чату. Коли увімкнено більше ніж одного оператора, жоден з них не має метаданих, щоб дізнатися, хто спілкується з ким. прийнято запрошення запит на підключення Про операторів - \ No newline at end of file + SimpleX Chat та Flux уклали угоду про включення серверів, що працюють на Flux, до додатку. + Підключення вимагає повторного узгодження шифрування. + Виконується повторне узгодження шифрування. + Виправити + Виправити підключення? + Увімкнути журнали + Помилка збереження бази даних + a + b + закреслити + Підключення не готове. + Відкрити за допомогою %s + Немає чатів у списку %s. + Немає непрочитаних чатів + всі + Зберегти список + Видалити + Видалити список? + Назва списку... + Усі чати буде видалено зі списку %s, а сам список видалено + Контакти + Помилка створення списку чату + Чати не знайдено + Створити список + Вибране + Без чатів + Редагувати + Помилка завантаження списків чату + Помилка оновлення списку чату + Групи + Компанії + Додати до списку + Додати список + Список + Назва списку та емодзі мають відрізнятися для всіх списків. + Нотатки + Список змін + Змінити порядок + Помилка збереження налаштувань + Помилка при створенні звіту + Тільки ви та модератори бачать це + Повідомити про спам: тільки модератори групи побачать це. + Порушення правил спільноти + модератор + Причина повідомлення? + Тільки відправник і модератори бачать це + Повідомити + Повідомити про порушення: тільки модератори групи побачать це. + Повідомлення буде архівоване для вас. + Інша причина + архівование повідомлення + Архів + Неприпустимий контент + Повідомити про контент: тільки модератори групи побачать це. + Архівувати повідомлення? + Повідомити про профіль учасника: тільки модератори групи побачать це. + Неприпустимий профіль + Спам + Повідомити інше: тільки модератори групи побачать це. + Архівувати повідомлення + архівоване повідомлення від %s + Видалити повідомлення + 1 повідомлення + %d повідомлень + Повідомлення учасників + Повідомлення + Архівовані повідомлення учасників + Вміст порушує умови використання + Спам + Файл заблоковано оператором сервера: \n%1$s. + Так + Відкрити посилання + Відкрити вебпосилання? + Підключення заблоковано + Запитати + Відкрити посилання зі списку чату + Підключення заблоковано оператором сервера: \n%1$s. + Ні + Назвати чат… + Повідомлення в цьому чаті ніколи не будуть видалені. + Видалити повідомлення чату з вашого пристрою. + Вимкнути автоматичне видалення повідомлень? + Вимкнути видалення повідомлень + 1 рік + типово (%s) + Змінити автоматичне видалення повідомлень? + Цю дію не можна скасувати — повідомлення, надіслані та отримані в цьому чаті раніше за обраний час, будуть видалені. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index c9b30c652a..638c94b012 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -208,21 +208,21 @@ đã hủy bỏ %s Không thể mời liên hệ! Hủy bỏ tin nhắn động - đã thay đổi quyền hạn của %s thành %s + đã thay đổi chức vụ của %s thành %s Dung lượng lưu trữ vượt quá giới hạn - người nhận không thể nhận được tin nhắn vừa gửi trước đó. đã thay đổi địa chỉ cho bạn Thay đổi mật khẩu cơ sở dữ liệu? - đã thay đổi quyền hạn của bạn thành %s + đã thay đổi chức vụ của bạn thành %s Thay đổi chế độ khóa Di động Đổi mật khẩu - Thay đổi quyền hạn của nhóm? + Thay đổi chức vụ nhóm? Thay đổi Thay đổi địa chỉ nhận? Thay đổi địa chỉ nhận Thay đổi chế độ tự hủy Thay đổi mã tự hủy - Thay đổi quyền hạn + Thay đổi chức vụ đang thay đổi địa chỉ… đang thay đổi địa chỉ… Bảng điều khiển trò chuyện @@ -315,7 +315,7 @@ Kết nối đã bị ngắt %s đang ở trong tình trạng không tốt]]> Kết nối tới máy tính - Hết thời gian chờ kết nối + Thời gian chờ kết nối Lỗi kết nối Kết nối Kết nối thông qua liên kết? @@ -464,7 +464,7 @@ Xóa hàng đợi Công cụ nhà phát triển THIẾT BỊ - Tùy chọn nhà phát triển + Tùy chọn cho nhà phát triển Xác thực thiết bị đã bị vô hiệu hóa. Tắt Khóa SimpleX. Lỗi máy chủ đích: %1$s Chỉ báo đã nhận! @@ -491,9 +491,9 @@ Các máy chủ đã kết nối Lỗi xác nhận Đã hoàn thành - Các khúc đã bị xóa - Các khúc đã được tải xuống - Các khúc đã được tải lên + Các khối đã bị xóa + Các khối đã được tải xuống + Các khối đã được tải lên Hồ sơ hiện tại lỗi giải mã Thống kê chi tiết @@ -550,7 +550,7 @@ Không thể gọi thành viên nhóm Xác nhận xóa liên hệ? Liên hệ đã bị xóa! - Các liên hệ đã lưu trữ + Các liên hệ được lưu trữ Tạo Mờ hình ảnh để riêng tư hơn. Kiểm soát mạng của bạn @@ -686,7 +686,7 @@ Lỗi tạo liên hệ thành viên Lỗi hủy bỏ thay đổi địa chỉ Lỗi tạo liên kết nhóm - Lỗi thay đổi quyền hạn + Lỗi thay đổi chức vụ Lỗi kết nối đến máy chủ chuyển tiếp %1$s. Vui lòng thử lại sau. Lỗi xóa liên hệ Lỗi xóa nhóm @@ -737,7 +737,7 @@ Dù đã tắt trong cuộc trò chuyện. Lỗi cập nhật cấu hình mạng Lỗi cập nhật quyền riêng tư người dùng - Mở rộng chọn quyền hạn + Mở rộng chọn chức vụ THỬ NGHIỆM Mở rộng Thoát mà không lưu @@ -940,7 +940,7 @@ Cài đặt SimpleX Chat cho cửa sổ câu lệnh ẩn danh qua liên kết địa chỉ liên lạc Cuộc gọi thoại đến - Quyền hạn ban đầu + Chức vụ ban đầu ẩn danh qua liên kết nhóm Mã bảo mật không đúng! Trả lời đến @@ -1144,7 +1144,7 @@ Cuộc trò chuyện mới phút Kho lưu trữ cơ sở dữ liệu mới - Quyền hạn thành viên mới + Chức vụ thành viên mới Nhiều cải tiến hơn nữa sắp ra mắt! %s có một phiên bản không được hỗ trợ. Xin vui lòng đảm bảo rằng bạn dùng cùng một phiên bản trên cả hai thiết bị.]]> Các tùy chọn phương tiện mới @@ -1217,7 +1217,7 @@ Không có gì được chọn Mở Mở bảng điều khiển trò chuyện - Mở hồ sơ trò chuyện + Thay đổi hồ sơ trò chuyện Dịch vụ onion sẽ được yêu cầu để kết nối.\nXin lưu ý: bạn sẽ không thể kết nối tới các máy chủ mà không có địa chỉ .onion. Mở Mở thư mục cơ sở dữ liệu @@ -1373,9 +1373,9 @@ %1$s rồi.]]> Mức sử dụng pin ứng dụng / Không hạn chế trong phần cài đặt ứng dụng.]]> %s]]> - cho phép SimpleX chạy trong nền trong hộp thoại tiếp theo. Nếu không, thông báo sẽ bị vô hiệu hóa.]]> + Chọn cho phép trong hộp thoại tiếp theo để nhận thông báo ngay lập tức.]]> %s, vui lòng chấp nhận điều kiện sử dụng.]]> - Dịch vụ nền SimpleX – nó tiêu tốn một vài phần trăm pin mỗi ngày.]]> + SimpleX chạy trong nền thay vì dùng thông báo đẩy.]]> Mức sử dụng pin ứng dụng / Không hạn chế trong phần cài đặt ứng dụng.]]> %1$s rồi.]]> %1$s!]]> @@ -1399,4 +1399,829 @@ Lỗi cập nhật máy chủ Các điều kiện đã được chấp nhận vào: %s. Các điều kiện sẽ được tự động chấp nhận với các nhà cung cấp được cho phép vào: %s. - \ No newline at end of file + Thiết bị Xiaomi: vui lòng bật Tự động khởi động trong phần cài đặt hệ thống để thông báo có thể hoạt động bình thường.]]> + Ứng dụng luôn chạy dưới nền + Cuộc trò chuyện sẽ bị xóa cho tất cả các thành viên - việc này không thể được hoàn tác! + Cuộc trò chuyện sẽ bị xóa cho bạn - việc này không thể được hoàn tác! + Các cuộc trò chuyện công việc + Thêm các thành viên nhóm + Cuộc trò chuyện + %1$s rồi.]]> + Cuộc trò chuyện đã tồn tại! + Thêm bạn bè + đã chấp nhận lời mời + mã hóa đầu cuối, với bảo mật sau ượng tử trong các tin nhắn trực tiếp.]]> + Giới thiệu về các nhà cung cấp + Thêm các thành viên nhóm của bạn vào các cuộc trò chuyện. + Địa chỉ doanh nghiệp + Lỗi lưu cơ sở dữ liệu + Tin nhắn trực tiếp giữa các thành viên bị cấm trong cuộc trò chuyện này. + Dành cho hồ sơ trò chuyện %s: + Kiểm tra tin nhắn mỗi 10 phút. + Ví dụ, nếu liên hệ của bạn nhận tin nhắn thông qua một máy chủ SimpleX Chat, ứng dụng của bạn sẽ gửi chúng qua một máy chủ Flux. + Cách nó cải thiện sự riêng tư + Mời để trò chuyện + Rời cuộc trò chuyện + Cho định tuyến riêng tư + để bảo mật siêu dữ liệu tốt hơn. + Đã cải thiện điều hướng trò chuyện + Xóa cuộc trò chuyện + Cho mạng xã hội + Rời cuộc trò chuyện? + Kết nối yêu cầu thiết lập lại mã hóa. + Đang trong quá trình thiết lập lại mã hóa. + Sửa + Sửa kết nối? + Xóa cuộc trò chuyện? + Tin nhắn trực tiếp giữa các thành viên bị cấm. + Bật logs + a + b + Kết nối chưa sẵn sàng. + Lỗi cập nhật danh sách trò chuyện + Lỗi tạo danh sách trò chuyện + Lỗi tải các danh sách trò chuyện + Các liên hệ + Nhóm + Thêm danh sách + Tất cả + Xóa + Xóa danh sách? + Chỉnh sửa + Tên danh sách... + Xóa hồ sơ trò chuyện cho + Tạo danh sách + Thêm vào danh sách + Các doanh nghiệp + Tất cả các cuộc trò chuyện sẽ bị xóa khỏi danh sách %s, và danh sách cũng sẽ bị xóa + Ưa thích + Danh sách + Xóa tin nhắn sau + Không có máy chủ tin nhắn. + Không có máy chủ để định tuyến tin nhắn riêng tư. + Không có máy chủ để nhận tin nhắn. + Tin nhắn quá lớn! + Các nhà cung cấp mạng + Thông báo và pin + Chỉ có các chủ cuộc trò chuyện mới có thể thay đổi các tùy chọn. + Thành viên sẽ bị xóa khỏi cuộc trò chuyện - việc này không thể được hoàn tác! + Nhà cung cấp mạng + Phi tập trung hóa mạng lưới + Không có tin nhắn + Không có cuộc trò chuyện nào + Không tìm thấy cuộc trò chuyện nào + Không có cuộc trò chuyện nào trong dánh sách %s. + Không có cuộc trò chuyện nào chưa được đọc + Tên danh sách và biểu tượng cảm xúc phải khác nhau đối với tất cả các danh sách. + Không có máy chủ để nhận tệp. + Không có máy chủ để gửi tệp. + Không có dịch vụ nền + Máy chủ mới + Không có máy chủ tệp và phương tiện. + Thanh công cụ trò chuyện trong tầm + Ngẫu nhiên + Các máy chủ cài sẵn + Xem thay đổi + Xem điều kiện + Bên vận hành máy chủ + - Mở cuộc trò chuyện từ tin nhắn chưa đọc đầu tiên.\n- Nhảy tới tin nhắn được trích dẫn. + Hoặc nhập tệp lưu trữ + Mã hóa kháng lượng tử + Sự riêng tư cho các khách hàng của bạn. + Xin vui lòng giảm kích thước tin nhắn và gửi lại. + Xin vui lòng giảm kích thước tin nhắn hoặc xóa phương tiện và gửi lại. + Xác thực proxy + Mã QR + Mở bằng %s + Thanh công cụ trò chuyện trong tầm + Đánh giá ứng dụng + Mật khẩu ngẫu nhiên được lưu trong cài đặt dưới dạng văn bản thuần túy.\nBạn có thể thay đổi nó sau. + Các máy chủ được proxy + Đọc thêm + Thanh công cụ ứng dụng trong tầm + Hoặc chia sẻ một cách riêng tư + Bên vận hành + mã hóa đầu cuối kháng lượng tử + đã nhận lời đáp… + Đã nhận tin nhắn + Đang nhận qua + đã nhận, bị cấm + Lịch sử gần đây và đã cải thiện bot thư mục. + Việc nhận tệp vẫn chưa được hỗ trợ + Đã nhận lời đáp + Địa chỉ nhận sẽ được đổi sang một máy chủ khác. Việc thay đổi địa chỉ sẽ hoàn thành sau khi người gửi hoạt động. + đã nhận lời xác nhận… + Kết nối lại tất cả các máy chủ đã được kết nối để buộc gửi tin nhắn. Việc này tiêu tốn thêm lưu lượng. + Kết nối lại tất cả các máy chủ + Đã nhận tổng số + Nhận được lỗi + Người nhận có thể thấy các bản cập nhật tin nhắn khi bạn gõ chúng. + Kết nối lại máy chủ để buộc gửi tin nhắn. Việc này tiêu tốn thêm lưu lượng. + Đã nhận tin nhắn + Việc nhận tệp sẽ bị dừng lại + Đang nhận tin nhắn… + Đã nhận tin nhắn + Kết nối lại máy chủ? + Chỉ báo đã nhận bị tắt + Kết nối lại các máy chủ? + Người nhận không thể xem tin nhắn này đến từ ai. + Kết nối lại + Đang nhận đồng thời + Đã nhận vào + Đã nhận vào: %s + Xóa ảnh + Đã từ chối cuộc gọi + Nhắc lại sau + Làm mới + Đã giảm mức sử dụng pin + Từ chối + Các thiết bị di động từ xa + Xóa + Xóa + Xóa thành viên + Xóa thành viên + Xóa thành viên? + Từ chối + đã xóa + Bản ghi được cập nhật vào + Ghi lại tin nhắn thoại + Xóa mật khẩu khỏi cài đặt? + Xóa mật khẩu khỏi Keystore? + Bản ghi được cập nhật vào: %s + Xóa kho lưu trữ? + đã xóa địa chỉ liên lạc + đã xóa ảnh đại diện hồ sơ + Máy chủ relay chỉ được sử dụng khi cần thiết. Bên khác có thể quan sát địa chỉ IP của bạn. + đã xóa %1$s + đã xóa bạn + đã từ chối cuộc gọi + Máy chủ relay bảo vệ địa chỉ IP của bạn, nhưng nó có thể quan sát thời lượng của cuộc gọi. + Đặt lại các màu + Lặp lại yêu cầu kết nối? + Đặt lại màu + Đặt lại về mặc định + Đặt lại + Lặp lại + đã yêu cầu kết nối + Tải lên lại lần nữa + Thiết lập lại mã hóa? + Nhập lại + Bắt buộc + Tải xuống lại lần nữa + Thiết lập lại + Đặt lại + Trả lời + Đặt lại về chủ đề người dùng + Thiết lập lại mã hóa + Khởi động lại + Khởi động lại ứng dụng để sử dụng cơ sở dữ liệu trò chuyện đã được nhập. + Lặp lại yêu cầu tham gia? + Đặt lại tất cả gợi ý + Đặt lại trở về chủ đề ứng dụng + Khởi động lại ứng dụng để tạo một hồ sơ trò chuyện mới. + Đặt lại tất cả số liệu thống kê + Đặt lại tất cả số liệu thống kê? + Lưu mật khẩu và mở kết nối trò chuyện + Gửi + KHỞI CHẠY KẾT NỐI TRÒ CHUYỆN + Lưu + Quét / Dán đường dẫn + Quét mã QR máy chủ + Lỗi khôi phục cơ sở dữ liệu + %s (hiện tại) + %s vào %s + Chọn các liên hệ + bí mật + gửi tệp chưa được hỗ trợ + Gửi tin nhắn tự xóa + Các tùy chọn của cuộc trò chuyện được chọn không cho phép tin nhắn này. + Quét mã QR + đã lưu + đã lưu từ %s + Đã lưu từ + Quét mã QR từ máy tính + Đã được bảo mật + Thanh tìm kiếm chấp nhận đường dẫn lời mời. + Củng cố hàng đợi + Gửi lỗi + Lưu + Chọn + Mã truy cập tự hủy + Khôi phục + %s và %s đã được kết nối + %s đã được tải xuống + Khôi phục bản sao lưu cơ sở dữ liệu + Chạy khi sử dụng ứng dụng + Thu hồi tệp + Thu hồi tệp? + Thu hồi + gửi tin nhắn trực tiếp + Đã chọn %d + Gửi tin nhắn trực tiếp để kết nối + Đang lưu %1$s tin nhắn + (quét hoặc dán từ bảng nháp) + Chọn các bên vận hành mạng lưới để sử dụng. + Gửi tin nhắn trực tiếp + Lưu lời chào? + gửi thất bại + Quét mã bảo mật từ ứng dụng của liên hệ bạn. + GỬI CHỈ BÁO ĐÃ NHẬN TỚI + Tìm kiếm + Tìm kiếm hoặc dán đường dẫn SimpleX + Lưu danh sách + Thử lại + Lưu và thông báo tới liên hệ + Xem xét lại sau + mã bảo mật đã thay đổi + Lưu hồ sơ nhóm + Lưu và kết nối lại + Tính năng gửi chỉ báo đã nhận sẽ được bật cho tất cả liên hệ. + Xem xét lại các điều kiện + Tỷ lệ + Gửi + Chọn hồ sơ trò chuyện + Quét mã + Mã bảo mật + Lưu các máy chủ? + %s đã được kết nối + Đã lưu tin nhắn + giây + Gửi một tin nhắn động - nó sẽ cập nhật cho (các) người nhận ngay khi bạn gõ + Người gửi đã hủy quá trình truyền tệp. + Mã truy cập tự hủy + Các máy chủ WebRTC ICE đã được lưu sẽ bị xóa. + Khởi động lại kết nối trò chuyện + Lưu + Tính năng gửi chỉ báo đã nhận sẽ được bật cho tất cả các liên hệ trong tất cả các hồ sơ trò chuyện có thể thấy được. + Tiết lộ + Lưu và thông báo tới các liên hệ + Lưu vào thông báo tới các thành viên nhóm + Lưu mật khẩu hồ sơ + Thẩm định bảo mật + Nhận tệp một cách an toàn + Lưu và cập nhật hồ sơ nhóm + giây + Đã lưu + Tự hủy + Chọn + Mã truy cập tự hủy đã được kích hoạt! + Lưu mật khẩu trong Keystore + %s và %s + Chức vụ + Các nhóm trở nên an toàn hơn + Quét từ di động + Ghi chú + tìm kiếm + Thay đổi danh sách + Thay đổi thứ tự + Lưu các máy chủ + Lưu cài đặt tự động chấp nhận + Lưu tùy chọn? + Lưu cài đặt? + Khôi phục bản sao lưu cơ sở dữ liệu? + Lưu mật khẩu trong cài đặt + Mã truy cập tự hủy đã được đổi! + Người gửi có thể đã xóa yêu cầu kết nối. + Phiên bản máy chủ không tương thích với ứng dụng của bạn: %1$s. + Đặt 1 ngày + đặt ảnh đại diện mới + Các tin nhắn đã gửi sẽ bị xóa sau thời gian đã cài. + thông tin hàng đợi máy chủ: %1$s\n\ntin nhắn được nhận cuối cùng: %2$s + CÀI ĐẶT + Đã gửi vào + Địa chỉ máy chủ + Mã phiên + Lời đáp đã gửi + Đặt tên liên hệ… + Gửi tin nhắn động + Đặt tên liên hệ + Bên vận hành máy chủ đã được đổi. + Giao thức máy chủ đã được đổi. + Gửi tin nhắn trực tiếp khi địa chỉ của bạn hoặc địa chỉ đích không hỗ trợ định tuyến riêng tư. + Gửi xem trước đường dẫn + Các tin nhắn đã gửi + Thông tin các máy chủ + Máy chủ yêu cầu xác thực để tạo hàng đợi, kiểm tra mật khẩu + Việc gửi tệp sẽ bị dừng lại. + Gửi thư điện tử cho chúng tôi + Đã gửi vào: %s + Máy chủ đã được thêm cho bên vận hành %s. + Địa chỉ máy chủ không tương thích với cài đặt mạng. + Phiên bản máy chủ không tương thích với cài đặt mạng. + đã gửi + Cài đặt + Gửi các câu hỏi và ý tưởng + Đặt nó thay vì sử dụng xác thực hệ thống. + Đã gửi tin nhắn + Đặt mật khẩu để xuất + Đang gửi qua + Đặt mật khẩu + Cài đặt + Địa chỉ máy chủ không tương thích với cài đặt mạng: %1$s. + CÁC MÁY CHỦ + Máy chủ yêu cầu xác thực để tải lên, kiểm tra mật khẩu + Máy chủ + Đặt mã truy cập + Gửi chỉ báo đã nhận bị tắt cho %d liên hệ + Đặt tùy chọn nhóm + Số liệu thống kê các máy chủ sẽ được đặt lại - việc này không thể được hoàn tác! + Gửi tin nhắn để cho phép gọi điện. + Đã gửi qua proxy + Gửi chỉ báo đã nhận được bật cho %d liên hệ + Đặt mật khẩu cơ sở dữ liệu + Gửi chỉ báo đã nhận + đặt địa chỉ liên lạc mới + Tin nhắn đã gửi + Đặt chủ đề mặc định + Gửi tối đa 100 tin nhắn cuối cùng tới các thành viên mới. + Tổng số đã gửi + Gửi Tin nhắn + Kiểm tra máy chủ thất bại! + Gửi tin nhắn trực tiếp khi địa chỉ IP được bảo vệ và máy chủ của bạn hoặc máy chủ đích không hõ trợ định tuyến riêng tư. + Gửi chỉ báo đã nhận bị tắt cho %d nhóm + Gửi chỉ báo đã nhận được bật cho %d nhóm + Đã gửi trực tiếp + Hiển thị thông điệp tới các thành viên mới! + Chia sẻ địa chỉ SimpleX trên mạng xã hội. + Hiển thị liên hệ và tin nhắn + Hiển thị trạng thái tin nhắn + Hiển thị: + Chia sẻ địa chỉ một cách công khai + Hiển thị + Hiển thị các tin nhắn cuối cùng + Chia sẻ + Hiển thị bảng điều khiển trong cửa sổ mới + Lỗi tạo báo cáo + Chia sẻ tin nhắn… + Hiển thị danh sách trò chuyện trong cửa sổ mới + Chỉ hiển thị liên hệ + Chia sẻ đường dẫn dùng 1 lần này + Chia sẻ với các liên hệ + Chia sẻ tệp… + Chia sẻ địa chỉ + Chia sẻ nội dung đa phương tiện… + Chia sẻ hồ sơ + Hiển thị tùy chọn cho nhà phát triển + Tạo khuôn ảnh đại diện + Thiết lập mật khẩu cơ sở dữ liệu + Đang hiển thị thông tin cho + Chia sẻ đường dẫn dùng 1 lần + Chia sẻ địa chỉ với các liên hệ? + Hiển thị tỷ lệ phần trăm + Hiển thị lỗi nội bộ + Chia sẻ đường dẫn + Chia sẻ đường dẫn dùng 1 lần với một người bạn + Lỗi lưu cài đặt + Hiển thị bản xem trước + Các cuộc gọi SimpleX Chat + simplexmq: v%s (%2s) + Các đường dẫn SimpleX là không được phép + Khóa SimpleX + Các đường dẫn SimpleX + Các tin nhắn SimpleX Chat + Địa chỉ SimpleX + SimpleX không thể chạy trong nền. Bạn sẽ chỉ nhận được thông báo khi ứng dụng đang chạy. + Chế độ Khóa SimpleX + Địa chỉ liên lạc SimpleX Chat + Đường dẫn nhóm SimpleX + Các đường dẫn SimpleX + Lời mời SimpleX dùng một lần + Khóa SimpleX + SimpleX Chat và Flux đã đi đến một thỏa thuận để đưa các máy chủ do Flux vận hành vào ứng dụng. + SimpleX + Địa chỉ SimpleX + Tắt? + Tắt + Sự an toàn của SimpleX Chat đã được kiểm định bởi Trail of Bits. + Dịch vụ SimpleX Chat + Khóa SimpleX đã được bật + Hiển thị mã QR + Địa chỉ SimpleX và các đường dẫn dùng một lần đều an toàn để chia sẻ thông qua bất kỳ ứng dụng nhắn tin nào. + Logo SimpleX + Hiển thị các lượt yêu cầu API chậm + Địa chỉ SimpleX hay đường dẫn dùng 1 lần? + Khóa SimpleX không được bật! + Các đường dẫn SimpleX là bị cấm. + Chỉ người gửi và các kiểm duyệt mới xem được + Lưu trữ bản báo cáo? + đã lưu trữ bản báo cáo + Báo cáo nội dụng: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Lưu trữ + Một lý do khác + kiểm duyệt viên + Báo cáo hồ sơ thành viên: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Đội ngũ SimpleX + Các giao thức SimpleX đã được xem xét bởi Trail of Bits. + Đã đơn giản hóa chế độ ẩn danh + Vi phạm các nguyên tắc cộng động + Nội dung không phù hợp + Hồ sơ không phù hợp + Chỉ bạn và các kiểm duyệt viên mới xem được + Báo cáo + Lý do báo cáo? + Báo cáo khác: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Báo cáo tin nhắn rác: chỉ có các kiểm duyệt viên trong nhóm là xem được. + Báo cáo vi phạm: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Kích thước + %s đã được xác minh + %s chưa được xác minh + Các tin nhắn bị bỏ qua + Bỏ qua việc mời các thành viên + Loa ngoài bật + Âm thanh đã bị tắt + Ổn định + PROXY SOCKS + Các nhóm nhỏ (tối đa 20 thành viên) + Một vài lỗi không nghiêm trọng đã xảy ra trong lúc nhập: + Loa ngoài tắt + Lưu trữ báo cáo + Xóa báo cáo + Các máy chủ %s + Hàm chạy chậm + Nhẹ + %s giây + Một vài máy chủ không vượt qua bài kiểm tra: + Thả sao trên Github + %s, %s và %s đã được kết nối + Máy chủ SMP + %s, %s và %d thành viên khác đã được kết nối + mã hóa đầu cuối tiêu chuẩn + Hình vuông, hình tròn, hoặc bất kỳ hình thù gì ở giữa. + proxy SOCKS + Cài đặt proxy SOCKS + đang bắt đầu… + %s, %s và %d thành viên + %s: %s + Bỏ qua phiên bản này + Một số tệp đã không được xuất + Loa ngoài + Bắt đầu kết nối trò chuyện? + Các máy chủ SMP + Bắt đầu kết nối trò chuyện + Tin nhắn rác + Mạnh + Bắt đầu từ %s. + Dừng + Dừng kết nối trò chuyện? + %s đã được tải lên + Đã đăng ký + đã lưu trữ báo cáo bởi %s + Dừng tệp + 1 báo cáo + %d báo cáo + Các báo cáo của thành viên + Đã lưu trữ các báo cáo của thành viên + Lỗi đăng ký dài hạn + Các báo cáo + Bắt đầu từ %s.\nTất cả dữ liệu được lưu trữ một cách riêng tư trên thiết bị của bạn.. + Hỗ trợ bluetooth và nhiều cải tiến khác. + Các đăng ký dài hạn bị bỏ qua + Dừng nhận tệp? + Dừng gửi tệp? + Tạo cuộc trò chuyện mới + Số liệu thống kê + Bắt đầu định kỳ + Dừng + Dừng chia sẻ địa chỉ? + Dừng kết nối trò chuyện để xuất, nhập hoặc xóa cơ sở dữ liệu trò chuyện. Bạn sẽ không thể nhận hay gửi tin nhắn trong khi kết nối trò chuyện bị dừng lại. + Nhập + Đang dừng kết nối trò chuyện + Dừng chia sẻ + gạch ngang + Dừng kết nối trò chuyện + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Hệ thống + Nhấn để tham gia + Thời gian chờ kết nối TCP + Nhấn để kích hoạt hồ sơ. + Hệ thống + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Chế độ hệ thống + HỖ TRỢ SIMPLEX CHAT + Lỗi tệp tạm thời + Nhấn nút + Kết nối TCP + Hệ thống + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Nhấn để Kết nối + Nhấn Tạo địa chỉ SimpleX ở menu để tạo sau. + Nhấn để tham gia một cách ẩn danh + Hệ thống + Chuyển đổi âm thanh và video trong lúc gọi. + Chuyển đổi hồ sơ trò chuyện cho các lời mời dùng một lần. + Kiểm tra thất bại tại bước %s. + Xác thực hệ thống + Nhấn để quét + Cảm ơn bạn đã cài đặt SimpleX Chat! + Kiểm tra máy chủ + Kiểm tra các máy chủ + Nhấn để bắt đầu một cuộc trò chuyện mới + Nhấn để dán đường dẫn + Đuôi + Chuyển đổi + Kết nối đã chạm giới hạn của các tin nhắn chưa được gửi đi, liên hệ của bạn có thể đang ngoại tuyến. + Tương lai của nhắn tin + Hình ảnh không thể được giải mã. Xin vui lòng thử lại với một hình ảnh khác hoặc liên lạc với các nhà phát triển. + Các tin nhắn sẽ bị xóa cho tất cả các thành viên. + Mã hóa đang hoạt động và thỏa thuận mã hóa mới là không bắt buộc. Nó có thể dẫn đến các lỗi kết nối! + Ứng dụng sẽ yêu cầu bạn xác nhận các lượt tải xuống từ các máy chủ truyền tệp không xác định (ngoại trừ .onion hoặc khi proxy SOCKS được sử dụng). + Kết nối đã bị chặn + Kết nối đã bị chặn bởi bên vận hành máy chủ:\n%1$s. + Nội dung vi phạm các điều kiện sử dụng + Tin nhắn rác + Tệp đã bị chặn bởi bên vận hành máy chủ:\n%1$s. + Cơ sở dữ liệu đang không hoạt động như bình thường. Nhấn để tìm hiểu thêm + Không + Mở đường dẫn + Yêu cầu + Mở đường dẫn web? + Mở các đường dẫn từ danh sách trò chuyện + Kết nối bạn đã chấp nhận sẽ bị hủy bỏ! + Liên hệ mà bạn đã chia sẻ đường dẫn này sẽ không thể kết nối! + Trình duyệt web mặc định là cần thiết cho các cuộc gọi. Xin vui lòng thiết lập trình duyệt mặc định trong hệ thống, và chia sẻ thêm thông tin với các nhà phát triển. + Ứng dụng bảo vệ sự riêng tư của bạn bằng cách sử dụng các bên vận hành khác nhau trong mỗi cuộc trò chuyện. + Ứng dụng có thể bị đóng sau 1 phút chạy trong nền. + Ứng dụng tìm nhận tin nhắn mới một cách định kỳ - nó tiêu tốn một vài phần trăm pin mỗi ngày. Ứng dụng không sử dụng thông báo đẩy - dữ liệu ở thiết bị của bạn không được gửi đi tới máy chủ nào. + Mã mà bạn đã quét không phải là một mã QR liên kết SimpleX. + Chủ đề + Mã băm của tin nhắn trước có sự khác biệt.\" + ID của tin nhắn tiếp theo là không chính xác (nhỏ hơn hoặc bằng với cái trước).\nViệc này có thể xảy ra do một vài lỗi hoặc khi kết nối bị xâm phạm. + Nỗ lực đổi mật khẩu cơ sở dữ liệu đã không được hoàn thành. + Tên thiết bị sẽ được chia sẻ với thiết bị di động đã được kết nối. + CÁC CHỦ ĐỀ + Tên hiển thị này không hợp lệ. Xin vui lòng chọn một cái tên khác. + Hồ sơ chỉ được chia sẻ với các liên hệ của bạn. + Cuộc trò chuyện này được bảo vệ bằng mã hóa đầu cuối có kháng lượng tử. + Mật khẩu được lưu trữ trong cài đặt dưới dạng thuần văn bản. + Thiết bị này + Video không thể được giải mã. Xin vui lòng thử với một video khác hoặc liên lạc với các nhà phát triển. + Tin nhắn sẽ bị đánh dấu là đã được kiểm duyệt cho tất cả các thành viên. + Tin nhắn sẽ bị xóa cho tất cả các thành viên. + Việc này không thể được hoàn tác - tất cả các nội dung đa phương tiện và tệp đã được gửi và nhận sẽ bị xóa. Những hình ảnh chất lượng thấp sẽ được giữ lại. + Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận sớm hơn so với thời gian được chọn sẽ bị xóa. Có thể mất vài phút để hoàn thành. + Nhóm này không còn tồn tại. + Nhóm này có trên %1$d thành viên, chỉ báo đã nhận không được gửi. + Các tin nhắn sẽ bị đánh dấu là đã được kiểm duyệt cho tất cả thành viên. + Tên thiết bị này + Chúng có thể bị ghi đề trong cài đặt liên hệ và nhóm. + Cuộc trò chuyện này được bởi vệ bằng mã hóa đầu cuối. + Tính năng này chưa được hỗ trợ. Hãy thử bản phát hành tiếp theo. + Dấu tick thứ hai mà chúng ta từng thiếu! ✅ + Những máy chủ cho tệp mới của hồ sơ trò chuyện hiện tại của bạn + Nền tảng ứng dụng và nhắn tin bảo vệ sự riêng tư và bảo mật của bạn. + Bên vận hành được cài sẵn thứ hai trong ứng dụng! + Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong cuộc trò chuyện sẽ được thông báo. + Văn bản bạn vừa dán không phải là một đường dẫn SimpleX. + Chức vụ sẽ được đổi thành %s. Thành viên sẽ nhận được một lời mời mới. + Mật khẩu sẽ được lưu trữ trong cài đặt dưới dạng thuần văn bản sau khi bản đổi nó hoặc khởi động lại ứng dụng. + Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn + Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong nhóm sẽ được thông báo. + Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. + Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể không thể khôi phục. + Những máy chủ cho các kết nối mới của hồ sơ trò chuyện hiện tại của bạn + Báo cáo sẽ được lưu trữ cho bạn. + Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. + Mã QR này không phải là một đường dẫn! + Đường dẫn này không phải là một đường dẫn kết nối hợp lệ! + Chuyển đổi danh sách trò chuyện: + Thời gian chờ đã hết trong khi kết nối tới máy tính + Để cho phép một ứng dụng di động kết nối tới máy tính, mở cổng này trong tường lửa của bạn, nếu bạn có bật nó lên + Để bảo vệ sự riêng tư của bạn, SimpleX sử dụng các ID riêng biệt cho mỗi liên hệ bạn có. + Để nhận thông báo, xin vui lòng nhập mật khẩu cơ sở dữ liệu + Quá nhiều ảnh! + Quá nhiều video! + Để gửi + Đường dẫn này đã được sử dụng với một thiết bị di động khác, xin vui lòng tạo một đường dẫn mới trên máy tính. + Để bảo vệ thông tin của bạn, bật Khóa SimpleX.\nBạn sẽ được nhắc để hoàn thành xác thực trước khi tính năng này được bật. + Chuỗi ký tự này không phải là một đường dẫn kết nối! + Để thực hiện các cuộc gọi, cho phép sử dụng mic của bạn. Kết thúc cuộc gọi và thử gọi lại. + Để nhận + Để kết nối, liên hệ của bạn có thể quét mã QR hoặc dùng đường dẫn trong ứng dụng. + Tiêu đề + Chuyển đổi ẩn danh khi kết nối. + Để bảo vệ múi giờ, các tệp hình ảnh/âm thanh sử dụng UTC. + Văn bản này có sẵn trong cài đặt + Để được thông báo về các bản phát hành mới, bật kiểm tra định kỳ cho các phiên bản Ổn định hoặc Beta. + Để ẩn các tin nhắn không mong muốn. + Để tiết lộ hồ sơ ẩn của bạn, nhập đầy đủ mật khẩu vào trường tìm kiếm trong trang Các hồ sơ trò chuyện của bạn. + Đây là đường dẫn dùng một lần của riêng bạn! + Cài đặt này áp dụng cho các tin nhắn trong hồ sơ trò chuyện hiện tại của bạn + Đây là địa chỉ SimpleX của riêng bạn! + Để kết nối thông qua đường dẫn + Để bảo vệ đường dẫn của bạn khỏi bị thay thế, bạn có thể so sánh các mã bảo mật liên lạc. + Để bảo vệ địa chỉ IP của bạn, định tuyến riêng tư sử dụng các máy chủ SMP của bạn để gửi tin nhắn. + Cách ly truyền tải + Các máy chủ không xác định + trạng thái không xác định + Lỗi không xác định + Các tin nhắn không được gửi đi + Bỏ yêu thích + Bỏ chặn cho tất cả + Bỏ chặn thành viên cho tất cả? + Để bắt đầu một cuộc trò chuyện mới + Bỏ ẩn hồ sơ trò chuyện + Bỏ ẩn hồ sơ + đã bỏ chặn %s + Bỏ chặn + Bỏ chặn thành viên + Bỏ chặn thành viên? + Độ trong suốt + Các phiên truyền tải + Các máy chủ không xác định! + gửi mà không được cho phép + (để chia sẻ với liên hệ của bạn) + Bỏ ẩn + Bật + Tổng + không xác định + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. + Để xác minh mã hóa đầu cuối với liên hệ của bạn, so sánh (hoặc quét) mã trên các thiết bị của các bạn. + Cách ly truyền tải + định dạng tin nhắn không xác định + Lỗi cơ sở dữ liệu không xác định: %s + Tối đa tới 100 tin nhắn cuối cùng là được gửi tới các thành viên mới. + Sử dụng các thông tin đăng nhập proxy khác cho mỗi kết nối. + Sử dụng các thông tin đăng nhập proxy khác nhau cho mỗi hồ sơ. + Cập nhật chế độ cách ly truyền tải? + Hủy liên kết máy tính? + Tải lên tệp + Có bản cập nhật: %s + Việc cập nhật cài đặt sẽ kết nối lại thiết bị với tất cả các máy chủ. + Đã tải lên các tệp + chưa đọc + Cập nhật + Cập nhật mật khẩu cơ sở dữ liệu + Hủy liên kết + Cập nhật cài đặt mạng? + Tải lên đã thất bại + Đang tải lên bản lưu trữ + Tải xuống bản cập nhật đã bị hủy + Bật thông báo + Nâng cấp và mở kết nối trò chuyện + Bật thông báo + đã cập nhật hồ sơ nhóm + đã cập nhật hồ sơ + Sử dụng hồ sơ hiện tại + Sử dụng kết nối Internet trực tiếp? + Không được bảo vệ + Trừ khi liên hệ của bạn đã xóa kết nối hoặc đường dẫn này đã được sử dụng, có thể đã có lỗi - xin vui lòng báo cáo.\nĐể kết nối, xin vui lòng yêu cầu liên hệ của bạn tạo một đường dẫn kết nối khác và chắc chắn rằng bạn có kết nối mạng ổn định. + Lỗi tải lên + Nâng cấp ứng dụng một cách tự động + Đã tải lên + Cập nhật + Cập nhật + Mở khóa + Xác minh bảo mật kết nối + Sử dụng cho các tệp + Dùng ứng dụng với một tay. + Xác minh mã trên di động + Xác minh mật khẩu cơ sở dữ liệu + Sử dụng định tuyến riêng tư với các máy chủ không xác định. + Sử dụng %s + Sử dụng các máy chủ + Dùng ứng dụng khi đang trong cuộc gọi. + Xác minh kết nối + Đang sử dụng các máy chủ SimpleX Chat. + Xác minh các kết nối + Sử dụng cho các tin nhắn + Tên người dùng + Sử dụng các thông tin đăng nhập ngẫu nhiên + Sử dụng các dịch vụ .onion + Xác minh mã bảo mật + Sử dụng hồ sơ ẩn danh mới + thông qua %1$s + Sử dụng proxy SOCKS + Sử dụng trò chuyện + Sử dụng cho các kết nối mới + Sử dụng máy chủ + Sử dụng proxy SOCKS? + Sử dụng các máy chủ SimpleX Chat? + Xác minh mật khẩu + Xác minh mã với máy tính + Sử dụng mật khẩu ngẫu nhiên + Sử dụng từ máy tính + Sử dụng định tuyến riêng tư với các máy chủ không xác định khi địa chỉ IP không được bảo vệ. + Xem các điều kiện + thông qua đường dẫn dùng một lần + Các tin nhắn thoại bị cấm. + Lịch sử hữu hình + Cuộc gọi video + Video đã được gửi + Tin nhắn thoại… + Các tin nhắn thoại là không được cho phép + Tin nhắn thoại + Video + Tin nhắn thoại (%1$s) + Video bật + Các tin nhắn thoại bị cấm trong cuộc trò chuyện này. + Tin nhắn thoại + Thông qua giao thức kháng lượng tử an toàn. + thông qua đường dẫn nhóm + Thông qua trình duyệt + video + Xem các điều kiện đã được cập nhật + Xem sự cố + Video tắt + Video sẽ được nhận khi liên hệ của bạn hoàn thành việc tải nó lên. + Video sẽ được nhận khi liên hệ của bạn trực tuyến, xin vui lòng chờ hoặc kiểm tra lại sau! + Xem mã bảo mật + Các tệp và video với kích thước tối đa lên tới 1gb + cuộc gọi video + thông qua relay + Tin nhắn thoại + cuộc gọi video (không được mã hóa đầu cuối) + thông qua đường dẫn địa chỉ liên lạc + Video + Chúng tôi không lưu bất kỳ liên hệ hay tin nhắn nào của bạn (một khi đã được gửi) trên các máy chủ. + Website + Xin chào %1$s! + Khi có sẵn + Các tin nhắn thoại bị cấm! + Lời chào + Màu sơ cấp hình nền + Đang chờ hình ảnh + Đang chờ hình ảnh + đang chờ xác nhận… + Có gì mới + tuần + Lời chào + Màu nền hình nền + Đang chờ máy tính… + Đang chờ di động để kết nối: + Xin chào! + Đang chờ video + muốn kết nối với bạn! + Đang chờ tệp + đang chờ trả lời… + Lời chào quá dài + - tin nhắn thoại với thời lượng tối đa lên tới 5 phút.\n- tùy chỉnh thời gian để tự xóa.\n- lịch sử chỉnh sử. + Cảnh báo: bạn có thể mất một số dữ liệu! + Cảnh báo: khởi động kết nối trò chuyện trên nhiều thiết bị không được hỗ trợ và sẽ gây ra các lỗi gửi tin nhắn + Các máy chủ ICE WebRTC + Khi ứng dụng đang chạy + Khi kết nối các cuộc gọi video và âm thanh. + Khi IP bị ẩn + Đang chờ video + + Thay đổi xóa tin nhắn tự động? + Các tin nhắn trong cuộc trò chuyện này sẽ không bao giờ bị xóa. + Máy chủ XFTP + Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các máy chủ truyền tệp. + Sẽ được kích hoạt trong các cuộc trò chuyện trực tiếp! + Mạng ethernet có dây + Khóa sai hoặc kết nối không xác định - khả năng cao kết nối này đã bị xóa. + Mật khẩu sai rồi! + WiFi + + Mật khẩu cơ sở dữ liệu sai + Khi bạn chia sẻ một hồ sơ ẩn danh với ai đó, hồ sơ này sẽ được sử dụng cho các nhóm mà họ mời bạn tham gia. + mặc định (%s) + Xóa tin nhắn trò chuyện khỏi thiết bị của bạn. + Tắt xóa tin nhắn tự động? + Tắt tính năng xóa tin nhắn + Đặt tên cuộc trò chuyện… + Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các relay XFTP sau đây:\n%1$s. + Các máy chủ XFTP + Khi có nhiều hơn một bên vận hành được kích hoạt, không ai trong số họ có siêu dữ liệu để biết được ai trò chuyện với ai. + Với các tệp và nội dung đa phương tiện được mã hóa. + Mức sử dụng pin đã được giảm xuống. + Mức sử dụng pin đã được giảm xuống. + Với lời chào tùy chọn. + Khi mọi người gửi yêu cầu kết nối, bạn có thể chấp nhận hoặc từ chối nó. + Khóa sai hoặc địa chỉ khối tệp không xác định - khả năng cao tệp đã bị xóa. + 1 năm + Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận trong cuộc trò chuyện này sớm hơn thời gian được chọn sẽ bị xóa. + + Bạn có thể hiển thị nó cho các liên hệ SimpleX của mình thông qua Cài đặt. + Bạn có thể tạo nó sau + Bạn có thể thay đổi nói trong cài đặt Giao diện. + Bạn đang tham gia nhóm thông qua đường dẫn này. + Bạn có thể bật vào lúc sau thông qua Cài đặt + BẠN + Bạn có thể chia sẻ một đường dẫn hoặc mã QR - bất kỳ ai cũng sẽ có thể tham gia nhóm. Bạn sẽ không mất các thành viên của nhóm nếu sau này bạn xóa nó đi. + Bạn có thể thử một lần nữa. + Bạn đã kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. + Bạn đã có một hồ sơ trò chuyện với cùng một tên hiển thị. Xin vui lòng chọn một cái tên khác. + Bạn có thể gửi tin nhắn tới %1$s từ Các liên hệ được lưu trữ. + Bạn đã kết nối với %1$s rồi. + Bạn cho phép + Bạn không được kết nối với các máy chủ này. Định tuyến riêng tư được sử dụng để gửi tin nhắn tới chúng. + Bạn có thể định cấu hình các bên vận hành trong cài đặt Mạng & máy chủ. + Bạn đang kết nối thông qua đường dẫn dùng một lần này! + Bạn có thể thử một lần nữa. + Bạn được mời vào nhóm. Tham gia để kết nối với các thành viên nhóm. + Bạn được mời vào nhóm + bạn: %1$s + Bạn có thể tùy chỉnh các máy chủ thông qua cài đặt. + Bạn có thể đặt tên kết nối, để nhớ xem đường dẫn đã được chia sẻ với ai. + bạn là quan sát viên + Bạn có thể sao chép và giảm kích thước tin nhắn để gửi nó đi. + Bạn có thể bật chúng vào lúc sau thông qua cài đặt Quyền riêng tư & Bảo mật của ứng dụng. + Bạn có thể ẩn hoặc tắt thông báo một hồ sơ người dùng - giữ nó trong phần menu. + bạn đã chặn %s + Bạn đã chấp nhận kết nối + bạn + bạn được mời vào nhóm + 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 0477307343..a7938d97d4 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 @@ -229,7 +229,7 @@ 按聊天资料(默认)或按连接(BETA)。 检查服务器地址并重试。 清除验证 - 关闭按键 + 关闭 配置 ICE 服务器 确认 确认你的证书 @@ -1059,7 +1059,7 @@ 当你或你的连接使用旧数据库备份时,可能会发生这种情况。 解密错误 请向开发者报告。 - 上一条消息的散列不同。 + 上一条消息的散列不同。\" 下一条消息的 ID 不正确(小于或等于上一条)。 \n它可能是由于某些错误或连接被破坏才发生。 停止文件 @@ -1232,7 +1232,7 @@ 中止地址更改 允许发送文件和媒体。 文件和媒体 - 只有组主可以启用文件和媒体。 + 只有群主可以启用文件和媒体。 文件和媒体被禁止。 成员可以发送文件和媒体。 禁止发送文件和媒体。 @@ -1917,8 +1917,7 @@ 重置 服务器地址不兼容网络设置:%1$s。 起始自 %s。 - 起始自 %s. -\n所有数据都是设备的私有数据。 + 自 %s 起 \n所有数据均私密地保存在你的设备上.. 已订阅 已认可 服务器版本不兼容你的应用:%1$s. @@ -2087,7 +2086,7 @@ 服务器 打开 Safari 设置/网站/麦克风,接着在 localhost 选择“允许”。 要进行通话,请允许使用设备麦克风。结束通话并尝试再次呼叫。 - 单击地址附近的\"信息\"按钮允许使用麦克风。 + 单击地址栏附近的“信息”按钮允许使用麦克风。 每个服务器都会使用新的 SOCKS5 凭据。 更好的消息日期。 更佳的安全性✅ @@ -2233,4 +2232,88 @@ 已接受邀请 被请求连接 关于运营者 - \ No newline at end of file + SimpleX Chat 和 Flux 达成协议将 Flux 运营的服务器包括在应用中。 + 修复 + 修复连接? + 正进行加密重协商。 + 连接需要加密重协商。 + 保存数据库出错 + 启用日志 + 连接未就绪。 + 创建聊天列表出错 + 更新聊天列表出错 + 加载聊天列表出错 + 列表 + 无聊天 + 找不到聊天 + %s列表中没有聊天。 + 收藏 + + 列表名… + 所有列表的名称和表情符号都应不同。 + 保存列表 + 删除 + 用%s打开 + 将从%s列表删除所有聊天,列表本身也将被删除 + 企业 + 编辑 + 添加到列表 + 联系人 + 创建列表 + 添加列表 + 全部 + 删除列表? + 没有未读聊天 + 附注 + 更改列表 + 更改顺序 + 创建报告出错 + 保存设置出错 + 存档 + 删除举报 + 举报 + 举报其他:仅moderators会看到。 + 举报成员个人资料:仅moderators会看到。 + 举报违规:仅moderators会看到。 + 存档举报 + 举报内容:仅moderators会看到。 + 举报垃圾信息:仅moderators会看到。 + moderators + 另一个理由 + 已存档的举报 + 违反社区指导方针 + 不恰当的内容 + 不恰当的个人资料 + 仅发送人和moderators能看到 + 只有你和moderators能看到 + 垃圾信息 + 存档举报? + 举报理由? + 将为你存档该举报。 + 存档了 %s 的举报 + 举报 + 1 个举报 + 成员举报 + 已存档的成员举报 + %d 个举报 + 垃圾信息 + 连接被阻止 + 连接被服务器运营者阻止:\n%1$s. + 内容违反使用条款 + 文件被服务器运营者阻止:\n%1$s. + 询问 + + + 打开 web 链接? + 打开链接 + 打开来自聊天列表的链接 + 设置聊天名称… + 停用消息自动删除? + 停用消息删除 + 1 年 + 默认(%s) + 从你的设备删除聊天消息。 + 此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。 + 此聊天中的消息永远不会被删除。 + 更改消息自动删除设置? + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index fd58811439..3038dd3d6c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1790,4 +1790,4 @@ 更新 接收總計 稍後提醒 - \ No newline at end of file + From 90a2faae933cf3ab112df12a125b9fec7045caa9 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 19:06:40 +0000 Subject: [PATCH 011/289] website: translations (#5580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Hungarian) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * core: faster history 2 wip * Translated using Weblate (Hungarian) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ --------- Co-authored-by: summoner001 Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: 大王叫我来巡山 --- website/langs/hu.json | 10 +++++----- website/langs/zh_Hans.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/langs/hu.json b/website/langs/hu.json index 7702b56f47..1459c12a03 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -118,8 +118,8 @@ "simplex-unique-overlay-card-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, 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 nem kötelező felhasználói cím esetében is, bár spam kapcsolatfelvételi kérések küldésére használható, megváltoztathatja vagy teljesen törölheti azt 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.", - "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-közvetítő-kiszolgálókon tartózkodnak, amíg be nem érkeznek a címzetthez, majd véglegesen törlődnek onnan.", - "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak közvetítik az üzeneteket, így védve mindkét fél magánéletét.", + "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-továbbító-kiszolgálókon tartózkodnak, amíg be nem érkeznek a címzetthez, majd véglegesen törlődnek onnan.", + "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az általunk biztosított, előre konfigurált kiszolgálókat használják.", "simplex-unique-overlay-card-4-p-2": "A SimpleX platform nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", @@ -128,7 +128,7 @@ "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő platformtól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", "simplex-unique-card-2-p-1": "Mivel a SimpleX platformon nincs azonosítója vagy állandó címe, 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-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.", - "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-közvetítő-kiszolgálókon tartózkodnak, amíg meg nem érkeznek a címzetthez, majd ezt követően véglegesen törlésre kerülnek.", + "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-továbbító-kiszolgálókon tartózkodnak, amíg meg nem érkeznek a címzetthez, majd ezt követően véglegesen törlésre kerülnek.", "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", "simplex-unique-card-4-p-2": "Használhatja a SimpleX-et saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", "join": "Csatlakozás", @@ -172,7 +172,7 @@ "simplex-network-section-desc": "A Simplex Chat a P2P és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", - "simplex-network-2-desc": "A SimpleX-közvetítő-kiszolgálók NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM csatlakoznak egymáshoz, és NINCS kiszolgálókönyvtár.", + "simplex-network-2-desc": "A SimpleX-továbbító-kiszolgálók NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM csatlakoznak egymáshoz, és NINCS kiszolgálókönyvtár.", "simplex-network-3-header": "SimpleX hálózat", "simplex-network-3-desc": "a kiszolgálók egyirányú üzenet várakoztatásokat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", "comparison-section-header": "Összehasonlítás más protokollokkal", @@ -192,7 +192,7 @@ "comparison-section-list-point-1": "Általában telefonszám alapján, néhány esetben felhasználónév alapján", "comparison-section-list-point-2": "DNS-alapú címek", "comparison-section-list-point-3": "Nyilvános kulcs vagy más globális egyedi azonosító", - "comparison-section-list-point-4a": "A SimpleX-közvetítő-kiszolgálók nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", + "comparison-section-list-point-4a": "A SimpleX-továbbító-kiszolgálók nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", "comparison-section-list-point-4": "Ha az üzemeltetett kiszolgálók veszélybe kerülnek. Hitelesítse a biztonsági kódot a Signal vagy más biztonságos üzenetküldő alkalmazás segítségével a támadások veszélyeinek csökkentésére", "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált - egyetlen hálózatként működnek", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 836f7057ee..c32bc2f123 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -61,7 +61,7 @@ "terminal-cli": "命令行程式", "simplex-explained-tab-1-p-1": "您可以创建联系人和群组,并进行双向对话,就像是任何其他即时通讯软件一样。", "hero-p-1": "其他应用——如Signal、Matrix、Session、Briar、Jami、Cwtch 等——都需要用户 ID。
而SimpleX 不需要用户ID,连随机生成的也不需要。
这从根本上改善了您的隐私。", - "hero-subheader": "首个不使用用户ID的即时通讯软件", + "hero-subheader": "首个
没有用户ID的即时通讯软件", "hero-overlay-2-textlink": "SimpleX 是如何工作的?", "hero-2-header-desc": "右侧的视频向您展示了如何通过一次性二维码、面对面交流或通过视频交换链接来连接到您的朋友。您同样可以通过共享邀请链接来进行连接。", "hero-overlay-1-title": "SimpleX 是如何工作的?", From cb0e362c01463b45efaaefe5885e857f39126a10 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 27 Jan 2025 19:18:50 +0000 Subject: [PATCH 012/289] ui: remove duplicate localization --- .../common/src/commonMain/resources/MR/base/strings.xml | 1 - 1 file changed, 1 deletion(-) 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 8add4ee6fb..82e4753dc9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1830,7 +1830,6 @@ Website Conditions accepted on: %s. Conditions will be accepted on: %s. - Operator Use servers Use %s Current conditions text couldn\'t be loaded, you can review conditions via this link: From 2a58f3656343c3d5d3610974009aa81da88b6445 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 19:22:52 +0000 Subject: [PATCH 013/289] ui: translations (fix) (#5582) * Translated using Weblate (Spanish) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Bulgarian) Currently translated at 97.6% (2244 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Turkish) Currently translated at 90.5% (2080 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Indonesian) Currently translated at 99.3% (2281 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Catalan) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Spanish) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Bulgarian) Currently translated at 97.6% (2244 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Turkish) Currently translated at 90.5% (2080 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Indonesian) Currently translated at 99.3% (2281 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Catalan) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ --------- Co-authored-by: Anonymous Co-authored-by: dtalens --- .../common/src/commonMain/resources/MR/bg/strings.xml | 2 +- .../common/src/commonMain/resources/MR/ca/strings.xml | 4 ++-- .../common/src/commonMain/resources/MR/es/strings.xml | 2 +- .../common/src/commonMain/resources/MR/in/strings.xml | 2 +- .../common/src/commonMain/resources/MR/tr/strings.xml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index ed901927a5..ca95b77827 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -2269,4 +2269,4 @@ Как помага за поверителността Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. Добави членове на екипа - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index fa972b34fa..53d1dbabfa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1508,7 +1508,7 @@ Altre Info servidors Mostrant info per a - Iniciat el %s\nLes dades es mantenen privades al vostre dispositiu.. + Iniciat el %s\nTotes les dades es mantenen privades al vostre dispositiu. Total No esteu connectats a aquests servidors. Per enviar missatges s\'usa l\'encaminament privat. Reconnectar servidors? @@ -2294,4 +2294,4 @@ Desactivar la supressió automàtica de missatges? Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat abans del seleccionat se suprimiran. Els missatges d\'aquest xat no se suprimiran mai. - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 2790e00698..fec6e52948 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -2331,4 +2331,4 @@ Todos los mensajes previos a la selección serán eliminados de este chat. ¡No podrá deshacerse! Un año predefinido (%s) - \ No newline at end of file + 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 8d38995590..840ca777ce 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2285,4 +2285,4 @@ Hanya pengirim dan moderator dapat melihat Hanya Anda dan moderator dapat melihat Spam - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 5d15ef1750..361e126c10 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -2138,4 +2138,4 @@ Listeye ekle %s listesindeki bütün sohbetler kaldırılacak ve liste silinecek İşletmeler - \ No newline at end of file + From c20e94f2fbedd2610d9c53329904615ab11b7f3c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 28 Jan 2025 22:37:37 +0000 Subject: [PATCH 014/289] core: update 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 733afb32be..17ec0e7392 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: b3c8358a43b7a15ba38e0a0385813172d47989c7 + tag: ae41717b9b4323295b5dc7e6157f80acd7c63f28 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 46ee4fc4e3..fad8557436 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b3c8358a43b7a15ba38e0a0385813172d47989c7" = "173f9hji6a5d6wkyys3y6yxv42ycray6b8dmwiswwnjyrnasdq6k"; + "https://github.com/simplex-chat/simplexmq.git"."ae41717b9b4323295b5dc7e6157f80acd7c63f28" = "1vfv2c42sf181v3i8ixavp4r7ssp4cd7gn8xjayajxj35kjh3hm6"; "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 621b291da16f8fb55a2bc30a1e50da157d846c03 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 29 Jan 2025 13:04:48 +0000 Subject: [PATCH 015/289] core: member mentions, types and rfc (#5555) * core: member mentions, types and rfc * update * update rfc * save/get mentions (WIP) * markdown * store received mentions and userMention flag * sent mentions * update message with mentions * db queries * CLI mentions, test passes * use maps for mentions * tests * comment * save mentions on sent messages * postresql schema * refactor * M.empty * include both displayName and localAlias into MentionedMemberInfo * fix saving sent mentions * include mentions in previews * update plans --- .../src/Directory/Events.hs | 9 +- docs/rfcs/2025-01-20-member-mentions.md | 60 +++++ .../typescript/src/command.ts | 9 - simplex-chat.cabal | 1 + src/Simplex/Chat/Bot.hs | 6 +- src/Simplex/Chat/Controller.hs | 40 ++-- src/Simplex/Chat/Library/Commands.hs | 220 ++++++++++-------- src/Simplex/Chat/Library/Internal.hs | 174 ++++++++++---- src/Simplex/Chat/Library/Subscriber.hs | 89 ++++--- src/Simplex/Chat/Markdown.hs | 27 ++- src/Simplex/Chat/Messages.hs | 51 ++-- src/Simplex/Chat/Protocol.hs | 46 +++- src/Simplex/Chat/Store/Groups.hs | 35 ++- src/Simplex/Chat/Store/Messages.hs | 113 ++++++--- .../Postgres/Migrations/M20241220_initial.hs | 14 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../SQLite/Migrations/M20250126_mentions.hs | 37 +++ .../SQLite/Migrations/chat_query_plans.txt | 52 ++++- .../Store/SQLite/Migrations/chat_schema.sql | 22 +- src/Simplex/Chat/Store/Shared.hs | 2 + src/Simplex/Chat/Terminal/Output.hs | 6 +- src/Simplex/Chat/Types.hs | 7 +- src/Simplex/Chat/Util.hs | 8 +- src/Simplex/Chat/View.hs | 19 +- tests/ChatTests/Direct.hs | 6 +- tests/ChatTests/Files.hs | 4 +- tests/ChatTests/Groups.hs | 72 +++++- tests/MarkdownTests.hs | 22 +- tests/ProtocolTests.hs | 14 +- tests/SchemaDump.hs | 4 +- tests/ValidNames.hs | 1 + 31 files changed, 858 insertions(+), 316 deletions(-) create mode 100644 docs/rfcs/2025-01-20-member-mentions.md create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 19c9405358..77f21337f7 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -30,6 +30,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Directory.Store import Simplex.Chat.Controller +import Simplex.Chat.Markdown (displayNameTextP) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) @@ -222,13 +223,7 @@ directoryCmdP = DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) where - gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP - displayNameP = quoted '\'' <|> takeNameTill (== ' ') - takeNameTill p = - A.peekChar' >>= \c -> - if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted c = A.char c *> takeNameTill (== c) <* A.char c - refChar c = c > ' ' && c /= '#' && c /= '@' + gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameTextP viewName :: Text -> Text viewName n = if any (== ' ') (T.unpack n) then "'" <> n <> "'" else n diff --git a/docs/rfcs/2025-01-20-member-mentions.md b/docs/rfcs/2025-01-20-member-mentions.md new file mode 100644 index 0000000000..fdb35c608b --- /dev/null +++ b/docs/rfcs/2025-01-20-member-mentions.md @@ -0,0 +1,60 @@ +# Member mentions + +## Problem + +Mention members in the messages. + +There are several UX objectives that mentions must deliver: +- to notify the user about the messages that mention the user or to reply to user's messages - groups must have this notification mode (already present in the API), +- to allow the user to navigate to unread mentions and replies, +- to highlight mentions and allow all users to open mentioned member profile - this is the least important objective. + +## Solution + +Message text should include the reference to the shared member display name: + +``` +Hello @name +``` + +or + +``` +Hello @'member name' +``` + +This is the same format that people already use and that is currently supported in the API. The name in the message should use the display name at the time of mention, both for backwards compatibility and for better view in compose field, and the message should additionally include the mapping from used display names to shared group member IDs, and the UI would show the current display name (at the time of loading the message to the view). + +For this mapping the message JSON will include the array of mentions, as objects with properties `displayName` and `memberId`. This is to ensure the intent and that the fragments of text are treated as mentions. + +Using an immutable `memberId` would prevent any race conditions and duplicate display names. The receiving client would show a local view name (display name or an alias), and might open a correct member card when mention is tapped. + +As display names are not unique in the group, we should convert them to locally-unique names (per message), by appending _1, _2, as necessary, and the same locally unique names should be used in the mapping to member IDs. These locally unique names must NOT use local user aliases, and must NOT use localDisplayName, as otherwise it may leak information that is known only to the user's client. + +There should be a reasonable limit on the number of mentions per message, e.g. 3. This is to prevent abuse, expensive processing both in the client and in super-peers that would have to forward member profiles if they were not forwarded before. This limit has to be enforced both on sending and receiving ends. + +## UX for sending mentions + +When a member types '@' character in the entry field, the app would show the paginated list of most recently active members, with search. This requires a separate API, and the same API can be used to show a paginated member list - loading the full list is already quite expensive with groups over 1-2k members. + +## UX for navigating to mentions + +The current circles with unread messages should indicate the number of unread mentions (including replies) above and below the view. Tapping the circle should navigate to the next unread mention, and not to the bottom/top of the conversation. Long-pressing the circle should offer the option to navigate to the top/bottom. In the absense of mentions, tapping circles would navigate to top/bottom. + +## Message UI + +Item text will include markdown elements for mentioned members. This will be used when rendering to show member display names or local aliases. + +Chat items data will include the list of members used in the chat item, including view names and member IDs. + +## Forwarding and saving to local items + +When forwarding to another conversation or saving to notes a message with mentions the app should use: +- current display names instead of display names used in the message. +- remove mentions mapping from the message data. + +## Schema + +Two new columns for chat_items table: +- user_mention - 0 or 1 to indicate whether a message is a reply to user's message or mentions user. +- member_mentions - the object mapping display names to member IDs, either as JSON, or in a more economical comma-separated list of "ID:name" strings (or "ID:'member name'). This field can be processed to load mention information, with the limit of 3 mentions per message it's sufficient. diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index e512e06672..135dc41ba7 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -29,7 +29,6 @@ export type ChatCommand = | APIRejectContact | APIUpdateProfile | APISetContactAlias - | APIParseMarkdown | NewGroup | APIAddMember | APIJoinGroup @@ -128,7 +127,6 @@ type ChatCommandTag = | "apiRejectContact" | "apiUpdateProfile" | "apiSetContactAlias" - | "apiParseMarkdown" | "newGroup" | "apiAddMember" | "apiJoinGroup" @@ -355,11 +353,6 @@ export interface APISetContactAlias extends IChatCommand { localAlias: string } -export interface APIParseMarkdown extends IChatCommand { - type: "apiParseMarkdown" - text: string -} - export interface NewGroup extends IChatCommand { type: "newGroup" groupProfile: GroupProfile @@ -732,8 +725,6 @@ export function cmdString(cmd: ChatCommand): string { return `/_profile ${cmd.userId} ${JSON.stringify(cmd.profile)}` case "apiSetContactAlias": return `/_set alias @${cmd.contactId} ${cmd.localAlias.trim()}` - case "apiParseMarkdown": - return `/_parse ${cmd.text}` case "newGroup": return `/_group ${JSON.stringify(cmd.groupProfile)}` case "apiAddMember": diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 59385080d4..1df50de1a6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -221,6 +221,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history + Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 2f7e2f2abd..e1f5ce1ef9 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -11,7 +11,7 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad import qualified Data.ByteString.Char8 as B -import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.Map.Strict as M import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Controller @@ -69,8 +69,8 @@ sendComposedMessage cc = sendComposedMessage' cc . contactId' sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO () sendComposedMessage' cc ctId quotedItemId msgContent = do - let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent} - sendChatCmd cc (APISendMessages (ChatRef CTDirect ctId) False Nothing (cm :| [])) >>= \case + let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent, mentions = M.empty} + sendChatCmd cc (APISendMessages (ChatRef CTDirect ctId) False Nothing [cm]) >>= \case CRNewChatItems {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId r -> putStrLn $ "unexpected send message response: " <> show r diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4ff29d6d6a..68c1bd2e92 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -39,6 +39,7 @@ import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) import Data.String import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) @@ -313,7 +314,7 @@ data ChatCommand | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} | APIReportMessage {groupId :: GroupId, chatItemId :: ChatItemId, reportReason :: ReportReason, reportText :: Text} | ReportMessage {groupName :: GroupName, contactName_ :: Maybe ContactName, reportReason :: ReportReason, reportedMessage :: Text} - | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} + | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, updatedMessage :: UpdatedMessage} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} @@ -346,7 +347,6 @@ data ChatCommand | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) - | APIParseMarkdown Text | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString @@ -1085,22 +1085,16 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary data ComposedMessage = ComposedMessage { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, - msgContent :: MsgContent + msgContent :: MsgContent, + mentions :: Map MemberName GroupMemberId } deriving (Show) --- This instance is needed for backward compatibility, can be removed in v6.0 -instance FromJSON ComposedMessage where - parseJSON (J.Object v) = do - fileSource <- - (v .:? "fileSource") >>= \case - Nothing -> CF.plain <$$> (v .:? "filePath") - f -> pure f - quotedItemId <- v .:? "quotedItemId" - msgContent <- v .: "msgContent" - pure ComposedMessage {fileSource, quotedItemId, msgContent} - parseJSON invalid = - JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) +data UpdatedMessage = UpdatedMessage + { msgContent :: MsgContent, + mentions :: Map MemberName GroupMemberId + } + deriving (Show) data ChatTagData = ChatTagData { emoji :: Maybe Text, @@ -1273,7 +1267,6 @@ data ChatErrorType | CEFileNotApproved {fileId :: FileTransferId, unknownServers :: [XFTPServer]} | CEFallbackToSMPProhibited {fileId :: FileTransferId} | CEInlineFileProhibited {fileId :: FileTransferId} - | CEInvalidQuote | CEInvalidForward | CEInvalidChatItemUpdate | CEInvalidChatItemDelete @@ -1635,4 +1628,19 @@ $(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) $(JQ.deriveToJSON defaultJSON ''ComposedMessage) +instance FromJSON ComposedMessage where + parseJSON (J.Object v) = do + fileSource <- + (v .:? "fileSource") >>= \case + Nothing -> CF.plain <$$> (v .:? "filePath") + f -> pure f + quotedItemId <- v .:? "quotedItemId" + msgContent <- v .: "msgContent" + mentions <- fromMaybe M.empty <$> v .:? "mentions" + pure ComposedMessage {fileSource, quotedItemId, msgContent, mentions} + parseJSON invalid = + JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) + +$(JQ.deriveJSON defaultJSON ''UpdatedMessage) + $(JQ.deriveToJSON defaultJSON ''ChatTagData) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 587f948941..7c2e6134c7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -37,7 +37,7 @@ import Data.Either (fromRight, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith4) +import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith5) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -80,7 +80,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (liftIOEither) +import Simplex.Chat.Util (liftIOEither, neUnzip3) import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent @@ -537,12 +537,13 @@ processChatCommand' vr = \case Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) _ -> pure Nothing APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of - CTDirect -> + CTDirect -> do + mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ - sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms) + sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) CTGroup -> withGroupLock "sendMessage" chatId $ - sendGroupContentMessages user chatId live itemTTL (L.map (,Nothing) cms) + sendGroupContentMessages user chatId live itemTTL (L.map composedMessageReq cms) CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" @@ -567,8 +568,8 @@ processChatCommand' vr = \case withFastStore' $ \db -> reorderChatTags db user $ L.toList tagIds ok user APICreateChatItems folderId cms -> withUser $ \user -> do - mapM_ assertAllowedContent' cms - createNoteFolderContentItems user folderId (L.map (,Nothing) cms) + forM_ cms $ \cm -> assertAllowedContent' cm >> assertNoMentions cm + createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do (gInfo, ms) <- @@ -577,9 +578,9 @@ processChatCommand' vr = \case (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) let ms' = filter compatibleModerator ms mc = MCReport reportText reportReason - cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc} + cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" - sendGroupContentMessages_ user gInfo ms' False Nothing [(cm, Nothing)] + sendGroupContentMessages_ user gInfo ms' False Nothing [composedMessageReq cm] where compatibleModerator GroupMember {activeConn, memberChatVRange} = maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion @@ -587,8 +588,9 @@ processChatCommand' vr = \case 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 mc -> withUser $ \user -> assertAllowedContent mc >> case cType of + APIUpdateChatItem (ChatRef cType chatId) 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 assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withFastStore $ \db -> getDirectCIWithReactions db user ct itemId @@ -599,7 +601,7 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -614,7 +616,8 @@ processChatCommand' vr = \case CTGroup -> withGroupLock "updateChatItem" chatId $ do Group gInfo@GroupInfo {groupId, membership} ms <- withFastStore $ \db -> getGroup db vr user chatId assertUserGroupRole gInfo GRAuthor - if prohibitedSimplexLinks gInfo membership mc + let (_, ft_) = msgContentTexts mc + if prohibitedSimplexLinks gInfo membership ft_ then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId @@ -625,19 +628,22 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + (mentionedMembers, mentions') <- withFastStore $ \db -> getMentionedMembers db user gInfo ft_ mentions + SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) let edited = itemLive /= Just True - updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId + ci' <- updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId + updateGroupCIMentions db gInfo ci' mentionedMembers 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) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTLocal -> do + unless (null mentions) $ throwChatError $ CECommandError "mentions are not supported in this chat" (nf@NoteFolder {noteFolderId}, cci) <- withFastStore $ \db -> (,) <$> getNoteFolder db user chatId <*> getLocalChatItem db user chatId itemId case cci of CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent oldMC} @@ -699,7 +705,7 @@ processChatCommand' vr = \case itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do - (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds + (gInfo, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable gInfo items assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate @@ -849,31 +855,33 @@ processChatCommand' vr = \case CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where - prepareForward :: User -> CM [ComposeMessageReq] + prepareForward :: User -> CM [ComposedMessageReq] prepareForward user = case fromCType of CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do (ct, items) <- getCommandDirectChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ct ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq ct (CChatItem md ci) (mc', file) = let itemId = chatItemId' ci ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) (toMsgDirection md) (Just fromChatId) (Just itemId)) - in (ComposedMessage file Nothing mc', ciff) + in (composedMessage file mc', ciff, msgContentTexts mc') where forwardName :: Contact -> ContactName forwardName Contact {profile = LocalProfile {displayName, localAlias}} | localAlias /= "" = localAlias | otherwise = displayName CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do + -- TODO [mentions] forward to the same group should retain mentions, and shouldn't read them again + -- update names? (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do let itemId = chatItemId' ci ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) - in (ComposedMessage file Nothing mc', ciff) + in (composedMessage file mc', ciff, msgContentTexts mc') where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName @@ -881,10 +889,10 @@ processChatCommand' vr = \case (_, items) <- getCommandLocalChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq (CChatItem _ ci) (mc', file) = let ciff = forwardCIFF ci Nothing - in (ComposedMessage file Nothing mc', ciff) + in (composedMessage file mc', ciff, msgContentTexts mc') CTContactRequest -> throwChatError $ CECommandError "not supported" CTContactConnection -> throwChatError $ CECommandError "not supported" where @@ -1288,7 +1296,6 @@ processChatCommand' vr = \case liftIO $ setGroupUIThemes db user g uiThemes ok user _ -> pure $ chatCmdError (Just user) "not supported" - APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken APIRegisterToken token mode -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) @@ -1844,7 +1851,7 @@ processChatCommand' vr = \case withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef False Nothing [composedMessage Nothing mc] Left _ -> withFastStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case Right [(gInfo, member)] -> do @@ -1856,13 +1863,15 @@ processChatCommand' vr = \case _ -> throwChatError $ CEContactNotFound name Nothing CTGroup -> do - gId <- withFastStore $ \db -> getGroupIdByName db user name + (gId, mentions) <- withFastStore $ \db -> do + gId <- getGroupIdByName db user name + (gId,) <$> liftIO (getMessageMentions db user gId msg) let chatRef = ChatRef CTGroup gId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef False Nothing [ComposedMessage Nothing Nothing mc mentions] CTLocal | name == "" -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand $ APICreateChatItems folderId (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APICreateChatItems folderId [composedMessage Nothing mc] | otherwise -> throwChatError $ CECommandError "not supported" _ -> throwChatError $ CECommandError "not supported" SendMemberContactMessage gName mName msg -> withUser $ \user -> do @@ -1881,11 +1890,11 @@ processChatCommand' vr = \case cr -> pure cr Just ctId -> do let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef False Nothing [composedMessage Nothing mc] SendLiveMessage chatName msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg let mc = MCText msg - processChatCommand $ APISendMessages chatRef True Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef True Nothing [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withFastStore' $ \db -> getUserContacts db vr user withChatLock "sendMessageBroadcast" . procCmd $ do @@ -1926,7 +1935,7 @@ processChatCommand' vr = \case contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg let mc = MCText msg - processChatCommand $ APISendMessages (ChatRef CTDirect contactId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| []) + processChatCommand $ APISendMessages (ChatRef CTDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg @@ -1936,14 +1945,14 @@ processChatCommand' vr = \case deletedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId (Just mName) deletedMsg processChatCommand $ APIDeleteMemberChatItem gId (deletedItemId :| []) EditMessage chatName editedMsg msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg editedItemId <- getSentChatItemIdByText user chatRef editedMsg let mc = MCText msg - processChatCommand $ APIUpdateChatItem chatRef editedItemId False mc + processChatCommand $ APIUpdateChatItem chatRef editedItemId False $ UpdatedMessage mc mentions UpdateLiveMessage chatName chatItemId live msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg let mc = MCText msg - processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc + processChatCommand $ APIUpdateChatItem chatRef chatItemId live $ UpdatedMessage mc mentions ReactToMessage add reaction chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg @@ -2213,10 +2222,13 @@ processChatCommand' vr = \case groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIGetGroupLink groupId SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do - groupId <- withFastStore $ \db -> getGroupIdByName db user gName - quotedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId cName quotedMsg + (groupId, quotedItemId, mentions) <- + withFastStore $ \db -> do + gId <- getGroupIdByName db user gName + qiId <- getGroupChatItemIdByText db user gId cName quotedMsg + (gId, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand $ APISendMessages (ChatRef CTGroup groupId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| []) + processChatCommand $ APISendMessages (ChatRef CTGroup groupId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand $ APIClearChat (ChatRef CTLocal folderId) @@ -2256,8 +2268,8 @@ 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) Nothing (MCFile "") :| []) - _ -> processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCFile "") :| []) + ChatRef CTLocal folderId -> processChatCommand $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] + _ -> processChatCommand $ APISendMessages chatRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName filePath <- lift $ toFSFilePath fPath @@ -2265,7 +2277,7 @@ processChatCommand' vr = \case fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) :| []) + processChatCommand $ APISendMessages chatRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" @@ -2486,6 +2498,12 @@ processChatCommand' vr = \case | name == "" -> withFastStore (`getUserNoteFolderId` user) | otherwise -> throwChatError $ CECommandError "not supported" _ -> throwChatError $ CECommandError "not supported" + getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) + getChatRefAndMentions user cName msg = do + chatRef@(ChatRef cType chatId) <- getChatRef user cName + (chatRef,) <$> case cType of + CTGroup -> withFastStore' $ \db -> getMessageMentions db user chatId msg + _ -> pure [] #if !defined(dbPostgres) checkChatStopped :: CM ChatResponse -> CM ChatResponse checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) @@ -2935,12 +2953,13 @@ processChatCommand' vr = \case cReqHashes :: (ConnReqUriHash, ConnReqUriHash) cReqHashes = bimap hash hash cReqSchemas hash = ConnReqUriHash . C.sha256Hash . strEncode + updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId case (cInfo, content) of (DirectChat ct@Contact {contactId}, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) | status == CIGISPending -> do - let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = newStatus} memRole + let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole timed_ <- contactCITimed ct updateDirectChatItemView user ct itemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ @@ -2951,8 +2970,12 @@ processChatCommand' vr = \case MCReport {} -> throwChatError $ CECommandError "sending reports via this API is not supported" _ -> pure () assertAllowedContent' :: ComposedMessage -> CM () - assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent - sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent + assertNoMentions :: ComposedMessage -> CM () + assertNoMentions ComposedMessage {mentions} + | null mentions = pure () + | otherwise = throwChatError $ CECommandError "mentions are not supported in this chat" + sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs ct@Contact {contactUsed} <- withFastStore $ \db -> getContact db vr user contactId @@ -2963,15 +2986,15 @@ processChatCommand' vr = \case where assertVoiceAllowed :: Contact -> CM () assertVoiceAllowed ct = - when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _) -> isVoice msgContent) cmrs) $ + when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _, _) -> isVoice msgContent) cmrs) $ throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) processComposedMessages :: Contact -> CM ChatResponse processComposedMessages ct = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers timed_ <- sndContactCITimed live ct itemTTL - (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ + (msgContainers, quotedItems_, mms) <- neUnzip3 <$> prepareMsgs (L.zip cmrs fInvs_) timed_ msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData msgs_ cmrs ciFiles_ quotedItems_ + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (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) itemsData timed_ live processSendErrs user r @@ -2982,39 +3005,40 @@ processChatCommand' vr = \case where setupSndFileTransfers :: CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) - prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect))) - prepareMsgs cmsFileInvs timed_ = - forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded), fInv_) -> + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect), (Map MemberName MentionedMember, Map MemberName MemberMention))) + prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _), fInv_) -> do + let mms = (M.empty, M.empty) case (quotedItemId, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withFastStore $ \db -> getDirectChatItem db user contactId qiId + getDirectChatItem db user contactId qiId (origQmc, qd, sent) <- quoteData qci 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 fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) - (Just _, Just _) -> throwChatError CEInvalidQuote + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem, mms) + (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> CM (MsgContent, CIQDirection 'CTDirect, Bool) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote + quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwError SEInvalidQuote quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) - quoteData _ = throwChatError CEInvalidQuote - sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + quoteData _ = throwError SEInvalidQuote + sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages user groupId live itemTTL cmrs = do assertMultiSendable live cmrs Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId sendGroupContentMessages_ user gInfo ms live itemTTL cmrs - sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do assertUserGroupRole gInfo GRAuthor assertGroupContentAllowed @@ -3026,18 +3050,18 @@ processChatCommand' vr = \case Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f)) Nothing -> pure () where - findProhibited :: [ComposeMessageReq] -> Maybe GroupFeature + findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft)) acc -> prohibitedGroupContent gInfo membership mc ft fileSource <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL - (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ + (msgContainers, quotedItems_, mms) <- neUnzip3 <$> prepareMsgs (L.zip cmrs fInvs_) timed_ (msgs_, gsr) <- sendGroupMessages user gInfo ms $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData (L.toList msgs_) cmrs ciFiles_ quotedItems_ + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr @@ -3050,16 +3074,16 @@ processChatCommand' vr = \case where setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers n = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) - prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup))) - prepareMsgs cmsFileInvs timed_ = - forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded), fInv_) -> - prepareGroupMsg user gInfo mc quotedItemId itemForwarded fInv_ timed_ live + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention))) + prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc, mentions}, itemForwarded, (_, ft_)), fInv_) -> + prepareGroupMsg db user gInfo mc ft_ mentions quotedItemId itemForwarded fInv_ timed_ live createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -3095,7 +3119,7 @@ processChatCommand' vr = \case Right _ -> GSSInactive Left e -> GSSError $ SndErrOther $ tshow e forM_ ciId_ $ \ciId -> createGroupSndStatus db ciId mId status - assertMultiSendable :: Bool -> NonEmpty ComposeMessageReq -> CM () + assertMultiSendable :: Bool -> NonEmpty ComposedMessageReq -> CM () assertMultiSendable live cmrs | length cmrs == 1 = pure () | otherwise = @@ -3103,7 +3127,7 @@ processChatCommand' vr = \case -- This is to support case of sending multiple attachments while also quoting another message. -- UI doesn't allow composing with multiple quotes, so api prohibits it as well, and doesn't bother -- batching retrieval of quoted messages (prepareMsgs). - when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _) -> isJust quotedItemId) cmrs) > 1) $ + when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _, _) -> isJust quotedItemId) cmrs) > 1) $ throwChatError (CECommandError "invalid multi send: live and more than one quote not supported") xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd) xftpSndFileTransfer user file fileSize n contactOrGroup = do @@ -3121,19 +3145,16 @@ processChatCommand' vr = \case saveMemberFD _ = pure () pure (fInv, ciFile) prepareSndItemsData :: + [ComposedMessageReq] -> + [(Map MemberName MentionedMember, Map MemberName MemberMention)] -> + [Maybe (CIFile 'MDSnd)] -> + [Maybe (CIQuote c)] -> [Either ChatError SndMessage] -> - NonEmpty ComposeMessageReq -> - NonEmpty (Maybe (CIFile 'MDSnd)) -> - NonEmpty (Maybe (CIQuote c)) -> [Either ChatError (NewSndChatItemData c)] - prepareSndItemsData msgs_ cmrs' ciFiles_ quotedItems_ = - [ ( case msg_ of - Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) f q itemForwarded - Left e -> Left e -- step over original error - ) - | (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <- - zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_) - ] + prepareSndItemsData = + zipWith5 $ \(ComposedMessage {msgContent}, itemForwarded, ts) mm f q -> \case + Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) ts mm f q itemForwarded + Left e -> Left e -- step over original error processSendErrs :: User -> ([ChatError], [ChatItem c d]) -> CM () processSendErrs user = \case -- no errors @@ -3178,7 +3199,7 @@ processChatCommand' vr = \case forwardMsgContent ChatItem {content = CISndMsgContent fmc} = pure $ Just fmc forwardMsgContent ChatItem {content = CIRcvMsgContent fmc} = pure $ Just fmc forwardMsgContent _ = throwChatError CEInvalidForward - createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposeMessageReq -> CM ChatResponse + createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposedMessageReq -> CM ChatResponse createNoteFolderContentItems user folderId cmrs = do assertNoQuotes nf <- withFastStore $ \db -> getNoteFolder db user folderId @@ -3190,11 +3211,11 @@ processChatCommand' vr = \case where assertNoQuotes :: CM () assertNoQuotes = - when (any (\(ComposedMessage {quotedItemId}, _) -> isJust quotedItemId) cmrs) $ + when (any (\(ComposedMessage {quotedItemId}, _, _) -> isJust quotedItemId) cmrs) $ throwChatError (CECommandError "createNoteFolderContentItems: quotes not supported") createLocalFiles :: NoteFolder -> UTCTime -> CM (NonEmpty (Maybe (CIFile 'MDSnd))) createLocalFiles nf createdAt = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do fsFilePath <- lift $ toFSFilePath filePath fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs @@ -3203,13 +3224,12 @@ processChatCommand' vr = \case fileId <- createLocalFile CIFSSndStored db user nf createdAt cf fileSize chunkSize pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal} prepareLocalItemsData :: - NonEmpty ComposeMessageReq -> + NonEmpty ComposedMessageReq -> NonEmpty (Maybe (CIFile 'MDSnd)) -> - [(CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom)] - prepareLocalItemsData cmrs' ciFiles_ = - [ (CISndMsgContent mc, f, itemForwarded) - | ((ComposedMessage {msgContent = mc}, itemForwarded), f) <- zip (L.toList cmrs') (L.toList ciFiles_) - ] + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + prepareLocalItemsData = + L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts) f -> + (CISndMsgContent mc, f, itemForwarded, ts) getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId) @@ -3231,7 +3251,13 @@ updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = disableSrv srv@UserServer {preset} = AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} -type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + +composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage +composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} + +composedMessageReq :: ComposedMessage -> ComposedMessageReq +composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgContentTexts mc) data ChangedProfileContact = ChangedProfileContact { ct :: Contact, @@ -3692,7 +3718,7 @@ chatCommandP = "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), "/report #" *> (ReportMessage <$> displayNameP <*> optional (" @" *> displayNameP) <*> _strP <* A.space <*> msgTextP), - "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), + "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <*> (" json" *> jsonP <|> " text " *> updatedMessagesTextP)), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), @@ -3725,7 +3751,6 @@ chatCommandP = "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), "/_set theme " *> (APISetChatUIThemes <$> chatRefP <*> optional (A.space *> jsonP)), - "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString), "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), @@ -4003,7 +4028,8 @@ chatCommandP = c -> c composedMessagesTextP = do text <- mcTextP - pure $ (ComposedMessage Nothing Nothing text) :| [] + pure [composedMessage Nothing text] + updatedMessagesTextP = (`UpdatedMessage` []) <$> mcTextP liveMessageP = " live=" *> onOffP <|> pure False sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing receiptSettings = do @@ -4123,7 +4149,7 @@ displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" quoted c = A.char c *> takeNameTill (== c) <* A.char c - refChar c = c > ' ' && c /= '#' && c /= '@' + refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' mkValidName :: String -> String mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1bcbf9f20d..1c641d165c 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -29,6 +29,7 @@ import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Containers.ListUtils (nubOrd) import Data.Either (partitionEithers, rights) import Data.Fixed (div') import Data.Foldable (foldr') @@ -102,6 +103,12 @@ import UnliftIO.STM maxMsgReactions :: Int maxMsgReactions = 3 +maxRcvMentions :: Int +maxRcvMentions = 5 + +maxSndMentions :: Int +maxSndMentions = 3 + withChatLock :: String -> CM a -> CM a withChatLock name action = asks chatLock >>= \l -> withLock l name action @@ -181,25 +188,76 @@ toggleNtf user m ntfOn = forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) -prepareGroupMsg :: User -> GroupInfo -> MsgContent -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> CM (MsgContainer, Maybe (CIQuote 'CTGroup)) -prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) +prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> MsgContent -> Maybe MarkdownList -> Map MemberName GroupMemberId -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention)) +prepareGroupMsg db user g@GroupInfo {groupId, membership} mc ft_ memberMentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of + (Nothing, Nothing) -> do + mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions + pure (MCSimple (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) + (Nothing, Just _) -> + pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, (M.empty, M.empty)) (Just quotedItemId, Nothing) -> do + mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withStore $ \db -> getGroupChatItem db user groupId quotedItemId + getGroupChatItem db user groupId quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) - (Just _, Just _) -> throwChatError CEInvalidQuote + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem, mms) + (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> GroupMember -> CM (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote + quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwError SEInvalidQuote quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) - quoteData _ _ = throwChatError CEInvalidQuote + quoteData _ _ = throwError SEInvalidQuote + +getMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName GroupMemberId -> ExceptT StoreError IO (Map MemberName MentionedMember, Map MemberName MemberMention) +getMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null mentions) -> do + let msgMentions = S.fromList $ mentionedNames ft + n = M.size mentions + -- prevent "invisible" and repeated-with-different-name mentions (when the same member is mentioned via another name) + unless (n <= maxSndMentions && all (`S.member` msgMentions) (M.keys mentions) && S.size (S.fromList $ M.elems mentions) == n) $ + throwError SEInvalidMention + mentionedMembers <- mapM (getMentionedGroupMember db user groupId) mentions + let mentions' = M.map (\MentionedMember {memberId} -> MemberMention {memberId}) mentionedMembers + pure (mentionedMembers, mentions') + _ -> pure (M.empty, M.empty) + +getRcvMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MemberMention -> IO (Map MemberName MentionedMember) +getRcvMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null mentions) -> + let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft + in mapM (getMentionedMemberByMemberId db user groupId) mentions' + _ -> pure M.empty + +-- prevent "invisible" and repeated-with-different-name mentions +uniqueMsgMentions :: Int -> Map MemberName MemberMention -> [ContactName] -> Map MemberName MemberMention +uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 + where + go acc _ _ [] = acc + go acc seen n (name : rest) + | n >= maxMentions = acc + | otherwise = case M.lookup name mentions of + Just mm@MemberMention {memberId} | S.notMember memberId seen -> + go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest + _ -> go acc seen n rest + +getMessageMentions :: DB.Connection -> User -> GroupId -> Text -> IO (Map MemberName GroupMemberId) +getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of + Just ft -> M.fromList . catMaybes <$> mapM get (nubOrd $ mentionedNames ft) + Nothing -> pure M.empty + where + get name = + fmap (name,) . eitherToMaybe + <$> runExceptT (getGroupMemberIdByName db user gId name) + +msgContentTexts :: MsgContent -> (Text, Maybe MarkdownList) +msgContentTexts mc = let t = msgContentText mc in (t, parseMaybeMarkdownList t) + +ciContentTexts :: CIContent d -> (Text, Maybe MarkdownList) +ciContentTexts content = let t = ciContentToText content in (t, parseMaybeMarkdownList t) quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent mc qmc ciFile_ @@ -228,17 +286,17 @@ 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 f -> Maybe GroupFeature -prohibitedGroupContent gInfo m mc file_ +prohibitedGroupContent :: GroupInfo -> GroupMember -> MsgContent -> Maybe MarkdownList -> Maybe f -> Maybe GroupFeature +prohibitedGroupContent gInfo m mc ft file_ | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles - | prohibitedSimplexLinks gInfo m mc = Just GFSimplexLinks + | prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks | otherwise = Nothing -prohibitedSimplexLinks :: GroupInfo -> GroupMember -> MsgContent -> Bool -prohibitedSimplexLinks gInfo m mc = +prohibitedSimplexLinks :: GroupInfo -> GroupMember -> Maybe MarkdownList -> Bool +prohibitedSimplexLinks gInfo m ft = not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo) - && maybe False (any ftIsSimplexLink) (parseMaybeMarkdownList $ msgContentText mc) + && maybe False (any ftIsSimplexLink) ft where ftIsSimplexLink :: FormattedText -> Bool ftIsSimplexLink FormattedText {format} = maybe False isSimplexLink format @@ -863,9 +921,6 @@ startUpdatedTimedItemThread user chatRef ci ci' = metaBrokerTs :: MsgMeta -> UTCTime metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs -sameMemberId :: MemberId -> GroupMember -> Bool -sameMemberId memId GroupMember {memberId} = memId == memberId - createContactPQSndItem :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of @@ -1549,15 +1604,20 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False +-- TODO [mentions] optimize by avoiding unnecesary parsing of control messages 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 = - saveSndChatItems user cd [Right NewSndChatItemData {msg, content, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case +saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = do + let itemTexts = ciContentTexts content + itemMentions = (M.empty, M.empty) + saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case [Right ci] -> pure ci _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item" data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, + itemTexts :: (Text, Maybe MarkdownList), + itemMentions :: (Map MemberName MentionedMember, Map MemberName MemberMention), ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), itemForwarded :: Maybe CIForwardedFrom @@ -1579,31 +1639,57 @@ saveSndChatItems user cd itemsData itemTimed live = do lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) - createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, ciFile, quotedItem, itemForwarded} = do + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ Right $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live createdAt Nothing createdAt + let ci = mkChatItem_ cd ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt + mentions = fst itemMentions + Right <$> case cd of + CDGroupSnd g | not (null mentions) -> createGroupCIMentions db g ci mentions + _ -> pure ci -saveRcvChatItem :: (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) +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 user cd msg@RcvMessage {sharedMsgId_} brokerTs content = - saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False + saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDRcv) -saveRcvChatItem' user cd msg@RcvMessage {forwardedByMember} sharedMsgId_ brokerTs content ciFile itemTimed live = do +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 MemberMention -> CM (ChatItem c 'MDRcv) +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem, itemForwarded) <- withStore' $ \db -> do + withStore' $ \db -> do when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt - r@(ciId, _, _) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt + (mentions' :: Map MemberName MentionedMember, userMention) <- case cd of + CDGroupRcv g@GroupInfo {membership} _ -> do + mentions' <- getRcvMentionedMembers db user g ft_ mentions + let userReply = case cmToQuotedMsg chatMsgEvent of + Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership + _ -> False + userMention' = userReply || any (\MentionedMember {memberId} -> sameMemberId memberId membership) mentions' + in pure (mentions', userMention') + CDDirectRcv _ -> pure (M.empty, False) + (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure r - pure $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live brokerTs forwardedByMember 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' + _ -> pure ci -mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live itemTs forwardedByMember currentTs = - let itemText = ciContentToText content - itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs - in ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} +-- 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 +mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = + let ts = ciContentTexts content + in mkChatItem_ cd ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs + +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d +mkChatItem_ cd ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = + let itemStatus = ciCreateStatus content + meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention currentTs itemTs forwardedByMember currentTs currentTs + in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do @@ -1815,26 +1901,26 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do 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 itemTs Nothing 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 createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> - [(CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom)] -> + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do withStore' $ \db -> updateChatTs db user cd createdAt - (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) itemsData) + (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) unless (null errs) $ toView $ CRChatErrors (Just user) errs pure items where - createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom) -> IO (ChatItem 'CTLocal 'MDSnd) - createItem db (content, ciFile, itemForwarded) = do - ciId <- createNewChatItem_ db user cd Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False createdAt Nothing createdAt + 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 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 ciFile Nothing Nothing itemForwarded Nothing False createdAt Nothing createdAt + pure $ mkChatItem_ cd ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False createdAt Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index abef8b579b..3293e6b208 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -31,6 +31,7 @@ import Data.Int (Int64) import Data.List (foldl', partition) 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.Text (Text) @@ -500,7 +501,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 -> 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 @@ -900,7 +901,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ - (msgContainer, _) <- prepareGroupMsg user gInfo mc quotedItemId_ Nothing fInv_ itemTimed False + -- TODO [mentions] history? + let (_t, ft_) = msgContentTexts mc + (msgContainer, _, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc ft_ M.empty quotedItemId_ Nothing fInv_ itemTimed False let senderVRange = memberChatVRange' sender xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of @@ -966,7 +969,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg brokerTs ttl live + 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 -- TODO discontinue XFile @@ -1539,7 +1542,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct - 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" -> @@ -1548,18 +1551,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- _ -> pure () if isVoice content && not (featureAllowed SCFVoice forContact ct) then do - void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False + 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 - newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content, msgContentTexts content) (snd <$> file_) timed_ live autoAcceptFile file_ where brokerTs = metaBrokerTs msgMeta - newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live + newChatItem content ciFile_ timed_ live = do + 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}] @@ -1625,7 +1628,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs content Nothing timed_ live + ts = ciContentTexts content + 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 @@ -1728,7 +1732,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 fInv_ of + | otherwise = case prohibitedGroupContent gInfo m content ft_ fInv_ of Just f -> rejected f Nothing -> withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case @@ -1737,13 +1741,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ Nothing -> createContentItem where - rejected f = void $ newChatItem (CIRcvGroupFeatureRejected f) Nothing Nothing False + rejected f = void $ 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 fInv_ itemTTL live_ = mcExtMsgContent mc + ExtMsgContent content mentions fInv_ itemTTL live_ = mcExtMsgContent mc + ts@(_, ft_) = msgContentTexts content createBlockedByAdmin | groupFeatureAllowed SGFFullDelete gInfo = do -- ignores member role when blocked by admin - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvBlocked Nothing timed' False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs groupMsgToView gInfo ci' | otherwise = do @@ -1755,7 +1760,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt groupMsgToView gInfo ci' | otherwise = do @@ -1763,22 +1768,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci <- createNonLive file_ toView =<< markGroupCIsDeleted user gInfo [CChatItem SMDRcv ci] False (Just moderator) moderatedAt createNonLive file_ = - saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content) (snd <$> file_) timed' False + saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions createContentItem = do file_ <- processFileInv - newChatItem (CIRcvMsgContent content) (snd <$> file_) timed' live' + 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 - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live + let mentions' = if showMessages (memberSettings m) then mentions else [] + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs 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} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} brokerTs ttl_ live_ - | prohibitedSimplexLinks gInfo m mc = + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MemberMention -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msg@RcvMessage {msgId} brokerTs ttl_ live_ + | prohibitedSimplexLinks gInfo m ft_ = messageWarning $ "x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks | otherwise = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -1786,7 +1792,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) brokerTs content Nothing timed_ live + mentions' = if showMessages (memberSettings m) then mentions else [] + ci <- saveRcvChatItem' user (CDGroupRcv gInfo 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 @@ -1794,6 +1801,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') where content = CIRcvMsgContent mc + ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId @@ -1809,7 +1817,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) reactions <- getGroupCIReactions db gInfo memberId sharedMsgId let edited = itemLive /= Just True - updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + mentionedMembers <- getRcvMentionedMembers db user gInfo ft_ mentions + ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + updateGroupCIMentions db gInfo ci' mentionedMembers 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) @@ -1870,7 +1880,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False + content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" + 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 @@ -1883,7 +1894,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False + content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci groupMsgToView gInfo ci' @@ -2063,7 +2075,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItem user (CDDirectRcv ct) msg brokerTs content + ci <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} @@ -2091,7 +2103,7 @@ 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 <- saveRcvChatItem user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) + ci <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci] toView $ CRContactDeletedByContact user ct'' else do @@ -2300,9 +2312,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else featureRejected CFCalls where brokerTs = metaBrokerTs msgMeta - saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) + saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvChatFeatureRejected f) Nothing Nothing False + let content = ciContentNoParse $ CIRcvChatFeatureRejected f + 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 @@ -2480,7 +2493,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event) groupMsgToView gInfo ci toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember @@ -2567,7 +2580,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 - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + 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} @@ -2594,7 +2607,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = bm' <- setMemberBlocked bmId toggleNtf user bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs ciContent + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs ciContent groupMsgToView gInfo ci toView CRMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do @@ -2679,7 +2692,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo ci xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () @@ -2687,7 +2700,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 - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView gInfo ci toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} @@ -2700,7 +2713,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure members -- member records are not deleted to keep history deleteMembersConnections user ms - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView gInfo ci toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m @@ -2713,7 +2726,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m unless (sameGroupProfileInfo p p') $ do - ci <- saveRcvChatItem user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') + ci <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') groupMsgToView g' ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' @@ -2772,7 +2785,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CRNewMemberContactReceivedInv user mCt' g m' forM_ mContent_ $ \mc -> do - ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc) + ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci] securityCodeChanged :: Contact -> CM () @@ -2799,7 +2812,7 @@ 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 ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent rcvMsg msgTs ttl live + XMsgUpdate sharedMsgId mContent mentions ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent mentions 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/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 8e82bfe727..9b6359b4ae 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -22,7 +22,7 @@ import Data.Functor (($>)) import Data.List (foldl', intercalate) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe, isNothing, mapMaybe) import Data.Semigroup (sconcat) import Data.String import Data.Text (Text) @@ -50,18 +50,28 @@ data Format | Colored {color :: FormatColor} | Uri | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} + | Mention {memberName :: Text} | Email | Phone deriving (Eq, Show) +mentionedNames :: MarkdownList -> [Text] +mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) + where + mentionedName = \case + Mention name -> Just name + _ -> Nothing + data SimplexLinkType = XLContact | XLInvitation | XLGroup deriving (Eq, Show) colored :: Color -> Format colored = Colored . FormatColor +{-# INLINE colored #-} markdown :: Format -> Text -> Markdown markdown = Markdown . Just +{-# INLINE markdown #-} instance Semigroup Markdown where m <> (Markdown _ "") = m @@ -163,6 +173,7 @@ markdownP = mconcat <$> A.many' fragmentP '`' -> formattedP '`' Snippet '#' -> A.char '#' *> secretP '!' -> coloredP <|> wordP + '@' -> mentionP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP @@ -192,6 +203,11 @@ markdownP = mconcat <$> A.many' fragmentP if T.null s || T.last s == ' ' then fail "not colored" else pure $ markdown (colored clr) s + mentionP = do + c <- A.char '@' *> A.peekChar' + name <- displayNameTextP + let sName = if c == '\'' then '\'' `T.cons` name `T.snoc` '\'' else name + pure $ markdown (Mention name) ('@' `T.cons` sName) colorP = A.anyChar >>= \case 'r' -> "ed" $> Red <|> pure Red @@ -251,6 +267,15 @@ markdownP = mconcat <$> A.many' fragmentP Just (CRDataGroup _) -> XLGroup Nothing -> XLContact +displayNameTextP :: Parser Text +displayNameTextP = quoted '\'' <|> takeNameTill (== ' ') + where + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' + $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) $(JQ.deriveJSON (sumTypeJSON fstToLower) ''Format) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 55542b1d2f..2010e4b4a6 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -31,6 +31,7 @@ import Data.Char (isSpace) import Data.Int (Int64) import Data.Kind (Constraint) import Data.List.NonEmpty (NonEmpty) +import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -46,6 +47,7 @@ import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -150,6 +152,9 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, meta :: CIMeta c d, content :: CIContent d, + -- The `mentions` map prevents loading all members from UI. + -- The key is a name used in the message text, used to look up MentionedMember. + mentions :: Map MemberName MentionedMember, formattedText :: Maybe MarkdownList, quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], @@ -157,18 +162,24 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show) -isMention :: ChatItem c d -> Bool -isMention ChatItem {chatDir, quotedItem} = case chatDir of - CIDirectRcv -> userItem quotedItem - CIGroupRcv _ -> userItem quotedItem - _ -> False - where - userItem = \case - Nothing -> False - Just CIQuote {chatDir = cd} -> case cd of - CIQDirectSnd -> True - CIQGroupSnd -> True - _ -> False +data MentionedMember = MentionedMember + { memberId :: MemberId, + -- member record can be created later than the mention is received + -- TODO [mentions] should we create member record for "unknown member" in this case? + memberRef :: Maybe MentionedMemberInfo + } + deriving (Eq, Show) + +data MentionedMemberInfo = MentionedMemberInfo + { groupMemberId :: GroupMemberId, + displayName :: Text, -- use `displayName` in copy/share actions + localAlias :: Maybe Text, -- use `fromMaybe displayName localAlias` in chat view + memberRole :: GroupMemberRole -- shown for admins/owners in the message + } + deriving (Eq, Show) + +isUserMention :: ChatItem c d -> Bool +isUserMention ChatItem {meta = CIMeta {userMention}} = userMention data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd @@ -364,6 +375,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemEdited :: Bool, itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, + userMention :: Bool, -- True for messages that mention user or reply to user messages deletable :: Bool, editable :: Bool, forwardedByMember :: Maybe GroupMemberId, @@ -372,11 +384,11 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta } deriving (Show) -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention currentTs itemTs forwardedByMember createdAt updatedAt = let deletable = deletable' itemContent itemDeleted itemTs nominalDay currentTs editable = deletable && isNothing itemForwarded - in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable, forwardedByMember, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, deletable, editable, forwardedByMember, createdAt, updatedAt} deletable' :: forall c d. ChatTypeI c => CIContent d -> Maybe (CIDeleted c) -> UTCTime -> NominalDiffTime -> UTCTime -> Bool deletable' itemContent itemDeleted itemTs allowedInterval currentTs = @@ -401,6 +413,7 @@ dummyMeta itemId ts itemText = itemEdited = False, itemTimed = Nothing, itemLive = Nothing, + userMention = False, deletable = False, editable = False, forwardedByMember = Nothing, @@ -1247,14 +1260,14 @@ data ChatItemVersion = ChatItemVersion deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion -mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content +mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent content where CIMeta {itemId, itemTs, createdAt} = meta version mc = ChatItemVersion { chatItemVersionId = itemId, msgContent = mc, - formattedText = parseMaybeMarkdownList $ msgContentText mc, + formattedText, itemVersionTs = itemTs, createdAt = createdAt } @@ -1387,6 +1400,10 @@ $(JQ.deriveToJSON defaultJSON ''CIQuote) $(JQ.deriveJSON defaultJSON ''CIReactionCount) +$(JQ.deriveJSON defaultJSON ''MentionedMemberInfo) + +$(JQ.deriveJSON defaultJSON ''MentionedMember) + instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 9cbc63b0e2..0a9b378a6b 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -35,6 +35,8 @@ import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB 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 (fromMaybe, mapMaybe) import Data.String import Data.Text (Text) @@ -310,7 +312,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, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MemberMention, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -531,6 +533,11 @@ mcExtMsgContent = \case MCComment _ c -> c MCForward c -> c +isMCForward :: MsgContainer -> Bool +isMCForward = \case + MCForward _ -> True + _ -> False + data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} @@ -589,9 +596,23 @@ msgContentTag = \case MCReport {} -> MCReport_ MCUnknown {tag} -> MCUnknown_ tag -data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} +data ExtMsgContent = ExtMsgContent + { content :: MsgContent, + -- the key used in mentions is a locally (per message) unique display name of member. + -- Suffixes _1, _2 should be appended to make names locally unique. + -- It should be done in the UI, as they will be part of the text, and validated in the API. + mentions :: Map MemberName MemberMention, + file :: Maybe FileInvitation, + ttl :: Maybe Int, + live :: Maybe Bool + } deriving (Eq, Show) +data MemberMention = MemberMention {memberId :: MemberId} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''MemberMention) + $(JQ.deriveJSON defaultJSON ''QuotedMsg) -- this limit reserves space for metadata in forwarded messages @@ -657,10 +678,16 @@ parseMsgContainer v = <|> (MCForward <$> ((v .: "forward" :: JT.Parser J.Object) *> mc)) <|> MCSimple <$> mc where - mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" <*> v .:? "ttl" <*> v .:? "live" + mc = do + content <- v .: "content" + file <- v .:? "file" + ttl <- v .:? "ttl" + live <- v .:? "live" + mentions <- fromMaybe M.empty <$> (v .:? "mentions") + pure ExtMsgContent {content, mentions, file, ttl, live} extMsgContent :: MsgContent -> Maybe FileInvitation -> ExtMsgContent -extMsgContent mc file = ExtMsgContent mc file Nothing Nothing +extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing justTrue :: Bool -> Maybe Bool justTrue True = Just True @@ -709,7 +736,12 @@ msgContainerJSON = \case MCSimple mc -> o $ msgContent mc where o = JM.fromList - msgContent (ExtMsgContent c file ttl live) = ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) ["content" .= c] + msgContent ExtMsgContent {content, mentions, file, ttl, live} = + ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) ["content" .= content] + +nonEmptyMap :: Map k v -> Maybe (Map k v) +nonEmptyMap m = if M.null m then Nothing else Just m +{-# INLINE nonEmptyMap #-} instance ToJSON MsgContent where toJSON = \case @@ -994,7 +1026,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" <*> opt "ttl" <*> opt "live" + XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> (fromMaybe M.empty <$> opt "mentions") <*> opt "ttl" <*> opt "live" XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> p "reaction" <*> p "add" @@ -1056,7 +1088,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 ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content] + XMsgUpdate msgId' content mentions ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("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] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index eb820eb66d..3010841b44 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -47,6 +47,8 @@ module Simplex.Chat.Store.Groups getActiveMembersByName, getGroupInfoByName, getGroupMember, + getMentionedGroupMember, + getMentionedMemberByMemberId, getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, @@ -148,7 +150,7 @@ import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (groupForwardVersion) +import Simplex.Chat.Protocol (MemberMention (..), groupForwardVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -798,6 +800,37 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) +getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO MentionedMember +getMentionedGroupMember db User {userId} groupId gmId = + ExceptT $ firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") + (groupId, gmId, userId) + +getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MemberMention -> IO MentionedMember +getMentionedMemberByMemberId db User {userId} groupId MemberMention {memberId} = + fmap (fromMaybe mentionedMember) $ maybeFirstRow toMentionedMember $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") + (groupId, memberId, userId) + where + mentionedMember = MentionedMember {memberId, memberRef = Nothing} + +mentionedMemberQuery :: Query +mentionedMemberQuery = + [sql| + SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + |] + +toMentionedMember :: (GroupMemberId, MemberId, GroupMemberRole, Text, Maybe Text) -> MentionedMember +toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) = + let memberRef = Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} + in MentionedMember {memberId, memberRef} + getGroupMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMemberById db vr user@User {userId} groupMemberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 58877210dc..9b8b7d3b20 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -53,6 +53,8 @@ module Simplex.Chat.Store.Messages markDirectChatItemDeleted, updateGroupChatItemStatus, updateGroupChatItem, + createGroupCIMentions, + updateGroupCIMentions, deleteGroupChatItem, updateGroupChatItemModerated, updateGroupCIBlockedByAdmin, @@ -136,6 +138,8 @@ import Data.Int (Int64) import Data.List (sortBy) 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.Ord (Down (..), comparing) import Data.Text (Text) @@ -152,6 +156,7 @@ import Simplex.Chat.Store.Groups import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) @@ -367,7 +372,7 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti 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 createdAt Nothing 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 @@ -381,9 +386,9 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live 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 @@ -400,13 +405,13 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw 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 ciContent quoteRow Nothing Nothing 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 MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live 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| @@ -415,20 +420,20 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_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, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, -- quote 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) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe 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)) :. ciTimedRow timed + 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 = case chatDirection of @@ -766,7 +771,7 @@ getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreview getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db vr user groupId lastItem <- case lastItemId_ of - Just lastItemId -> (: []) <$> getGroupChatItem db user groupId lastItemId + Just lastItemId -> (: []) <$> getGroupCIWithReactions db user groupInfo lastItemId Nothing -> pure [] pure $ AChat SCTGroup (Chat (GroupChat groupInfo) lastItem stats) @@ -855,7 +860,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = +toLocalChatItem currentTs ((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_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -879,7 +884,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = @@ -888,7 +893,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Just (CIDeleted @'CTLocal deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1021,6 +1026,7 @@ safeToDirectItem currentTs itemId = \case { chatDir = CIDirectSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1276,6 +1282,7 @@ safeToGroupItem currentTs itemId = \case { chatDir = CIGroupSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1501,6 +1508,7 @@ safeToLocalItem currentTs itemId = \case { chatDir = CILocalSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1810,7 +1818,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) -type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) @@ -1834,7 +1842,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((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_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1858,7 +1866,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -1867,7 +1875,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1891,7 +1899,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction -- 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) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +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_ @@ -1918,7 +1926,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, _ -> 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, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], 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 = @@ -1929,7 +1937,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, _ -> 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) currentTs itemTs forwardedByMember createdAt updatedAt + 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} @@ -2202,7 +2210,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -2254,12 +2262,14 @@ getGroupCIWithReactions db user g@GroupInfo {groupId} itemId = do liftIO . groupCIWithReactions db g =<< getGroupChatItem db user groupId itemId groupCIWithReactions :: DB.Connection -> GroupInfo -> CChatItem 'CTGroup -> IO (CChatItem 'CTGroup) -groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of - Just sharedMsgId -> do - let GroupMember {memberId} = chatItemMember g ci - reactions <- getGroupCIReactions db g memberId sharedMsgId - pure $ CChatItem md ci {reactions} - Nothing -> pure cci +groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemId, itemSharedMsgId}}) = do + mentions <- getGroupCIMentions db itemId + case itemSharedMsgId of + Just sharedMsgId -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- getGroupCIReactions db g memberId sharedMsgId + pure $ CChatItem md ci {reactions, mentions} + Nothing -> pure $ if null mentions then cci else CChatItem md ci {mentions} updateGroupChatItem :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) updateGroupChatItem db user groupId ci newContent edited live msgId_ = do @@ -2285,6 +2295,25 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt +createGroupCIMentions :: forall d. DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +createGroupCIMentions db GroupInfo {groupId} ci mentions = do + DB.executeMany db "INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?)" rows + pure (ci :: ChatItem 'CTGroup d) {mentions} + where + rows = map (\(name, MentionedMember {memberId}) -> (ciId, groupId, memberId, name)) $ M.assocs mentions + ciId = chatItemId' ci + +updateGroupCIMentions :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +updateGroupCIMentions db g ci@ChatItem {mentions} mentions' + | mentions' == mentions = pure ci + | otherwise = do + unless (null mentions) $ deleteMentions + if null mentions' + then pure ci + else createGroupCIMentions db g ci mentions' + where + deleteMentions = DB.execute db "DELETE FROM chat_item_mentions WHERE chat_item_id = ?" (Only $ chatItemId' ci) + deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do let itemId = chatItemId' ci @@ -2458,7 +2487,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember @@ -2562,7 +2591,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -2760,6 +2789,28 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = |] (groupId, itemMemberId, itemSharedMsgId) +getGroupCIMentions :: DB.Connection -> ChatItemId -> IO (Map MemberName MentionedMember) +getGroupCIMentions db ciId = + M.fromList . map mentionedMember + <$> DB.query + db + [sql| + 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) + WHERE r.chat_item_id = ? + |] + (Only ciId) + where + mentionedMember :: (ContactName, MemberId, Maybe GroupMemberId, Maybe GroupMemberRole, Maybe Text, Maybe Text) -> (ContactName, MentionedMember) + mentionedMember (name, memberId, gmId_, mRole_, displayName_, localAlias) = + let memberRef = case (gmId_, mRole_, displayName_) of + (Just groupMemberId, Just memberRole, Just displayName) -> + Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} + _ -> Nothing + in (name, MentionedMember {memberId, memberRef}) + getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of Just itemSharedMId -> case chat of diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index 60a2ace8f3..48d35b23d2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -426,7 +426,8 @@ CREATE TABLE chat_items( fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL, via_proxy SMALLINT, msg_content_tag TEXT, - include_in_history SMALLINT NOT NULL DEFAULT 0 + include_in_history SMALLINT NOT NULL DEFAULT 0, + user_mention SMALLINT NOT NULL DEFAULT 0 ); ALTER TABLE groups ADD CONSTRAINT fk_groups_chat_items @@ -676,6 +677,13 @@ CREATE TABLE chat_tags_chats( group_id BIGINT REFERENCES groups ON DELETE CASCADE, chat_tag_id BIGINT NOT NULL REFERENCES chat_tags ON DELETE CASCADE ); +CREATE TABLE chat_item_mentions ( + chat_item_mention_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BYTEA NOT NULL, + display_name TEXT NOT NULL +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1025,4 +1033,8 @@ CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_s chat_item_id, group_member_id ); +CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index c4d4237f98..29dab148dc 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -125,6 +125,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history +import Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -249,7 +250,8 @@ schemaMigrations = ("20241230_reports", m20241230_reports, Just down_m20241230_reports), ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), - ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history) + ("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) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs new file mode 100644 index 0000000000..f5f5e24fe0 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250126_mentions :: Query +m20250126_mentions = + [sql| +ALTER TABLE chat_items ADD COLUMN user_mention INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE chat_item_mentions ( + chat_item_mention_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BLOB NOT NULL, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + display_name TEXT NOT NULL +); + +CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); +|] + +down_m20250126_mentions :: Query +down_m20250126_mentions = + [sql| +DROP INDEX idx_chat_item_mentions_group_id; +DROP INDEX idx_chat_item_mentions_chat_item_id; +DROP INDEX idx_chat_item_mentions_display_name; +DROP INDEX idx_chat_item_mentions_member_id; + +DROP TABLE chat_item_mentions; +ALTER TABLE chat_items DROP COLUMN user_mention; +|] 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 a58775429d..f9c19d31b5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -714,7 +714,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -731,7 +731,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember @@ -778,7 +778,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -3295,6 +3295,18 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +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) + WHERE r.chat_item_id = ? + +Plan: +SEARCH r USING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) LEFT-JOIN +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + Query: SELECT re_group_member_id FROM group_member_intros @@ -3489,6 +3501,7 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +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=?) @@ -3503,6 +3516,7 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +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=?) @@ -3517,6 +3531,7 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +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=?) @@ -3964,12 +3979,12 @@ Query: user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_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, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, -- quote 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: @@ -4690,6 +4705,24 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT remote_ctrl_id, ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key FROM remote_controllers @@ -4957,6 +4990,7 @@ SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_ Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_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=?) @@ -4968,6 +5002,7 @@ 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 created_at <= ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at do let itemId = chatItemId' ci chatRef = chatInfoToRef chat @@ -178,7 +178,7 @@ responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () responseNotification t@ChatTerminal {sendNotification} cc = \case -- At the moment of writing received items are created one at a time CRNewChatItems u ((AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) : _) -> - when (chatDirNtf u cInfo chatDir $ isMention ci) $ do + when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) @@ -187,7 +187,7 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case where text = msgText mc formattedText CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> - whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isMention ci) $ setActiveChat t cInfo + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ setActiveChat t cInfo CRContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d137e54d23..9c425c059b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -366,6 +366,8 @@ type UserName = Text type ContactName = Text +type MemberName = Text + type GroupName = Text optionalFullName :: ContactName -> Text -> Text @@ -800,6 +802,9 @@ memberConn GroupMember {activeConn} = activeConn memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn +sameMemberId :: MemberId -> GroupMember -> Bool +sameMemberId memId GroupMember {memberId} = memId == memberId + memberChatVRange' :: GroupMember -> VersionRangeChat memberChatVRange' GroupMember {activeConn, memberChatVRange} = case activeConn of Just Connection {peerChatVRange} -> peerChatVRange @@ -839,7 +844,7 @@ data NewGroupMember = NewGroupMember } newtype MemberId = MemberId {unMemberId :: ByteString} - deriving (Eq, Show) + deriving (Eq, Ord, Show) deriving newtype (FromField) instance ToField MemberId where toField (MemberId m) = toField $ Binary m diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 3f7d19fd6d..506829befc 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -4,7 +4,7 @@ {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-orphans #-} -module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle) where +module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle, neUnzip3) where import Control.Exception (Exception) import Control.Monad @@ -15,6 +15,7 @@ import Control.Monad.Reader import Data.Bifunctor (first) import qualified Data.ByteString.Lazy as LB import Data.List (sortBy) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Ord (comparing) import Data.Time (NominalDiffTime) import Data.Word (Word16) @@ -56,6 +57,11 @@ liftIOEither :: (MonadIO m, MonadError e m) => IO (Either e a) -> m a liftIOEither a = liftIO a >>= liftEither {-# INLINE liftIOEither #-} +neUnzip3 :: NonEmpty (a, b, c) -> (NonEmpty a, NonEmpty b, NonEmpty c) +neUnzip3 ((a, b, c) :| xs) = + let (as, bs, cs) = unzip3 xs + in (a :| as, b :| bs, c :| cs) + newtype InternalException e = InternalException {unInternalException :: e} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 21cd9a6636..2025979d45 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -499,7 +499,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isMention ci + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] @@ -588,7 +588,7 @@ viewChats ts tz = concatMap chatPreview . reverse _ -> [] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember}, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> viewCI where viewCI = case chat of @@ -627,7 +627,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False _ -> showRcvItem from where - from = ttyFromGroup g m + from = ttyFromGroupAttention g m userMention where context = maybe @@ -2178,7 +2178,6 @@ viewChatError isCmd logLevel testView = \case CEFileNotApproved fileId unknownSrvs -> ["file " <> sShow fileId <> " aborted, unknwon XFTP servers:"] <> map (plain . show) unknownSrvs CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"] CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."] - CEInvalidQuote -> ["cannot reply to this message"] CEInvalidForward -> ["cannot forward message(s)"] CEInvalidChatItemUpdate -> ["cannot update this item"] CEInvalidChatItemDelete -> ["cannot delete this item"] @@ -2373,7 +2372,10 @@ ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullN ttyGroup g <> optFullName g fullName ttyFromGroup :: GroupInfo -> GroupMember -> StyledString -ttyFromGroup g m = membershipIncognito g <> ttyFrom (fromGroup_ g m) +ttyFromGroup g m = ttyFromGroupAttention g m False + +ttyFromGroupAttention :: GroupInfo -> GroupMember -> Bool -> StyledString +ttyFromGroupAttention g m attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g m attention) ttyFromGroupEdited :: GroupInfo -> GroupMember -> StyledString ttyFromGroupEdited g m = membershipIncognito g <> ttyFrom (fromGroup_ g m <> "[edited] ") @@ -2383,7 +2385,12 @@ ttyFromGroupDeleted g m deletedText_ = membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) fromGroup_ :: GroupInfo -> GroupMember -> Text -fromGroup_ g m = "#" <> viewGroupName g <> " " <> viewMemberName m <> "> " +fromGroup_ g m = fromGroupAttention_ g m False + +fromGroupAttention_ :: GroupInfo -> GroupMember -> Bool -> Text +fromGroupAttention_ g m attention = + let attn = if attention then "!" else "" + in "#" <> viewGroupName g <> " " <> viewMemberName m <> attn <> "> " ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index a930246106..ea4a30b412 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1620,7 +1620,7 @@ testMuteGroup = cath <## " hello too!" concurrentlyN_ [ do - bob <# "#team cath> > bob hello " + bob <# "#team cath!> > bob hello " bob <## " hello too! ", do alice <# "#team cath> > bob hello" @@ -1633,7 +1633,7 @@ testMuteGroup = alice <## " hey bob!" concurrentlyN_ [ do - bob <# "#team alice> > bob hello" + bob <# "#team alice!> > bob hello" bob <## " hey bob!", do cath <# "#team alice> > bob hello" @@ -1647,7 +1647,7 @@ testMuteGroup = bob <# "#team alice> > cath hello too! " bob <## " hey cath! ", do - cath <# "#team alice> > cath hello too!" + cath <# "#team alice!> > cath hello too!" cath <## " hey cath!" ] bob ##> "/gs" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index cceb91c77c..c573a1957a 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -372,9 +372,9 @@ testGroupSendImageWithTextAndQuote = alice <## "use /fc 1 to cancel sending" concurrentlyN_ [ do - bob <# "#team alice> > bob hi team" + bob <# "#team alice!> > bob hi team" bob <## " hey bob" - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <# "#team alice!> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [

/ | ] to receive it", do cath <# "#team alice> > bob hi team" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4bf5003a31..8db304791f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -15,14 +15,17 @@ import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) +import Data.Bifunctor (second) import qualified Data.ByteString.Char8 as B import Data.List (intercalate, isInfixOf) +import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Library.Internal (uniqueMsgMentions) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Types (VersionRangeChat) +import Simplex.Chat.Protocol (MemberMention (..), supportedChatVRange) +import Simplex.Chat.Types (MemberId (..), VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval @@ -186,6 +189,9 @@ chatGroupTests = do it "mark member inactive on reaching quota" testGroupMemberInactive describe "group member reports" $ do it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports + describe "group member mentions" $ do + it "should send messages with member mentions" testMemberMention + describe "uniqueMsgMentions" testUniqueMsgMentions where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -1044,7 +1050,7 @@ testGroupMessageQuotedReply = bob <## " hello, all good, you?" concurrently_ ( do - alice <# "#team bob> > alice hello! how are you?" + alice <# "#team bob!> > alice hello! how are you?" alice <## " hello, all good, you?" ) ( do @@ -1079,7 +1085,7 @@ testGroupMessageQuotedReply = alice <## " hi there!" ) ( do - bob <# "#team cath> > bob hello, all good, you?" + bob <# "#team cath!> > bob hello, all good, you?" bob <## " hi there!" ) cath #$> ("/_get chat #1 count=1", chat', [((1, "hi there!"), Just (0, "hello, all good, you?"))]) @@ -1090,7 +1096,7 @@ testGroupMessageQuotedReply = alice <## " go on" concurrently_ ( do - bob <# "#team alice> > bob will tell more" + bob <# "#team alice!> > bob will tell more" bob <## " go on" ) ( do @@ -1131,7 +1137,7 @@ testGroupMessageUpdate = bob <## " hi alice" concurrently_ ( do - alice <# "#team bob> > alice hey 👋" + alice <# "#team bob!> > alice hey 👋" alice <## " hi alice" ) ( do @@ -1158,7 +1164,7 @@ testGroupMessageUpdate = cath <## " greetings!" concurrently_ ( do - alice <# "#team cath> > alice greetings 🤝" + alice <# "#team cath!> > alice greetings 🤝" alice <## " greetings!" ) ( do @@ -1272,7 +1278,7 @@ testGroupMessageDelete = bob <## " hi alic" concurrently_ ( do - alice <# "#team bob> > alice hello!" + alice <# "#team bob!> > alice hello!" alice <## " hi alic" ) ( do @@ -5423,7 +5429,7 @@ testGroupHistoryQuotes = alice `send` "> #team @bob (BOB) 2" alice <# "#team > bob BOB" alice <## " 2" - bob <# "#team alice> > bob BOB" + bob <# "#team alice!> > bob BOB" bob <## " 2" threadDelay 1000000 @@ -5431,7 +5437,7 @@ testGroupHistoryQuotes = bob `send` "> #team @alice (ALICE) 3" bob <# "#team > alice ALICE" bob <## " 3" - alice <# "#team bob> > alice ALICE" + alice <# "#team bob!> > alice ALICE" alice <## " 3" threadDelay 1000000 @@ -6651,3 +6657,49 @@ testGroupMemberReports = 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 [marked deleted by alice]")]) + +testMemberMention :: HasCallStack => TestParams -> IO () +testMemberMention = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice #> "#team hello!" + concurrentlyN_ + [ bob <# "#team alice> hello!", + cath <# "#team alice> hello!" + ] + bob #> "#team hello @alice" + concurrentlyN_ + [ alice <# "#team bob!> hello @alice", + cath <# "#team bob> hello @alice" + ] + alice #> "#team hello @bob @bob @cath" + concurrentlyN_ + [ bob <# "#team alice!> hello @bob @bob @cath", + cath <# "#team alice!> hello @bob @bob @cath" + ] + cath #> "#team hello @Alice" -- not a mention + concurrentlyN_ + [ alice <# "#team cath> hello @Alice", + bob <# "#team cath> hello @Alice" + ] + +testUniqueMsgMentions :: SpecWith TestParams +testUniqueMsgMentions = do + it "1 correct mention" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd")]) ["alice"] + `shouldBe` (mm [("alice", "abcd")]) + it "2 correct mentions" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("bob", "efgh")]) ["alice", "bob"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + it "2 correct mentions with repetition" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("bob", "efgh")]) ["alice", "alice", "alice", "bob", "bob", "bob"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + it "too many mentions - drop extras" $ \_ -> + uniqueMsgMentions 3 (mm [("a", "abcd"), ("b", "efgh"), ("c", "1234"), ("d", "5678")]) ["a", "a", "a", "b", "b", "c", "d"] + `shouldBe` (mm [("a", "abcd"), ("b", "efgh"), ("c", "1234")]) + it "repeated-with-different name - drop extras" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("alice2", "abcd"), ("bob", "efgh"), ("bob2", "efgh")]) ["alice", "alice2", "bob", "bob2"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + where + mm = M.fromList . map (second $ MemberMention . MemberId) diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 615edc02c4..3f3b5adbc2 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -19,6 +19,7 @@ markdownTests = do textWithUri textWithEmail textWithPhone + textWithMentions multilineMarkdownList textFormat :: Spec @@ -180,8 +181,10 @@ textWithEmail = describe "text with Email" do parseMarkdown "chat@simplex.chat test" `shouldBe` email "chat@simplex.chat" <> " test" parseMarkdown "test1 chat@simplex.chat test2" `shouldBe` "test1 " <> email "chat@simplex.chat" <> " test2" it "ignored as markdown" do - parseMarkdown "chat @simplex.chat" `shouldBe` "chat @simplex.chat" - parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat @simplex.chat" + parseMarkdown "chat @simplex.chat" `shouldBe` "chat " <> mention "simplex.chat" "@simplex.chat" + parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat " <> mention "simplex.chat" "@simplex.chat" + parseMarkdown "this is chat@ simplex.chat" `shouldBe` "this is chat@ simplex.chat" + parseMarkdown "this is chat @ simplex.chat" `shouldBe` "this is chat @ simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -204,6 +207,21 @@ textWithPhone = describe "text with Phone" do it "ignored as markdown (double spaces)" $ parseMarkdown "test 07777 777 777 test" `shouldBe` "test 07777 777 777 test" +mention :: Text -> Text -> Markdown +mention = Markdown . Just . Mention + +textWithMentions :: Spec +textWithMentions = describe "text with mentions" do + it "correct markdown" do + parseMarkdown "@alice" `shouldBe` mention "alice" "@alice" + parseMarkdown "hello @alice" `shouldBe` "hello " <> mention "alice" "@alice" + parseMarkdown "hello @alice !" `shouldBe` "hello " <> mention "alice" "@alice" <> " !" + parseMarkdown "@'alice jones'" `shouldBe` mention "alice jones" "@'alice jones'" + parseMarkdown "hello @'alice jones'!" `shouldBe` "hello " <> mention "alice jones" "@'alice jones'" <> "!" + it "ignored as markdown" $ do + parseMarkdown "hello @'alice jones!" `shouldBe` "hello @'alice jones!" + parseMarkdown "hello @ alice!" `shouldBe` "hello @ alice!" + uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index aba67ed034..4f191384ae 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -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)) 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))) 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)) @@ -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))) 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)))) 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)) 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))) 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 it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index a6ce19da87..a2dde191cb 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -158,8 +158,8 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS agentQueryStats (createAgentStore (DBOpts testAgentDB "" False True TQOff) MCError) (const $ pure ()) - chatSavedPlans' `shouldBe` chatSavedPlans - agentSavedPlans' `shouldBe` agentSavedPlans + chatSavedPlans' == chatSavedPlans `shouldBe` True + agentSavedPlans' == agentSavedPlans `shouldBe` True removeFile testDB removeFile testAgentDB where diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index bbc9c853ea..03089b1721 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -21,6 +21,7 @@ testMkValidName = do mkValidName "J . . Doe" `shouldBe` "J . Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" + mkValidName "'alice" `shouldBe` "alice" mkValidName " alice" `shouldBe` "alice" mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" From e0d6e4ccf7fba3094131e8d0fd4f9df7dc6577ea Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 30 Jan 2025 14:08:56 +0700 Subject: [PATCH 016/289] android, desktop: refactor ChatView by reducing usage of derived state (#5589) --- .../kotlin/chat/simplex/common/App.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 171 +++++++++--------- .../views/chat/SelectableChatItemToolbars.kt | 7 +- .../views/chat/group/GroupReportsView.kt | 29 +-- .../views/chat/item/MarkedDeletedItemView.kt | 2 +- 5 files changed, 104 insertions(+), 109 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index ba1eda8a7c..2456463910 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, reportsView = false, onComposed = onComposed) + ChatView(currentChatId, contentTag = null, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId, reportsView = false) {} + else -> ChatView(currentChatId, contentTag = null) {} } } 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 7713a2399f..7a53c18e5d 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 @@ -59,7 +59,7 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts fun ChatView( staleChatId: State, - reportsView: Boolean, + contentTag: MsgContentTag?, scrollToItemId: MutableState = remember { mutableStateOf(null) }, onComposed: suspend (chatId: String) -> Unit ) { @@ -67,7 +67,6 @@ fun ChatView( // 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 activeChatStats = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value if (chatInfo == null || user == null) { @@ -76,11 +75,6 @@ fun ChatView( ModalManager.end.closeModals() } } else { - val groupReports = remember { derivedStateOf { - val reportsCount = if (activeChatInfo.value is ChatInfo.Group) activeChatStats.value?.reportsCount ?: 0 else 0 - GroupReports(reportsCount, reportsView) } - } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(groupReports.value.contentTag).value.asReversed() } } val searchText = rememberSaveable { mutableStateOf("") } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { @@ -106,7 +100,7 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> - if (!groupReports.value.reportsView) { + if (contentTag == null) { markUnreadChatAsRead(chatId) } showSearch.value = false @@ -121,13 +115,13 @@ fun ChatView( // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chatsForContent(if (reportsView) MsgContentTag.Report else null).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 + chatModel.chatsForContent(contentTag).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), - LocalContentTag provides groupReports.value.contentTag + LocalContentTag provides contentTag ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -141,14 +135,13 @@ fun ChatView( val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { - apiFindMessages(c, value, groupReports.value.toContentTag()) + apiFindMessages(c, value, contentTag) searchText.value = value } } ChatLayout( remoteHostId = remoteHostId, chatInfo = activeChatInfo, - reversedChatItems = reversedChatItems, unreadCount, composeState, composeView = { @@ -177,7 +170,7 @@ fun ChatView( } } else { SelectedItemsBottomToolbar( - reversedChatItems = reversedChatItems, + contentTag = contentTag, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -238,7 +231,6 @@ fun ChatView( ) } }, - groupReports, scrollToItemId, attachmentOption, attachmentBottomSheetState, @@ -333,7 +325,7 @@ fun ChatView( setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - if (!groupReports.value.reportsView) { + if (contentTag == null) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(true) { close -> @@ -347,12 +339,12 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, groupReports.value.toContentTag(), pagination, searchText.value, visibleItemIndexes) + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = reversedChatItems.value.lastOrNull { it.id == itemId } + val toDeleteItem = reversedChatItemsStatic(contentTag).lastOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -660,11 +652,9 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) fun ChatLayout( remoteHostId: State, chatInfo: State, - reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), - groupReports: State, scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, @@ -735,7 +725,8 @@ 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, !groupReports.value.reportsView)) { + val contentTag = LocalContentTag.current + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, contentTag == null)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -748,8 +739,8 @@ fun ChatLayout( override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, reversedChatItems, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, groupReports, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, + useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -757,7 +748,7 @@ fun ChatLayout( } } } - if (groupReports.value.reportsView) { + if (contentTag == MsgContentTag.Report) { Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -768,7 +759,7 @@ fun ChatLayout( AnimatedVisibility(selectedChatItems.value != null) { if (chatInfo != null) { SelectedItemsBottomToolbar( - reversedChatItems = reversedChatItems, + contentTag = contentTag, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { _ -> @@ -805,23 +796,24 @@ fun ChatLayout( } } } + val reportsCount = reportsCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (groupReports.value.showBar) { - ReportedCountToolbar(groupReports, withStatusBar = true, showGroupReports) + if (contentTag == null && reportsCount > 0) { + ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) } else { StatusBarBackground() } } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - if (groupReports.value.reportsView) { + if (contentTag == MsgContentTag.Report) { if (oneHandUI.value) { StatusBarBackground() } Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { Box { if (selectedChatItems.value == null) { - GroupReportsAppBar(groupReports, { ModalManager.end.closeModal() }, onSearchValueChanged) + GroupReportsAppBar(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged) } else { SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value) } @@ -832,14 +824,14 @@ fun ChatLayout( Box { if (selectedChatItems.value == null) { if (chatInfo != null) { - ChatInfoToolbar(chatInfo, groupReports, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + ChatInfoToolbar(chatInfo, contentTag, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) } } else { SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (groupReports.value.showBar && (!oneHandUI.value || !chatBottomBar.value)) { - ReportedCountToolbar(groupReports, withStatusBar = false, showGroupReports) + if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { + ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) } } } @@ -851,7 +843,7 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( chatInfo: ChatInfo, - groupReports: State, + contentTag: MsgContentTag?, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -873,7 +865,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid && !groupReports.value.reportsView) { + if (appPlatform.isAndroid && contentTag == null) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -1069,7 +1061,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo @Composable private fun ReportedCountToolbar( - groupReports: State, + reportsCount: Int, withStatusBar: Boolean, showGroupReports: () -> Unit ) { @@ -1087,12 +1079,11 @@ private fun ReportedCountToolbar( ) { Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) Spacer(Modifier.width(4.dp)) - val reports = groupReports.value.reportsCount Text( - if (reports == 1) { + if (reportsCount == 1) { stringResource(MR.strings.group_reports_active_one) } else { - stringResource(MR.strings.group_reports_active).format(reports) + stringResource(MR.strings.group_reports_active).format(reportsCount) }, style = MaterialTheme.typography.button ) @@ -1106,21 +1097,19 @@ private fun ContactVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } -/** Saves current scroll position when [GroupReports] are open and user opens [ChatItemInfoView], for example, and goes back */ +/** Saves current scroll position when group reports are open and user opens [ChatItemInfoView], for example, and goes back */ private var reportsListState: LazyListState? = null @Composable fun BoxScope.ChatItemsList( remoteHostId: Long?, chatInfo: ChatInfo, - reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeViewHeight: State, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, - groupReports: State, scrollToItemId: MutableState, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, @@ -1151,8 +1140,17 @@ fun BoxScope.ChatItemsList( ) { val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatStateForContent(groupReports.value.contentTag)) } } - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) + val contentTag = LocalContentTag.current + // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state + val mergedItems = remember { + derivedStateOf { + MergedItems.create(chatModel.chatItemsForContent(contentTag).value.asReversed(), unreadCount, revealedItems.value, chatModel.chatStateForContent(contentTag)) + } + } + val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value.asReversed() } } + val reportsCount = reportsCount(chatInfo.id) + val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) /** 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 * */ @@ -1171,7 +1169,7 @@ fun BoxScope.ChatItemsList( LazyListState(index + 1, -maxHeightForList.value) } }) - SaveReportsStateOnDispose(groupReports, listState) + SaveReportsStateOnDispose(listState) val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } @@ -1181,7 +1179,7 @@ fun BoxScope.ChatItemsList( ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) } if (!loadingMoreItems.value) { - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), reversedChatItems, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false try { loadingMoreItems.value = true @@ -1201,11 +1199,11 @@ fun BoxScope.ChatItemsList( 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 (groupReports.value.reportsView) return@remember { scrollToItemId.value = it } + if (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(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, groupReports.value.contentTag) } - if (!groupReports.value.reportsView) { + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, contentTag) } + if (contentTag == null) { LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { if (appPlatform.isAndroid) { ModalManager.end.closeModals() @@ -1214,18 +1212,18 @@ fun BoxScope.ChatItemsList( scrollToItemId.value = null } } } - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo, groupReports) + LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) DisposableEffectOnGone( always = { - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) }, whenGone = { VideoPlayerHolder.releaseAll() - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) } ) @@ -1473,12 +1471,12 @@ fun BoxScope.ChatItemsList( Modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( - top = topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar), + top = topPaddingToContent, bottom = composeViewHeight.value ), reverseLayout = true, additionalBarOffset = composeViewHeight, - additionalTopBar = remember { derivedStateOf { groupReports.value.showBar } }, + additionalTopBar = rememberUpdatedState(contentTag == null && reportsCount > 0), chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -1522,8 +1520,8 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, groupReports, markChatRead, listState) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopCenter), mergedItems, listState, groupReports) + FloatingButtons(topPaddingToContent, topPaddingToContentPx, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } @@ -1543,14 +1541,15 @@ fun BoxScope.ChatItemsList( } @Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo, groupReports: State) { +private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { + val contentTag = LocalContentTag.current LaunchedEffect(remoteHostId, chatInfo.id) { try { loadingMoreItems.value = true - if (chatModel.chatStateForContent(groupReports.value.contentTag).totalAfter.value <= 0) return@LaunchedEffect + if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect delay(500) withContext(Dispatchers.Default) { - apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, groupReports.value.toContentTag(), ChatPagination.Last(ChatPagination.INITIAL_COUNT)) + apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, contentTag, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } } finally { loadingMoreItems.value = false @@ -1616,6 +1615,8 @@ private fun NotifyChatListOnFinishingComposition( @Composable fun BoxScope.FloatingButtons( + topPaddingToContent: Dp, + topPaddingToContentPx: State, loadingMoreItems: MutableState, animatedScrollingInProgress: MutableState, mergedItems: State, @@ -1623,12 +1624,10 @@ fun BoxScope.FloatingButtons( maxHeight: State, composeViewHeight: State, searchValue: State, - groupReports: State, markChatRead: () -> Unit, listState: State ) { val scope = rememberCoroutineScope() - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1674,7 +1673,7 @@ fun BoxScope.FloatingButtons( val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( - Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopEnd), + Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd), topUnreadCount, animatedScrollingInProgress, onClick = { @@ -1696,7 +1695,7 @@ fun BoxScope.FloatingButtons( DefaultDropdownMenu( showDropDown, modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } }, - offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)) + offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent) ) { ItemAction( generalGetString(MR.strings.mark_read), @@ -1713,7 +1712,7 @@ fun BoxScope.FloatingButtons( fun PreloadItems( chatId: String, ignoreLoadingRequests: MutableSet, - reversedChatItems: State>, + contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1724,8 +1723,8 @@ fun PreloadItems( val chatId = rememberUpdatedState(chatId) val loadItems = rememberUpdatedState(loadItems) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) - PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, reversedChatItems, mergedItems, listState, remaining, loadItems) - PreloadItemsAfter(allowLoad, chatId, reversedChatItems, mergedItems, listState, remaining, loadItems) + PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems) + PreloadItemsAfter(allowLoad, chatId, contentTag, mergedItems, listState, remaining, loadItems) } @Composable @@ -1733,7 +1732,7 @@ private fun PreloadItemsBefore( allowLoad: State, chatId: State, ignoreLoadingRequests: State>, - reversedChatItems: State>, + contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1746,12 +1745,12 @@ private fun PreloadItemsBefore( val splits = mergedItems.value.splits val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = reversedChatItems.value + val items = reversedChatItemsStatic(contentTag) if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { - lastIndexToLoadFrom = 0 + lastIndexToLoadFrom = items.lastIndex } if (allowLoad.value && lastIndexToLoadFrom != null) { - items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id + items.getOrNull(lastIndexToLoadFrom)?.id } else { null } @@ -1760,10 +1759,12 @@ private fun PreloadItemsBefore( .filter { !ignoreLoadingRequests.value.contains(it) } .collect { loadFromItemId -> withBGApi { - val sizeWas = reversedChatItems.value.size - val oldestItemIdWas = reversedChatItems.value.lastOrNull()?.id + val items = reversedChatItemsStatic(contentTag) + val sizeWas = items.size + val oldestItemIdWas = items.lastOrNull()?.id val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - if (triedToLoad && sizeWas == reversedChatItems.value.size && oldestItemIdWas == reversedChatItems.value.lastOrNull()?.id) { + val itemsUpdated = reversedChatItemsStatic(contentTag) + if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { ignoreLoadingRequests.value.add(loadFromItemId) } } @@ -1775,7 +1776,7 @@ private fun PreloadItemsBefore( private fun PreloadItemsAfter( allowLoad: MutableState, chatId: State, - reversedChatItems: State>, + contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1796,7 +1797,7 @@ private fun PreloadItemsAfter( snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() .map { firstVisibleIndex -> - val items = reversedChatItems.value + val items = reversedChatItemsStatic(contentTag) val splits = mergedItems.value.splits val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) @@ -1864,15 +1865,14 @@ fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): D @Composable private fun FloatingDate( modifier: Modifier, + topPaddingToContentPx: State, mergedItems: State, listState: State, - groupReports: State ) { val isNearBottom = remember(chatModel.chatId) { mutableStateOf(listState.value.firstVisibleItemIndex == 0) } val nearBottomIndex = remember(chatModel.chatId) { mutableStateOf(if (isNearBottom.value) -1 else 0) } val showDate = remember(chatModel.chatId) { mutableStateOf(false) } val density = LocalDensity.current.density - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier val lastVisibleItemDate = remember { derivedStateOf { @@ -1957,10 +1957,11 @@ private fun FloatingDate( } @Composable -private fun SaveReportsStateOnDispose(groupReports: State, listState: State) { +private fun SaveReportsStateOnDispose(listState: State) { + val contentTag = LocalContentTag.current DisposableEffect(Unit) { onDispose { - reportsListState = if (groupReports.value.reportsView && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null + reportsListState = if (contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null } } } @@ -2062,6 +2063,18 @@ private fun MarkItemsReadAfterDelay( } } +@Composable +fun reportsCount(staleChatId: String?): Int { + return if (staleChatId?.startsWith("#") != true) { + 0 + } else { + remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId }?.chatStats } }.value?.reportsCount ?: 0 + } +} + +private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List = + chatModel.chatItemsForContent(contentTag).value.asReversed() + private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State, mergedItems: State, listState: State): ListItem? { val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value return mergedItems.value.items.getOrNull((listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> @@ -2665,11 +2678,9 @@ fun PreviewChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, - reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, - groupReports = remember { mutableStateOf(GroupReports(0, false)) }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -2742,11 +2753,9 @@ fun PreviewGroupChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, - reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, - groupReports = remember { mutableStateOf(GroupReports(0, false)) }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), 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 e449831ee0..45a2a31c04 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 @@ -48,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> @Composable fun SelectedItemsBottomToolbar( chatInfo: ChatInfo, - reversedChatItems: State>, + contentTag: MsgContentTag?, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible moderateItems: () -> Unit, @@ -107,8 +107,9 @@ fun SelectedItemsBottomToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) { - recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + val chatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value } } + LaunchedEffect(chatInfo, chatItems.value, selectedChatItems.value) { + recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } } 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 a1ec3ec0a9..058ee59a3b 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 @@ -16,28 +16,14 @@ import kotlinx.coroutines.flow.* val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } -data class GroupReports( - val reportsCount: Int, - val reportsView: Boolean, -) { - val showBar: Boolean = reportsCount > 0 && !reportsView - - fun toContentTag(): MsgContentTag? { - if (!reportsView) return null - return MsgContentTag.Report - } - - val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report -} - @Composable private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { - ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {}) + ChatView(staleChatId, contentTag = MsgContentTag.Report, scrollToItemId, onComposed = {}) } @Composable fun GroupReportsAppBar( - groupReports: State, + contentTag: MsgContentTag?, close: () -> Unit, onSearchValueChanged: (String) -> Unit ) { @@ -65,11 +51,11 @@ fun GroupReportsAppBar( } } ) - ItemsReload(groupReports) + ItemsReload(contentTag) } @Composable -private fun ItemsReload(groupReports: State) { +private fun ItemsReload(contentTag: MsgContentTag?) { LaunchedEffect(Unit) { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() @@ -79,7 +65,7 @@ private fun ItemsReload(groupReports: State) { .filterNotNull() .filter { it.chatInfo is ChatInfo.Group } .collect { chat -> - reloadItems(chat, groupReports) + reloadItems(chat, contentTag) } } } @@ -100,7 +86,6 @@ suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: Mu } } -private suspend fun reloadItems(chat: Chat, groupReports: State) { - val contentFilter = groupReports.value.toContentTag() - apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +private suspend fun reloadItems(chat: Chat, contentTag: MsgContentTag?) { + apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index d63094cd1d..f731db2df9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -95,7 +95,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, reve fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String = if (cItem.meta.itemDeleted != null && cItem.isReport) { - if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as? ChatInfo.Group)?.groupInfo?.membership?.groupMemberId) { generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName) } else { generalGetString(MR.strings.report_item_archived) From 2d719273a8da90aacd910f677411d03ce102d348 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 30 Jan 2025 10:06:26 +0000 Subject: [PATCH 017/289] core: update message text when forwarding and quoting to reflect changes in mentioned member names (#5586) * update message text when forwarding and quoting to reflect changes in mentioned member names * fix, test * forward mentions to the same chat, refactor * comment * tests * test markdown conversion to text * simplify * unused * comments --- .../src/Directory/Events.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 106 +++--- src/Simplex/Chat/Library/Internal.hs | 99 ++++-- src/Simplex/Chat/Library/Subscriber.hs | 12 +- src/Simplex/Chat/Markdown.hs | 35 +- src/Simplex/Chat/Messages.hs | 15 +- src/Simplex/Chat/Protocol.hs | 12 +- src/Simplex/Chat/Store/Groups.hs | 16 +- src/Simplex/Chat/Store/Messages.hs | 14 +- src/Simplex/Chat/Util.hs | 8 +- src/Simplex/Chat/View.hs | 5 +- tests/ChatTests/Groups.hs | 103 +++++- tests/MarkdownTests.hs | 309 ++++++++++-------- 13 files changed, 460 insertions(+), 276 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 77f21337f7..adaccf612f 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -226,7 +226,7 @@ directoryCmdP = gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameTextP viewName :: Text -> Text -viewName n = if any (== ' ') (T.unpack n) then "'" <> n <> "'" else n +viewName n = if T.any (== ' ') n then "'" <> n <> "'" else n directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7c2e6134c7..ea947180ff 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -37,7 +37,7 @@ import Data.Either (fromRight, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith5) +import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith4) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -80,7 +80,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (liftIOEither, neUnzip3) +import Simplex.Chat.Util (liftIOEither) import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent @@ -542,8 +542,11 @@ processChatCommand' vr = \case withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) CTGroup -> - withGroupLock "sendMessage" chatId $ - sendGroupContentMessages user chatId live itemTTL (L.map composedMessageReq cms) + 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 live itemTTL cmrs CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" @@ -628,7 +631,8 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (mentionedMembers, mentions') <- withFastStore $ \db -> getMentionedMembers db user gInfo ft_ mentions + ciMentions <- withFastStore $ \db -> getCIMentions db user gInfo ft_ mentions + let mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime @@ -636,7 +640,7 @@ processChatCommand' vr = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) let edited = itemLive /= Just True ci' <- updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId - updateGroupCIMentions db gInfo ci' mentionedMembers + 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) @@ -831,7 +835,7 @@ processChatCommand' vr = \case MCFile t -> t /= "" MCReport {} -> True MCUnknown {} -> True - APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of + APIForwardChatItems toChat@(ChatRef toCType toChatId) fromChat@(ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do cmrs <- prepareForward user case L.nonEmpty cmrs of @@ -843,8 +847,9 @@ processChatCommand' vr = \case cmrs <- prepareForward user case L.nonEmpty cmrs of Just cmrs' -> - withGroupLock "forwardChatItem, to group" toChatId $ - sendGroupContentMessages user toChatId False itemTTL cmrs' + withGroupLock "forwardChatItem, to group" toChatId $ do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId + sendGroupContentMessages user gInfo False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -865,23 +870,26 @@ processChatCommand' vr = \case ciComposeMsgReq ct (CChatItem md ci) (mc', file) = let itemId = chatItemId' ci ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) (toMsgDirection md) (Just fromChatId) (Just itemId)) - in (composedMessage file mc', ciff, msgContentTexts mc') + in (composedMessage file mc', ciff, msgContentTexts mc', M.empty) where forwardName :: Contact -> ContactName forwardName Contact {profile = LocalProfile {displayName, localAlias}} | localAlias /= "" = localAlias | otherwise = displayName CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do - -- TODO [mentions] forward to the same group should retain mentions, and shouldn't read them again - -- update names? (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq - ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do + ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText}) (mc, file) = do let itemId = chatItemId' ci ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) - in (composedMessage file mc', ciff, msgContentTexts mc') + -- updates text to reflect current mentioned member names + (mc', _, mentions') = updatedMentionNames mc formattedText mentions + -- only includes mentions when forwarding to the same group + ciMentions = if toChat == fromChat then mentions' else M.empty + -- no need to have mentions in ComposedMessage, they are in ciMentions + in (ComposedMessage file Nothing mc' M.empty, ciff, msgContentTexts mc', ciMentions) where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName @@ -892,7 +900,7 @@ processChatCommand' vr = \case ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq (CChatItem _ ci) (mc', file) = let ciff = forwardCIFF ci Nothing - in (composedMessage file mc', ciff, msgContentTexts mc') + in (composedMessage file mc', ciff, msgContentTexts mc', M.empty) CTContactRequest -> throwChatError $ CECommandError "not supported" CTContactConnection -> throwChatError $ CECommandError "not supported" where @@ -2986,15 +2994,15 @@ processChatCommand' vr = \case where assertVoiceAllowed :: Contact -> CM () assertVoiceAllowed ct = - when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _, _) -> isVoice msgContent) cmrs) $ + when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _, _, _) -> isVoice msgContent) cmrs) $ throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) processComposedMessages :: Contact -> CM ChatResponse processComposedMessages ct = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers timed_ <- sndContactCITimed live ct itemTTL - (msgContainers, quotedItems_, mms) <- neUnzip3 <$> prepareMsgs (L.zip cmrs fInvs_) timed_ + (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ + 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) itemsData timed_ live processSendErrs user r @@ -3005,19 +3013,18 @@ processChatCommand' vr = \case where setupSndFileTransfers :: CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) - prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect), (Map MemberName MentionedMember, Map MemberName MemberMention))) + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect))) prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> - forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _), fInv_) -> do - let mms = (M.empty, M.empty) + 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, mms) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) + (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) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId @@ -3025,7 +3032,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, mms) + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) @@ -3033,10 +3040,10 @@ 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 -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user groupId live itemTTL cmrs = do + sendGroupContentMessages :: User -> GroupInfo -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo live itemTTL cmrs = do assertMultiSendable live cmrs - Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo sendGroupContentMessages_ user gInfo ms live itemTTL cmrs sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do @@ -3053,15 +3060,15 @@ processChatCommand' vr = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft)) acc -> prohibitedGroupContent gInfo membership mc ft fileSource <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership mc ft fileSource <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL - (msgContainers, quotedItems_, mms) <- neUnzip3 <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo ms $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) + (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ + (msgs_, gsr) <- sendGroupMessages user gInfo ms chatMsgEvents + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr @@ -3074,16 +3081,17 @@ processChatCommand' vr = \case where setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers n = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) - prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention))) + 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, mentions}, itemForwarded, (_, ft_)), fInv_) -> - prepareGroupMsg db user gInfo mc ft_ mentions quotedItemId itemForwarded fInv_ timed_ live + 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 createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -3127,7 +3135,7 @@ processChatCommand' vr = \case -- This is to support case of sending multiple attachments while also quoting another message. -- UI doesn't allow composing with multiple quotes, so api prohibits it as well, and doesn't bother -- batching retrieval of quoted messages (prepareMsgs). - when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _, _) -> isJust quotedItemId) cmrs) > 1) $ + when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _, _, _) -> isJust quotedItemId) cmrs) > 1) $ throwChatError (CECommandError "invalid multi send: live and more than one quote not supported") xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd) xftpSndFileTransfer user file fileSize n contactOrGroup = do @@ -3146,13 +3154,12 @@ processChatCommand' vr = \case pure (fInv, ciFile) prepareSndItemsData :: [ComposedMessageReq] -> - [(Map MemberName MentionedMember, Map MemberName MemberMention)] -> [Maybe (CIFile 'MDSnd)] -> [Maybe (CIQuote c)] -> [Either ChatError SndMessage] -> [Either ChatError (NewSndChatItemData c)] prepareSndItemsData = - zipWith5 $ \(ComposedMessage {msgContent}, itemForwarded, ts) mm f q -> \case + zipWith4 $ \(ComposedMessage {msgContent}, itemForwarded, ts, mm) f q -> \case Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) ts mm f q itemForwarded Left e -> Left e -- step over original error processSendErrs :: User -> ([ChatError], [ChatItem c d]) -> CM () @@ -3179,12 +3186,12 @@ processChatCommand' vr = \case getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup]) getCommandGroupChatItems user gId itemIds = do gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds)) + (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) unless (null errs) $ toView $ CRChatErrors (Just user) errs pure (gInfo, items) where - getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) - getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId + getGroupCI :: DB.Connection -> GroupInfo -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) + getGroupCI db gInfo itemId = runExceptT . withExceptT ChatErrorStore $ getGroupCIWithReactions db user gInfo itemId getCommandLocalChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (NoteFolder, [CChatItem 'CTLocal]) getCommandLocalChatItems user nfId itemIds = do nf <- withStore $ \db -> getNoteFolder db user nfId @@ -3211,11 +3218,11 @@ processChatCommand' vr = \case where assertNoQuotes :: CM () assertNoQuotes = - when (any (\(ComposedMessage {quotedItemId}, _, _) -> isJust quotedItemId) cmrs) $ + when (any (\(ComposedMessage {quotedItemId}, _, _, _) -> isJust quotedItemId) cmrs) $ throwChatError (CECommandError "createNoteFolderContentItems: quotes not supported") createLocalFiles :: NoteFolder -> UTCTime -> CM (NonEmpty (Maybe (CIFile 'MDSnd))) createLocalFiles nf createdAt = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do fsFilePath <- lift $ toFSFilePath filePath fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs @@ -3228,7 +3235,7 @@ processChatCommand' vr = \case NonEmpty (Maybe (CIFile 'MDSnd)) -> NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) prepareLocalItemsData = - L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts) f -> + L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts, _) f -> (CISndMsgContent mc, f, itemForwarded, ts) getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) @@ -3251,13 +3258,18 @@ updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = disableSrv srv@UserServer {preset} = AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} -type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} composedMessageReq :: ComposedMessage -> ComposedMessageReq -composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgContentTexts mc) +composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgContentTexts mc, M.empty) + +composedMessageReqMentions :: DB.Connection -> User -> GroupInfo -> ComposedMessage -> ExceptT StoreError IO ComposedMessageReq +composedMessageReqMentions db user g cm@ComposedMessage {msgContent = mc, mentions} = do + let ts@(_, ft_) = msgContentTexts mc + (cm,Nothing,ts,) <$> getCIMentions db user g ft_ mentions data ChangedProfileContact = ChangedProfileContact { ct :: Contact, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1c641d165c..e61a7795e4 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -29,6 +29,7 @@ import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Char (isDigit) import Data.Containers.ListUtils (nubOrd) import Data.Either (partitionEithers, rights) import Data.Fixed (div') @@ -188,22 +189,24 @@ 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 -> Maybe MarkdownList -> Map MemberName GroupMemberId -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention)) -prepareGroupMsg db user g@GroupInfo {groupId, membership} mc ft_ memberMentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of - (Nothing, Nothing) -> do - mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions - pure (MCSimple (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) +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 + (Nothing, Nothing) -> + let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) + in pure (XMsgNew mc', Nothing) (Nothing, Just _) -> - pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, (M.empty, M.empty)) + let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) + in pure (XMsgNew mc', Nothing) (Just quotedItemId, Nothing) -> do - mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - getGroupChatItem db user groupId quotedItemId + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- + getGroupCIWithReactions db user g quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} qmc = quoteContent mc origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem, mms) + (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)) + pure (XMsgNew mc', Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) @@ -212,42 +215,72 @@ prepareGroupMsg db user g@GroupInfo {groupId, membership} mc ft_ memberMentions quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) quoteData _ _ = throwError SEInvalidQuote -getMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName GroupMemberId -> ExceptT StoreError IO (Map MemberName MentionedMember, Map MemberName MemberMention) -getMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of - Just ft | not (null mentions) -> do +updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) +updatedMentionNames mc ft_ mentions = case ft_ of + Just ft | not (null ft) && not (null mentions) && not (all sameName $ M.assocs mentions) -> + let (mentions', ft') = mapAccumL update M.empty ft + text = T.concat $ map markdownText ft' + in (mc {text} :: MsgContent, Just ft', mentions') + _ -> (mc, ft_, mentions) + where + sameName (name, CIMention {memberRef}) = case memberRef of + Just CIMentionMember {displayName} -> case T.stripPrefix displayName name of + Just rest + | T.null rest -> True + | otherwise -> case T.uncons rest of + Just ('_', suffix) -> T.all isDigit suffix + _ -> False + Nothing -> False + Nothing -> True + update mentions' ft@(FormattedText f _) = case f of + Just (Mention name) -> case M.lookup name mentions of + Just mm@CIMention {memberRef} -> + let name' = uniqueMentionName 0 $ case memberRef of + Just CIMentionMember {displayName} -> displayName + Nothing -> name + in (M.insert name' mm mentions', FormattedText (Just $ Mention name') ('@' `T.cons` viewName name')) + Nothing -> (mentions', ft) + _ -> (mentions', ft) + where + uniqueMentionName :: Int -> Text -> Text + uniqueMentionName pfx name = + let prefixed = if pfx == 0 then name else (name `T.snoc` '_') <> tshow pfx + in if prefixed `M.member` mentions' then uniqueMentionName (pfx + 1) name else prefixed + +getCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName GroupMemberId -> ExceptT StoreError IO (Map MemberName CIMention) +getCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null ft) && not (null mentions) -> do let msgMentions = S.fromList $ mentionedNames ft n = M.size mentions -- prevent "invisible" and repeated-with-different-name mentions (when the same member is mentioned via another name) unless (n <= maxSndMentions && all (`S.member` msgMentions) (M.keys mentions) && S.size (S.fromList $ M.elems mentions) == n) $ throwError SEInvalidMention - mentionedMembers <- mapM (getMentionedGroupMember db user groupId) mentions - let mentions' = M.map (\MentionedMember {memberId} -> MemberMention {memberId}) mentionedMembers - pure (mentionedMembers, mentions') - _ -> pure (M.empty, M.empty) + mapM (getMentionedGroupMember db user groupId) mentions + _ -> pure M.empty -getRcvMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MemberMention -> IO (Map MemberName MentionedMember) -getRcvMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of - Just ft | not (null mentions) -> +getRcvCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MsgMention -> IO (Map MemberName CIMention) +getRcvCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null ft) && not (null mentions) -> let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft in mapM (getMentionedMemberByMemberId db user groupId) mentions' _ -> pure M.empty -- prevent "invisible" and repeated-with-different-name mentions -uniqueMsgMentions :: Int -> Map MemberName MemberMention -> [ContactName] -> Map MemberName MemberMention +uniqueMsgMentions :: Int -> Map MemberName MsgMention -> [ContactName] -> Map MemberName MsgMention uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 where go acc _ _ [] = acc go acc seen n (name : rest) | n >= maxMentions = acc | otherwise = case M.lookup name mentions of - Just mm@MemberMention {memberId} | S.notMember memberId seen -> + Just mm@MsgMention {memberId} | S.notMember memberId seen -> go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest _ -> go acc seen n rest getMessageMentions :: DB.Connection -> User -> GroupId -> Text -> IO (Map MemberName GroupMemberId) getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of - Just ft -> M.fromList . catMaybes <$> mapM get (nubOrd $ mentionedNames ft) - Nothing -> pure M.empty + Just ft | not (null ft) -> M.fromList . catMaybes <$> mapM get (nubOrd $ mentionedNames ft) + _ -> pure M.empty where get name = fmap (name,) . eitherToMaybe @@ -1608,8 +1641,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 - itemMentions = (M.empty, M.empty) - saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions, 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" @@ -1617,7 +1649,7 @@ data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, itemTexts :: (Text, Maybe MarkdownList), - itemMentions :: (Map MemberName MentionedMember, Map MemberName MemberMention), + itemMentions :: Map MemberName CIMention, ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), itemForwarded :: Maybe CIForwardedFrom @@ -1643,9 +1675,8 @@ saveSndChatItems user cd itemsData itemTimed live = do 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 - mentions = fst itemMentions Right <$> case cd of - CDGroupSnd g | not (null mentions) -> createGroupCIMentions db g ci mentions + CDGroupSnd g | 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) @@ -1658,18 +1689,18 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = 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 MemberMention -> 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) 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 MentionedMember, userMention) <- case cd of + (mentions' :: Map MemberName CIMention, userMention) <- case cd of CDGroupRcv g@GroupInfo {membership} _ -> do - mentions' <- getRcvMentionedMembers db user g ft_ mentions + mentions' <- getRcvCIMentions db user g ft_ mentions let userReply = case cmToQuotedMsg chatMsgEvent of Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership _ -> False - userMention' = userReply || any (\MentionedMember {memberId} -> sameMemberId memberId membership) mentions' + 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 msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 3293e6b208..a8a3af5252 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -902,10 +902,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ -- TODO [mentions] history? - let (_t, ft_) = msgContentTexts mc - (msgContainer, _, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc ft_ M.empty quotedItemId_ Nothing fInv_ itemTimed False + -- let (_t, ft_) = msgContentTexts mc + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc M.empty quotedItemId_ Nothing fInv_ itemTimed False let senderVRange = memberChatVRange' sender - xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} + xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of (Just fileDescrText, Just msgId) -> do partSize <- asks $ xftpDescrPartSize . config @@ -1782,7 +1782,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ groupMsgToView gInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MemberMention -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () + 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_ | prohibitedSimplexLinks gInfo m ft_ = messageWarning $ "x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks @@ -1817,9 +1817,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) reactions <- getGroupCIReactions db gInfo memberId sharedMsgId let edited = itemLive /= Just True - mentionedMembers <- getRcvMentionedMembers db user gInfo ft_ mentions + ciMentions <- getRcvCIMentions db user gInfo ft_ mentions ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId - updateGroupCIMentions db gInfo ci' mentionedMembers + 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) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 9b6359b4ae..5adf9f2dc9 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -16,7 +16,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A -import Data.Char (isDigit, isPunctuation) +import Data.Char (isDigit, isPunctuation, isSpace) import Data.Either (fromRight) import Data.Functor (($>)) import Data.List (foldl', intercalate) @@ -267,6 +267,36 @@ markdownP = mconcat <$> A.many' fragmentP Just (CRDataGroup _) -> XLGroup Nothing -> XLContact +markdownText :: FormattedText -> Text +markdownText (FormattedText f_ t) = case f_ of + Nothing -> t + Just f -> case f of + Bold -> around '*' + Italic -> around '_' + StrikeThrough -> around '~' + Snippet -> around '`' + Secret -> around '#' + Colored (FormatColor c) -> color c + Uri -> t + SimplexLink {} -> t + Mention _ -> t + Email -> t + Phone -> t + where + around c = c `T.cons` t `T.snoc` c + color c = case colorStr c of + Just cStr -> cStr <> t `T.snoc` '!' + Nothing -> t + colorStr = \case + Red -> Just "!1 " + Green -> Just "!2 " + Blue -> Just "!3 " + Yellow -> Just "!4 " + Cyan -> Just "!5 " + Magenta -> Just "!6 " + Black -> Nothing + White -> Nothing + displayNameTextP :: Parser Text displayNameTextP = quoted '\'' <|> takeNameTill (== ' ') where @@ -276,6 +306,9 @@ displayNameTextP = quoted '\'' <|> takeNameTill (== ' ') quoted c = A.char c *> takeNameTill (== c) <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' +viewName :: Text -> Text +viewName s = if T.any isSpace s then "'" <> s <> "'" else s + $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) $(JQ.deriveJSON (sumTypeJSON fstToLower) ''Format) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 2010e4b4a6..ea2f94ad99 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -153,8 +153,8 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem meta :: CIMeta c d, content :: CIContent d, -- The `mentions` map prevents loading all members from UI. - -- The key is a name used in the message text, used to look up MentionedMember. - mentions :: Map MemberName MentionedMember, + -- The key is a name used in the message text, used to look up CIMention. + mentions :: Map MemberName CIMention, formattedText :: Maybe MarkdownList, quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], @@ -162,15 +162,14 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show) -data MentionedMember = MentionedMember +data CIMention = CIMention { memberId :: MemberId, -- member record can be created later than the mention is received - -- TODO [mentions] should we create member record for "unknown member" in this case? - memberRef :: Maybe MentionedMemberInfo + memberRef :: Maybe CIMentionMember } deriving (Eq, Show) -data MentionedMemberInfo = MentionedMemberInfo +data CIMentionMember = CIMentionMember { groupMemberId :: GroupMemberId, displayName :: Text, -- use `displayName` in copy/share actions localAlias :: Maybe Text, -- use `fromMaybe displayName localAlias` in chat view @@ -1400,9 +1399,9 @@ $(JQ.deriveToJSON defaultJSON ''CIQuote) $(JQ.deriveJSON defaultJSON ''CIReactionCount) -$(JQ.deriveJSON defaultJSON ''MentionedMemberInfo) +$(JQ.deriveJSON defaultJSON ''CIMentionMember) -$(JQ.deriveJSON defaultJSON ''MentionedMember) +$(JQ.deriveJSON defaultJSON ''CIMention) instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 0a9b378a6b..53af11ada7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -312,7 +312,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 MemberMention, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -539,12 +539,12 @@ isMCForward = \case _ -> False data MsgContent - = MCText Text + = MCText {text :: Text} | MCLink {text :: Text, preview :: LinkPreview} | MCImage {text :: Text, image :: ImageData} | MCVideo {text :: Text, image :: ImageData, duration :: Int} | MCVoice {text :: Text, duration :: Int} - | MCFile Text + | MCFile {text :: Text} | MCReport {text :: Text, reason :: ReportReason} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -601,17 +601,17 @@ data ExtMsgContent = ExtMsgContent -- the key used in mentions is a locally (per message) unique display name of member. -- Suffixes _1, _2 should be appended to make names locally unique. -- It should be done in the UI, as they will be part of the text, and validated in the API. - mentions :: Map MemberName MemberMention, + mentions :: Map MemberName MsgMention, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool } deriving (Eq, Show) -data MemberMention = MemberMention {memberId :: MemberId} +data MsgMention = MsgMention {memberId :: MemberId} deriving (Eq, Show) -$(JQ.deriveJSON defaultJSON ''MemberMention) +$(JQ.deriveJSON defaultJSON ''MsgMention) $(JQ.deriveJSON defaultJSON ''QuotedMsg) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3010841b44..1b73a5b761 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -150,7 +150,7 @@ import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (MemberMention (..), groupForwardVersion) +import Simplex.Chat.Protocol (MsgMention (..), groupForwardVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -800,7 +800,7 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) -getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO MentionedMember +getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention getMentionedGroupMember db User {userId} groupId gmId = ExceptT $ firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ DB.query @@ -808,15 +808,15 @@ getMentionedGroupMember db User {userId} groupId gmId = (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, gmId, userId) -getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MemberMention -> IO MentionedMember -getMentionedMemberByMemberId db User {userId} groupId MemberMention {memberId} = +getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MsgMention -> IO CIMention +getMentionedMemberByMemberId db User {userId} groupId MsgMention {memberId} = fmap (fromMaybe mentionedMember) $ maybeFirstRow toMentionedMember $ DB.query db (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") (groupId, memberId, userId) where - mentionedMember = MentionedMember {memberId, memberRef = Nothing} + mentionedMember = CIMention {memberId, memberRef = Nothing} mentionedMemberQuery :: Query mentionedMemberQuery = @@ -826,10 +826,10 @@ mentionedMemberQuery = JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) |] -toMentionedMember :: (GroupMemberId, MemberId, GroupMemberRole, Text, Maybe Text) -> MentionedMember +toMentionedMember :: (GroupMemberId, MemberId, GroupMemberRole, Text, Maybe Text) -> CIMention toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) = - let memberRef = Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} - in MentionedMember {memberId, memberRef} + let memberRef = Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} + in CIMention {memberId, memberRef} getGroupMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMemberById db vr user@User {userId} groupMemberId = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 9b8b7d3b20..339e50140c 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2295,15 +2295,15 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt -createGroupCIMentions :: forall d. DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +createGroupCIMentions :: forall d. DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName CIMention -> IO (ChatItem 'CTGroup d) createGroupCIMentions db GroupInfo {groupId} ci mentions = do DB.executeMany db "INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?)" rows pure (ci :: ChatItem 'CTGroup d) {mentions} where - rows = map (\(name, MentionedMember {memberId}) -> (ciId, groupId, memberId, name)) $ M.assocs mentions + rows = map (\(name, CIMention {memberId}) -> (ciId, groupId, memberId, name)) $ M.assocs mentions ciId = chatItemId' ci -updateGroupCIMentions :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +updateGroupCIMentions :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName CIMention -> IO (ChatItem 'CTGroup d) updateGroupCIMentions db g ci@ChatItem {mentions} mentions' | mentions' == mentions = pure ci | otherwise = do @@ -2789,7 +2789,7 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = |] (groupId, itemMemberId, itemSharedMsgId) -getGroupCIMentions :: DB.Connection -> ChatItemId -> IO (Map MemberName MentionedMember) +getGroupCIMentions :: DB.Connection -> ChatItemId -> IO (Map MemberName CIMention) getGroupCIMentions db ciId = M.fromList . map mentionedMember <$> DB.query @@ -2803,13 +2803,13 @@ getGroupCIMentions db ciId = |] (Only ciId) where - mentionedMember :: (ContactName, MemberId, Maybe GroupMemberId, Maybe GroupMemberRole, Maybe Text, Maybe Text) -> (ContactName, MentionedMember) + mentionedMember :: (ContactName, MemberId, Maybe GroupMemberId, Maybe GroupMemberRole, Maybe Text, Maybe Text) -> (ContactName, CIMention) mentionedMember (name, memberId, gmId_, mRole_, displayName_, localAlias) = let memberRef = case (gmId_, mRole_, displayName_) of (Just groupMemberId, Just memberRole, Just displayName) -> - Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} + Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} _ -> Nothing - in (name, MentionedMember {memberId, memberRef}) + in (name, CIMention {memberId, memberRef}) getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 506829befc..3f7d19fd6d 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -4,7 +4,7 @@ {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-orphans #-} -module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle, neUnzip3) where +module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle) where import Control.Exception (Exception) import Control.Monad @@ -15,7 +15,6 @@ import Control.Monad.Reader import Data.Bifunctor (first) import qualified Data.ByteString.Lazy as LB import Data.List (sortBy) -import Data.List.NonEmpty (NonEmpty (..)) import Data.Ord (comparing) import Data.Time (NominalDiffTime) import Data.Word (Word16) @@ -57,11 +56,6 @@ liftIOEither :: (MonadIO m, MonadError e m) => IO (Either e a) -> m a liftIOEither a = liftIO a >>= liftEither {-# INLINE liftIOEither #-} -neUnzip3 :: NonEmpty (a, b, c) -> (NonEmpty a, NonEmpty b, NonEmpty c) -neUnzip3 ((a, b, c) :| xs) = - let (as, bs, cs) = unzip3 xs - in (a :| as, b :| bs, c :| cs) - newtype InternalException e = InternalException {unInternalException :: e} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2025979d45..445ec0b7c0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -17,7 +17,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB -import Data.Char (isSpace, toUpper) +import Data.Char (toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) @@ -2404,9 +2404,6 @@ ttyToGroup g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " ") ttyToGroupEdited :: GroupInfo -> StyledString ttyToGroupEdited g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " [edited] ") -viewName :: Text -> Text -viewName s = if T.any isSpace s then "'" <> s <> "'" else s - ttyFilePath :: FilePath -> StyledString ttyFilePath = plain diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 8db304791f..dccd311f59 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -21,10 +21,11 @@ import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) -import Simplex.Chat.Library.Internal (uniqueMsgMentions) -import Simplex.Chat.Messages (ChatItemId) +import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) +import Simplex.Chat.Markdown (parseMaybeMarkdownList) +import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Protocol (MemberMention (..), supportedChatVRange) +import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText, supportedChatVRange) import Simplex.Chat.Types (MemberId (..), VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite @@ -191,7 +192,9 @@ chatGroupTests = do it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports describe "group member mentions" $ do it "should send messages with member mentions" testMemberMention + it "should forward and quote message updating mentioned member name" testForwardQuoteMention describe "uniqueMsgMentions" testUniqueMsgMentions + describe "updatedMentionNames" testUpdatedMentionNames where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -6684,6 +6687,72 @@ testMemberMention = bob <# "#team cath> hello @Alice" ] +testForwardQuoteMention :: HasCallStack => TestParams -> IO () +testForwardQuoteMention = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob #> "#team hello @alice @cath" + concurrentlyN_ + [ alice <# "#team bob!> hello @alice @cath", + cath <# "#team bob!> hello @alice @cath" + ] + -- quote mentions + alice `send` "> #team @bob (hello) hi there!" + alice <# "#team > bob hello @alice @cath" + alice <## " hi there!" + concurrently_ + ( do + bob <# "#team alice!> > bob hello @alice @cath" + bob <## " hi there!" + ) + ( do + cath <# "#team alice> > bob hello @alice @cath" + cath <## " hi there!" + ) + -- forward mentions to the same group + alice `send` "#team <- #team hello" + alice <# "#team <- #team" + alice <## " hello @alice @cath" + concurrentlyN_ + [ do + bob <# "#team alice> -> forwarded" + bob <## " hello @alice @cath", + do + cath <# "#team alice!> -> forwarded" + cath <## " hello @alice @cath" + ] + -- forward mentions + alice `send` "@bob <- #team hello" + alice <# "@bob <- #team" + alice <## " hello @alice @cath" + bob <# "alice> -> forwarded" + bob <## " hello @alice @cath" + -- member renamed to duplicate name + cath ##> "/p alice_1" + cath <## "user profile is changed to alice_1 (your 1 contacts are notified)" + alice <## "contact cath changed to alice_1" + alice <## "use @alice_1 to send messages" + -- mention changed in quoted mentions + alice `send` "> #team @bob (hello) hi there!" + alice <# "#team > bob hello @alice @alice_1" + alice <## " hi there!" + concurrently_ + ( do + bob <# "#team alice!> > bob hello @alice @alice_1" + bob <## " hi there!" + ) + ( do + cath <# "#team alice> > bob hello @alice @alice_1" + cath <## " hi there!" + ) + -- mention changed in forwarded message + alice `send` "@bob <- #team hello" + alice <# "@bob <- #team" + alice <## " hello @alice @alice_1" + bob <# "alice> -> forwarded" + bob <## " hello @alice @alice_1" + testUniqueMsgMentions :: SpecWith TestParams testUniqueMsgMentions = do it "1 correct mention" $ \_ -> @@ -6702,4 +6771,30 @@ testUniqueMsgMentions = do uniqueMsgMentions 2 (mm [("alice", "abcd"), ("alice2", "abcd"), ("bob", "efgh"), ("bob2", "efgh")]) ["alice", "alice2", "bob", "bob2"] `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) where - mm = M.fromList . map (second $ MemberMention . MemberId) + mm = M.fromList . map (second $ MsgMention . MemberId) + +testUpdatedMentionNames :: SpecWith TestParams +testUpdatedMentionNames = do + it "keep mentions" $ \_ -> do + test (mm [("alice", Just "alice"), ("bob", Nothing)]) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + test (mm [("alice_1", Just "alice"), ("alice", Just "alice")]) "hello @alice @alice_1" + `shouldBe` "hello @alice @alice_1" + it "keep non-mentions" $ \_ -> do + test (mm []) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + test (mm [("alice", Just "alice")]) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + it "replace changed names" $ \_ -> do + test (mm [("alice", Just "Alice Jones"), ("bob", Just "robert")]) "hello @alice @bob" + `shouldBe` "hello @'Alice Jones' @robert" + 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 + in msgContentText mc' + mm = M.fromList . map (second mentionedMember) + mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = memberInfo <$> name_} + where + memberInfo name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 3f3b5adbc2..335d514981 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -7,6 +7,7 @@ module MarkdownTests where import Data.List.NonEmpty (NonEmpty) import Data.Text (Text) +import qualified Data.Text as T import Simplex.Chat.Markdown import System.Console.ANSI.Types import Test.Hspec @@ -22,79 +23,99 @@ markdownTests = do textWithMentions multilineMarkdownList +infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>> + +(==>) :: Text -> Markdown -> Expectation +s ==> m = parseMarkdown s `shouldBe` m + +(<==) :: Text -> Markdown -> Expectation +s <== m = s <<== markdownToList m + +(<==>) :: Text -> Markdown -> Expectation +s <==> m = (s ==> m) >> (s <== m) + +(==>>) :: Text -> MarkdownList -> Expectation +s ==>> ft = parseMaybeMarkdownList s `shouldBe` Just ft + +(<<==) :: Text -> MarkdownList -> Expectation +s <<== ft = T.concat (map markdownText ft) `shouldBe` s + +(<<==>>) :: Text -> MarkdownList -> Expectation +s <<==>> ft = (s ==>> ft) >> (s <<== ft) + textFormat :: Spec textFormat = describe "text format (bold)" do it "correct markdown" do - parseMarkdown "this is *bold formatted* text" - `shouldBe` "this is " <> markdown Bold "bold formatted" <> " text" - parseMarkdown "*bold formatted* text" - `shouldBe` markdown Bold "bold formatted" <> " text" - parseMarkdown "this is *bold*" - `shouldBe` "this is " <> markdown Bold "bold" - parseMarkdown " *bold* text" - `shouldBe` " " <> markdown Bold "bold" <> " text" - parseMarkdown " *bold* text" - `shouldBe` " " <> markdown Bold "bold" <> " text" - parseMarkdown "this is *bold* " - `shouldBe` "this is " <> markdown Bold "bold" <> " " - parseMarkdown "this is *bold* " - `shouldBe` "this is " <> markdown Bold "bold" <> " " + "this is *bold formatted* text" + <==> "this is " <> markdown Bold "bold formatted" <> " text" + "*bold formatted* text" + <==> markdown Bold "bold formatted" <> " text" + "this is *bold*" + <==> "this is " <> markdown Bold "bold" + " *bold* text" + <==> " " <> markdown Bold "bold" <> " text" + " *bold* text" + <==> " " <> markdown Bold "bold" <> " text" + "this is *bold* " + <==> "this is " <> markdown Bold "bold" <> " " + "this is *bold* " + <==> "this is " <> markdown Bold "bold" <> " " it "ignored as markdown" do - parseMarkdown "this is * unformatted * text" - `shouldBe` "this is * unformatted * text" - parseMarkdown "this is *unformatted * text" - `shouldBe` "this is *unformatted * text" - parseMarkdown "this is * unformatted* text" - `shouldBe` "this is * unformatted* text" - parseMarkdown "this is **unformatted** text" - `shouldBe` "this is **unformatted** text" - parseMarkdown "this is*unformatted* text" - `shouldBe` "this is*unformatted* text" - parseMarkdown "this is *unformatted text" - `shouldBe` "this is *unformatted text" + "this is * unformatted * text" + <==> "this is * unformatted * text" + "this is *unformatted * text" + <==> "this is *unformatted * text" + "this is * unformatted* text" + <==> "this is * unformatted* text" + "this is **unformatted** text" + <==> "this is **unformatted** text" + "this is*unformatted* text" + <==> "this is*unformatted* text" + "this is *unformatted text" + <==> "this is *unformatted text" it "ignored internal markdown" do - parseMarkdown "this is *long _bold_ (not italic)* text" - `shouldBe` "this is " <> markdown Bold "long _bold_ (not italic)" <> " text" - parseMarkdown "snippet: `this is *bold text*`" - `shouldBe` "snippet: " <> markdown Snippet "this is *bold text*" + "this is *long _bold_ (not italic)* text" + <==> "this is " <> markdown Bold "long _bold_ (not italic)" <> " text" + "snippet: `this is *bold text*`" + <==> "snippet: " <> markdown Snippet "this is *bold text*" secretText :: Spec secretText = describe "secret text" do it "correct markdown" do - parseMarkdown "this is #black_secret# text" - `shouldBe` "this is " <> markdown Secret "black_secret" <> " text" - parseMarkdown "##black_secret### text" - `shouldBe` markdown Secret "#black_secret##" <> " text" - parseMarkdown "this is #black secret# text" - `shouldBe` "this is " <> markdown Secret "black secret" <> " text" - parseMarkdown "##black secret### text" - `shouldBe` markdown Secret "#black secret##" <> " text" - parseMarkdown "this is #secret#" - `shouldBe` "this is " <> markdown Secret "secret" - parseMarkdown " #secret# text" - `shouldBe` " " <> markdown Secret "secret" <> " text" - parseMarkdown " #secret# text" - `shouldBe` " " <> markdown Secret "secret" <> " text" - parseMarkdown "this is #secret# " - `shouldBe` "this is " <> markdown Secret "secret" <> " " - parseMarkdown "this is #secret# " - `shouldBe` "this is " <> markdown Secret "secret" <> " " + "this is #black_secret# text" + <==> "this is " <> markdown Secret "black_secret" <> " text" + "##black_secret### text" + <==> markdown Secret "#black_secret##" <> " text" + "this is #black secret# text" + <==> "this is " <> markdown Secret "black secret" <> " text" + "##black secret### text" + <==> markdown Secret "#black secret##" <> " text" + "this is #secret#" + <==> "this is " <> markdown Secret "secret" + " #secret# text" + <==> " " <> markdown Secret "secret" <> " text" + " #secret# text" + <==> " " <> markdown Secret "secret" <> " text" + "this is #secret# " + <==> "this is " <> markdown Secret "secret" <> " " + "this is #secret# " + <==> "this is " <> markdown Secret "secret" <> " " it "ignored as markdown" do - parseMarkdown "this is # unformatted # text" - `shouldBe` "this is # unformatted # text" - parseMarkdown "this is #unformatted # text" - `shouldBe` "this is #unformatted # text" - parseMarkdown "this is # unformatted# text" - `shouldBe` "this is # unformatted# text" - parseMarkdown "this is ## unformatted ## text" - `shouldBe` "this is ## unformatted ## text" - parseMarkdown "this is#unformatted# text" - `shouldBe` "this is#unformatted# text" - parseMarkdown "this is #unformatted text" - `shouldBe` "this is #unformatted text" + "this is # unformatted # text" + <==> "this is # unformatted # text" + "this is #unformatted # text" + <==> "this is #unformatted # text" + "this is # unformatted# text" + <==> "this is # unformatted# text" + "this is ## unformatted ## text" + <==> "this is ## unformatted ## text" + "this is#unformatted# text" + <==> "this is#unformatted# text" + "this is #unformatted text" + <==> "this is #unformatted text" it "ignored internal markdown" do - parseMarkdown "snippet: `this is #secret_text#`" - `shouldBe` "snippet: " <> markdown Snippet "this is #secret_text#" + "snippet: `this is #secret_text#`" + <==> "snippet: " <> markdown Snippet "this is #secret_text#" red :: Text -> Markdown red = markdown (colored Red) @@ -102,38 +123,38 @@ red = markdown (colored Red) textColor :: Spec textColor = describe "text color (red)" do it "correct markdown" do - parseMarkdown "this is !1 red color! text" - `shouldBe` "this is " <> red "red color" <> " text" - parseMarkdown "!1 red! text" - `shouldBe` red "red" <> " text" - parseMarkdown "this is !1 red!" - `shouldBe` "this is " <> red "red" - parseMarkdown " !1 red! text" - `shouldBe` " " <> red "red" <> " text" - parseMarkdown " !1 red! text" - `shouldBe` " " <> red "red" <> " text" - parseMarkdown "this is !1 red! " - `shouldBe` "this is " <> red "red" <> " " - parseMarkdown "this is !1 red! " - `shouldBe` "this is " <> red "red" <> " " + "this is !1 red color! text" + <==> "this is " <> red "red color" <> " text" + "!1 red! text" + <==> red "red" <> " text" + "this is !1 red!" + <==> "this is " <> red "red" + " !1 red! text" + <==> " " <> red "red" <> " text" + " !1 red! text" + <==> " " <> red "red" <> " text" + "this is !1 red! " + <==> "this is " <> red "red" <> " " + "this is !1 red! " + <==> "this is " <> red "red" <> " " it "ignored as markdown" do - parseMarkdown "this is !1 unformatted ! text" - `shouldBe` "this is !1 unformatted ! text" - parseMarkdown "this is !1 unformatted ! text" - `shouldBe` "this is !1 unformatted ! text" - parseMarkdown "this is !1 unformatted! text" - `shouldBe` "this is !1 unformatted! text" - -- parseMarkdown "this is !!1 unformatted!! text" - -- `shouldBe` "this is " <> "!!1" <> "unformatted!! text" - parseMarkdown "this is!1 unformatted! text" - `shouldBe` "this is!1 unformatted! text" - parseMarkdown "this is !1 unformatted text" - `shouldBe` "this is !1 unformatted text" + "this is !1 unformatted ! text" + <==> "this is !1 unformatted ! text" + "this is !1 unformatted ! text" + <==> "this is !1 unformatted ! text" + "this is !1 unformatted! text" + <==> "this is !1 unformatted! text" + -- "this is !!1 unformatted!! text" + -- <==> "this is " <> "!!1" <> "unformatted!! text" + "this is!1 unformatted! text" + <==> "this is!1 unformatted! text" + "this is !1 unformatted text" + <==> "this is !1 unformatted text" it "ignored internal markdown" do - parseMarkdown "this is !1 long *red* (not bold)! text" - `shouldBe` "this is " <> red "long *red* (not bold)" <> " text" - parseMarkdown "snippet: `this is !1 red text!`" - `shouldBe` "snippet: " <> markdown Snippet "this is !1 red text!" + "this is !1 long *red* (not bold)! text" + <==> "this is " <> red "long *red* (not bold)" <> " text" + "snippet: `this is !1 red text!`" + <==> "snippet: " <> markdown Snippet "this is !1 red text!" uri :: Text -> Markdown uri = Markdown $ Just Uri @@ -144,29 +165,31 @@ simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType textWithUri :: Spec textWithUri = describe "text with Uri" do it "correct markdown" do - parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat" - parseMarkdown "https://simplex.chat." `shouldBe` uri "https://simplex.chat" <> "." - parseMarkdown "https://simplex.chat, hello" `shouldBe` uri "https://simplex.chat" <> ", hello" - parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat" - parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat" - parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site" - parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat/" `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/" - parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat." `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "." - parseMarkdown "https://github.com/simplex-chat/ - SimpleX on GitHub" `shouldBe` uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" - -- parseMarkdown "SimpleX on GitHub (https://github.com/simplex-chat/)" `shouldBe` "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" - parseMarkdown "https://en.m.wikipedia.org/wiki/Servo_(software)" `shouldBe` uri "https://en.m.wikipedia.org/wiki/Servo_(software)" + "https://simplex.chat" <==> uri "https://simplex.chat" + "https://simplex.chat." <==> uri "https://simplex.chat" <> "." + "https://simplex.chat, hello" <==> uri "https://simplex.chat" <> ", hello" + "http://simplex.chat" <==> uri "http://simplex.chat" + "this is https://simplex.chat" <==> "this is " <> uri "https://simplex.chat" + "https://simplex.chat site" <==> uri "https://simplex.chat" <> " site" + "SimpleX on GitHub: https://github.com/simplex-chat/" <==> "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/" + "SimpleX on GitHub: https://github.com/simplex-chat." <==> "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "." + "https://github.com/simplex-chat/ - SimpleX on GitHub" <==> uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" + -- "SimpleX on GitHub (https://github.com/simplex-chat/)" <==> "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" + "https://en.m.wikipedia.org/wiki/Servo_(software)" <==> uri "https://en.m.wikipedia.org/wiki/Servo_(software)" it "ignored as markdown" do - parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat" - parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" + "_https://simplex.chat" <==> "_https://simplex.chat" + "this is _https://simplex.chat" <==> "this is _https://simplex.chat" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" - parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) - parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) - parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) + ("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) + ("simplex:" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) + ("https://example.com" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) let ct = "/contact#/?v=2&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" - parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) + ("https://simplex.chat" <> ct) <==> simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) + ("simplex:" <> ct) <==> simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("simplex:" <> ct) let gr = "/contact#/?v=2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" - parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + ("https://simplex.chat" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + ("simplex:" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("simplex:" <> gr) email :: Text -> Markdown email = Markdown $ Just Email @@ -174,17 +197,17 @@ email = Markdown $ Just Email textWithEmail :: Spec textWithEmail = describe "text with Email" do it "correct markdown" do - parseMarkdown "chat@simplex.chat" `shouldBe` email "chat@simplex.chat" - parseMarkdown "test chat@simplex.chat" `shouldBe` "test " <> email "chat@simplex.chat" - parseMarkdown "test chat+123@simplex.chat" `shouldBe` "test " <> email "chat+123@simplex.chat" - parseMarkdown "test chat.chat+123@simplex.chat" `shouldBe` "test " <> email "chat.chat+123@simplex.chat" - parseMarkdown "chat@simplex.chat test" `shouldBe` email "chat@simplex.chat" <> " test" - parseMarkdown "test1 chat@simplex.chat test2" `shouldBe` "test1 " <> email "chat@simplex.chat" <> " test2" + "chat@simplex.chat" <==> email "chat@simplex.chat" + "test chat@simplex.chat" <==> "test " <> email "chat@simplex.chat" + "test chat+123@simplex.chat" <==> "test " <> email "chat+123@simplex.chat" + "test chat.chat+123@simplex.chat" <==> "test " <> email "chat.chat+123@simplex.chat" + "chat@simplex.chat test" <==> email "chat@simplex.chat" <> " test" + "test1 chat@simplex.chat test2" <==> "test1 " <> email "chat@simplex.chat" <> " test2" it "ignored as markdown" do - parseMarkdown "chat @simplex.chat" `shouldBe` "chat " <> mention "simplex.chat" "@simplex.chat" - parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat " <> mention "simplex.chat" "@simplex.chat" - parseMarkdown "this is chat@ simplex.chat" `shouldBe` "this is chat@ simplex.chat" - parseMarkdown "this is chat @ simplex.chat" `shouldBe` "this is chat @ simplex.chat" + "chat @simplex.chat" <==> "chat " <> mention "simplex.chat" "@simplex.chat" + "this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat" + "this is chat@ simplex.chat" <==> "this is chat@ simplex.chat" + "this is chat @ simplex.chat" <==> "this is chat @ simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -192,20 +215,20 @@ phone = Markdown $ Just Phone textWithPhone :: Spec textWithPhone = describe "text with Phone" do it "correct markdown" do - parseMarkdown "07777777777" `shouldBe` phone "07777777777" - parseMarkdown "test 07777777777" `shouldBe` "test " <> phone "07777777777" - parseMarkdown "07777777777 test" `shouldBe` phone "07777777777" <> " test" - parseMarkdown "test1 07777777777 test2" `shouldBe` "test1 " <> phone "07777777777" <> " test2" - parseMarkdown "test 07777 777 777 test" `shouldBe` "test " <> phone "07777 777 777" <> " test" - parseMarkdown "test +447777777777 test" `shouldBe` "test " <> phone "+447777777777" <> " test" - parseMarkdown "test +44 (0) 7777 777 777 test" `shouldBe` "test " <> phone "+44 (0) 7777 777 777" <> " test" - parseMarkdown "test +44-7777-777-777 test" `shouldBe` "test " <> phone "+44-7777-777-777" <> " test" - parseMarkdown "test +44 (0) 7777.777.777 https://simplex.chat test" - `shouldBe` "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" + "07777777777" <==> phone "07777777777" + "test 07777777777" <==> "test " <> phone "07777777777" + "07777777777 test" <==> phone "07777777777" <> " test" + "test1 07777777777 test2" <==> "test1 " <> phone "07777777777" <> " test2" + "test 07777 777 777 test" <==> "test " <> phone "07777 777 777" <> " test" + "test +447777777777 test" <==> "test " <> phone "+447777777777" <> " test" + "test +44 (0) 7777 777 777 test" <==> "test " <> phone "+44 (0) 7777 777 777" <> " test" + "test +44-7777-777-777 test" <==> "test " <> phone "+44-7777-777-777" <> " test" + "test +44 (0) 7777.777.777 https://simplex.chat test" + <==> "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" it "ignored as markdown (too short)" $ - parseMarkdown "test 077777 test" `shouldBe` "test 077777 test" + "test 077777 test" <==> "test 077777 test" it "ignored as markdown (double spaces)" $ - parseMarkdown "test 07777 777 777 test" `shouldBe` "test 07777 777 777 test" + "test 07777 777 777 test" <==> "test 07777 777 777 test" mention :: Text -> Text -> Markdown mention = Markdown . Just . Mention @@ -213,14 +236,14 @@ mention = Markdown . Just . Mention textWithMentions :: Spec textWithMentions = describe "text with mentions" do it "correct markdown" do - parseMarkdown "@alice" `shouldBe` mention "alice" "@alice" - parseMarkdown "hello @alice" `shouldBe` "hello " <> mention "alice" "@alice" - parseMarkdown "hello @alice !" `shouldBe` "hello " <> mention "alice" "@alice" <> " !" - parseMarkdown "@'alice jones'" `shouldBe` mention "alice jones" "@'alice jones'" - parseMarkdown "hello @'alice jones'!" `shouldBe` "hello " <> mention "alice jones" "@'alice jones'" <> "!" + "@alice" <==> mention "alice" "@alice" + "hello @alice" <==> "hello " <> mention "alice" "@alice" + "hello @alice !" <==> "hello " <> mention "alice" "@alice" <> " !" + "@'alice jones'" <==> mention "alice jones" "@'alice jones'" + "hello @'alice jones'!" <==> "hello " <> mention "alice jones" "@'alice jones'" <> "!" it "ignored as markdown" $ do - parseMarkdown "hello @'alice jones!" `shouldBe` "hello @'alice jones!" - parseMarkdown "hello @ alice!" `shouldBe` "hello @ alice!" + "hello @'alice jones!" <==> "hello @'alice jones!" + "hello @ alice!" <==> "hello @ alice!" uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri @@ -228,15 +251,15 @@ uri' = FormattedText $ Just Uri multilineMarkdownList :: Spec multilineMarkdownList = describe "multiline markdown" do it "correct markdown" do - parseMaybeMarkdownList "http://simplex.chat\nhttp://app.simplex.chat" `shouldBe` Just [uri' "http://simplex.chat", "\n", uri' "http://app.simplex.chat"] + "http://simplex.chat\nhttp://app.simplex.chat" <<==>> [uri' "http://simplex.chat", "\n", uri' "http://app.simplex.chat"] it "combines the same formats" do - parseMaybeMarkdownList "http://simplex.chat\ntext 1\ntext 2\nhttp://app.simplex.chat" `shouldBe` Just [uri' "http://simplex.chat", "\ntext 1\ntext 2\n", uri' "http://app.simplex.chat"] + "http://simplex.chat\ntext 1\ntext 2\nhttp://app.simplex.chat" <<==>> [uri' "http://simplex.chat", "\ntext 1\ntext 2\n", uri' "http://app.simplex.chat"] it "no markdown" do parseMaybeMarkdownList "not a\nmarkdown" `shouldBe` Nothing let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" it "multiline with simplex link" do - parseMaybeMarkdownList ("https://simplex.chat" <> inv <> "\ntext") - `shouldBe` Just + ("https://simplex.chat" <> inv <> "\ntext") + <<==>> [ FormattedText (Just $ SimplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), "\ntext" ] From 3bc822a1e967a9191d9bd5af317710a9b855ebaf Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 30 Jan 2025 17:59:21 +0000 Subject: [PATCH 018/289] core: mentions in history, unread mentions in stats (#5594) * core: mentions in history, unread mentions in stats * fix * update plans --- src/Simplex/Chat/Library/Subscriber.hs | 8 +- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Store/Messages.hs | 37 ++++---- .../SQLite/Migrations/M20250126_mentions.hs | 4 + .../SQLite/Migrations/chat_query_plans.txt | 91 +++++++++++-------- .../Store/SQLite/Migrations/chat_schema.sql | 6 ++ tests/ChatTests/Groups.hs | 44 ++++++++- 7 files changed, 131 insertions(+), 62 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a8a3af5252..2682d550b7 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -894,16 +894,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = in Just (fInv, fileDescrText) | otherwise = Nothing processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] - processContentItem sender ChatItem {meta, quotedItem} mc fInvDescr_ = + processContentItem sender ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = if isNothing fInvDescr_ && not (msgContentHasText mc) then pure [] else do let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ - -- TODO [mentions] history? - -- let (_t, ft_) = msgContentTexts mc - (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc M.empty quotedItemId_ Nothing fInv_ itemTimed False + (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 let senderVRange = memberChatVRange' sender xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index ea2f94ad99..5562f016c9 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -320,6 +320,7 @@ deriving instance Show AChat 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 minUnreadItemId :: ChatItemId, unreadChat :: Bool @@ -327,7 +328,7 @@ data ChatStats = ChatStats deriving (Show) emptyChatStats :: ChatStats -emptyChatStats = ChatStats 0 0 0 False +emptyChatStats = ChatStats 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 339e50140c..b9a227b85c 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -570,7 +570,12 @@ data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreview type ChatStatsRow = (Int, Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat} +toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, unreadMentions = 0, reportsCount, minUnreadItemId, unreadChat} + +type GroupStatsRow = (Int, Int, Int, ChatItemId, BoolInt) + +toGroupStats :: GroupStatsRow -> ChatStats +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 = @@ -674,9 +679,9 @@ findGroupChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQ findGroupChatPreviews_ db User {userId} pagination clq = map toPreview <$> getPreviews where - toPreview :: (GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow -> AChatPreviewData + toPreview :: (GroupId, UTCTime, Maybe ChatItemId) :. GroupStatsRow -> AChatPreviewData toPreview ((groupId, ts, lastItemId_) :. statsRow) = - ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toChatStats statsRow) + ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toGroupStats statsRow) baseQuery = [sql| SELECT @@ -690,12 +695,13 @@ findGroupChatPreviews_ db User {userId} pagination clq = LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -761,7 +767,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = |] p = baseParams :. (userId, search, search, search, search) queryWithPagination q p - queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. GroupStatsRow] queryWithPagination query params = case pagination of PTLast count -> DB.query db (query <> " ORDER BY g.chat_ts DESC LIMIT ?") (params :. Only count) PTAfter ts count -> DB.query db (query <> " AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ?") (params :. (ts, count)) @@ -1353,19 +1359,19 @@ getGroupChatInitial_ db user g contentFilter count = 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 + stats <- getStats 0 (0, 0) (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" stats where - getStats minUnreadItemId unreadCount = do + getStats minUnreadItemId (unreadCount, unreadMentions) = do reportsCount <- getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = 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 <- getGroupUnreadCount_ db user g Nothing + (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g Nothing reportsCount <- getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Maybe ChatItemId) getGroupMinUnreadId_ db user g contentFilter = @@ -1375,11 +1381,11 @@ getGroupMinUnreadId_ db user g contentFilter = 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 +getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Int, Int) getGroupUnreadCount_ db user g contentFilter = - fromOnly . head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" + head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" where - baseQuery = "SELECT COUNT(1) 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 = ? " getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = @@ -3111,10 +3117,9 @@ getGroupSndStatusCounts db itemId = (Only itemId) getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> GroupMember -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] -getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do +getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do ciIds <- getLastItemIds_ - -- use getGroupCIWithReactions to read reactions data - reverse <$> mapM (runExceptT . getGroupChatItem db user groupId) ciIds + reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds where getLastItemIds_ :: IO [ChatItemId] getLastItemIds_ = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs index f5f5e24fe0..a920c17859 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs @@ -22,11 +22,15 @@ CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); + +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items(user_id, group_id, item_status, user_mention); |] down_m20250126_mentions :: Query down_m20250126_mentions = [sql| +DROP INDEX idx_chat_items_groups_user_mention; + DROP INDEX idx_chat_item_mentions_group_id; DROP INDEX idx_chat_item_mentions_chat_item_id; DROP INDEX idx_chat_item_mentions_display_name; 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 f9c19d31b5..acec82aaf4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -157,7 +157,7 @@ Query: WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) SEARCH g USING INTEGER PRIMARY KEY (rowid=?) Query: @@ -480,7 +480,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -491,7 +491,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -985,7 +985,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1005,7 +1005,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1151,7 +1151,7 @@ Query: LIMIT 1 Plan: -SEARCH i USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH i USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INTEGER PRIMARY KEY (rowid=?) USE TEMP B-TREE FOR ORDER BY @@ -1920,12 +1920,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -1949,7 +1950,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -1971,12 +1972,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -1995,7 +1997,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2016,12 +2018,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -2039,7 +2042,7 @@ Query: 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_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) 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_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) @@ -2104,12 +2108,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -2127,7 +2132,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2148,12 +2153,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -2171,7 +2177,7 @@ Query: 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_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) 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_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) @@ -2236,12 +2243,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -2259,7 +2267,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2280,12 +2288,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -2300,7 +2309,7 @@ Query: 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_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) 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_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) @@ -2362,12 +2372,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + 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 = ? GROUP BY group_id @@ -2382,7 +2393,7 @@ Query: 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_id=? AND group_id>?) +SEARCH chat_items USING COVERING 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>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2930,7 +2941,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -4080,7 +4091,7 @@ Query: WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups (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 group_snd_item_status, COUNT(1) @@ -4171,7 +4182,7 @@ Query: WHERE user_id = ? AND group_id = ? AND item_status = ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups (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: UPDATE chat_items SET item_status = ?, updated_at = ? @@ -4666,7 +4677,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_history (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -4693,7 +4704,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.group_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5013,7 +5024,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_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=?) @@ -5198,7 +5209,7 @@ SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_grou SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group_id (group_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_group_id (fwd_from_group_id=?) -SCAN chat_items USING COVERING INDEX idx_chat_items_groups_item_ts +SCAN chat_items USING COVERING INDEX idx_chat_items_groups_user_mention SEARCH messages USING COVERING INDEX idx_messages_group_id (group_id=?) SEARCH user_contact_links USING COVERING INDEX idx_user_contact_links_group_id (group_id=?) SEARCH files USING COVERING INDEX idx_files_group_id (group_id=?) @@ -5310,7 +5321,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_history (user_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (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=?) @@ -5449,10 +5460,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 item_status = ? -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (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=?) @@ -5465,6 +5472,10 @@ 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 = ? +Plan: +SEARCH chat_items USING COVERING 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: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index b1bdecd1e4..58374fe51c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1011,3 +1011,9 @@ CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions( chat_item_id, member_id ); +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( + user_id, + group_id, + item_status, + user_mention +); diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index dccd311f59..ee957d3daa 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -191,8 +191,9 @@ chatGroupTests = do describe "group member reports" $ do it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports describe "group member mentions" $ do - it "should send messages with member mentions" testMemberMention + it "should send and edit messages with member mentions" testMemberMention it "should forward and quote message updating mentioned member name" testForwardQuoteMention + it "should send updated mentions in history" testGroupHistoryWithMentions describe "uniqueMsgMentions" testUniqueMsgMentions describe "updatedMentionNames" testUpdatedMentionNames where @@ -6686,6 +6687,12 @@ testMemberMention = [ alice <# "#team cath> hello @Alice", bob <# "#team cath> hello @Alice" ] + cath ##> "! #team hello @alice @bob" + cath <# "#team [edited] hello @alice @bob" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello @alice @bob", + bob <# "#team cath> [edited] hello @alice @bob" + ] testForwardQuoteMention :: HasCallStack => TestParams -> IO () testForwardQuoteMention = @@ -6753,6 +6760,41 @@ testForwardQuoteMention = bob <# "alice> -> forwarded" bob <## " hello @alice @alice_1" +testGroupHistoryWithMentions :: HasCallStack => TestParams -> IO () +testGroupHistoryWithMentions = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + + threadDelay 1000000 + + alice #> "#team hello @bob" + bob <# "#team alice!> hello @bob" + + bob ##> "/p robert" + bob <## "user profile is changed to robert (your 1 contacts are notified)" + alice <## "contact bob changed to robert" + alice <## "use @robert to send messages" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath joined the group", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + WithTime "#team alice> hello @robert [>>]", + "#team: member robert is connected" + ], + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + testUniqueMsgMentions :: SpecWith TestParams testUniqueMsgMentions = do it "1 correct mention" $ \_ -> From 5d18a49726d0358b0541476bef904713040fddab Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:32:07 +0400 Subject: [PATCH 019/289] core: delete unused group contacts, don't create new ones (#5590) * core: delete unused group contacts, don't create new ones * remove from exceptions * plans * fix tests * remove fixtures * update plans * update migration --------- Co-authored-by: Evgeny Poberezkin --- simplex-chat.cabal | 1 + src/Simplex/Chat/Controller.hs | 1 - src/Simplex/Chat/Library/Commands.hs | 22 +- src/Simplex/Chat/Library/Internal.hs | 6 +- src/Simplex/Chat/Library/Subscriber.hs | 49 +- src/Simplex/Chat/Store/Direct.hs | 14 +- src/Simplex/Chat/Store/Groups.hs | 55 +- src/Simplex/Chat/Store/Messages.hs | 3 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20250129_delete_unused_contacts.hs | 64 + .../SQLite/Migrations/chat_query_plans.txt | 207 +--- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/View.hs | 1 - tests/Bots/DirectoryTests.hs | 157 ++- tests/ChatClient.hs | 24 - tests/ChatTests/Direct.hs | 39 +- tests/ChatTests/Groups.hs | 1101 +++-------------- tests/ChatTests/Profiles.hs | 41 +- tests/ChatTests/Utils.hs | 7 + tests/SchemaDump.hs | 9 +- 20 files changed, 416 insertions(+), 1397 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 1df50de1a6..4511131dbf 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -222,6 +222,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions + Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 68c1bd2e92..a7a73eea0a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -765,7 +765,6 @@ data ChatResponse | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} - | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ea947180ff..4accff2471 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1095,26 +1095,7 @@ processChatCommand' vr = \case withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members withStore' $ \db -> deleteGroup db user gInfo - let contactIds = mapMaybe memberContactId members - (errs1, (errs2, connIds)) <- lift $ second unzip . partitionEithers <$> withStoreBatch (\db -> map (deleteUnusedContact db) contactIds) - let errs = errs1 <> mapMaybe (fmap ChatErrorStore) errs2 - unless (null errs) $ toView $ CRChatErrors (Just user) errs - deleteAgentConnectionsAsync user $ concat connIds pure $ CRGroupDeletedUser user gInfo - where - deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId])) - deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do - ct <- getContact db vr user contactId - ifM - ((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct)) - (pure (Nothing, [])) - (getConnections ct) - where - getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId]) - getConnections ct = do - conns <- liftIO $ getContactConnections db vr userId ct - e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just) - pure (e_, map aConnId conns) 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 @@ -2986,10 +2967,9 @@ processChatCommand' vr = \case sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs - ct@Contact {contactUsed} <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db vr user contactId assertDirectAllowed user MDSnd ct XMsgNew_ assertVoiceAllowed ct - unless contactUsed $ withFastStore' $ \db -> updateContactUsed db user ct processComposedMessages ct where assertVoiceAllowed :: Contact -> CM () diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index e61a7795e4..8cdad6cd60 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -797,15 +797,15 @@ acceptContactRequest user@User {userId} UserContactRequest {agentInvitationId = dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend (ct,conn,) <$> withAgent (\a -> acceptContact a (aConnId conn) True invId dm pqSup' subMode) -acceptContactRequestAsync :: User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> CM Contact -acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do +acceptContactRequestAsync :: User -> UserContactRequest -> Maybe IncognitoProfile -> PQSupport -> CM Contact +acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile pqSup = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False vr <- chatVersionRange let chatV = vr `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup chatV withStore' $ \db -> do - (ct, Connection {connId}) <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed + (ct, Connection {connId}) <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup True deleteContactRequestRec db user cReq setCommandConnId db user cmdId connId pure ct diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2682d550b7..cb9cb1ed11 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -584,7 +584,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = lift $ setContactNetworkStatus ct' NSConnected toView $ CRContactConnected user ct' (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct') $ do - unless (contactUsed ct') $ withFastStore' $ \db -> updateContactUsed db user ct' createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo pqEnc) Nothing createFeatureEnabledItems ct' when (contactConnInitiated conn') $ do @@ -697,7 +696,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] XGrpMemIntro continuation on receiving INV CFCreateConnGrpMemInv | maxVersion (peerChatVRange conn) >= groupDirectInvVersion -> sendWithoutDirectCReq - | otherwise -> sendWithDirectCReq + | otherwise -> messageError "processGroupMessage INV: member chat version range incompatible" where sendWithoutDirectCReq = do let GroupMember {groupMemberId, memberId} = m @@ -705,13 +704,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = liftIO $ setConnConnReqInv db user connId cReq getHostConnId db user groupId sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} - sendWithDirectCReq = do - let GroupMember {groupMemberId, memberId} = m - contData <- withStore' $ \db -> do - setConnConnReqInv db user connId cReq - getXGrpMemIntroContGroup db user m - forM_ contData $ \(hostConnId, directConnReq) -> - sendXGrpMemInv hostConnId (Just directConnReq) XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do ct <- withStore $ \db -> getContactViaMember db vr user m @@ -1310,9 +1302,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case autoAccept of Just AutoAccept {acceptIncognito, businessAddress} | businessAddress -> - if v < groupFastLinkJoinVersion || (isSimplexTeam && v < businessChatsVersion) + if isSimplexTeam && v < businessChatsVersion then do - ct <- acceptContactRequestAsync user cReq Nothing True reqPQSup + ct <- acceptContactRequestAsync user cReq Nothing reqPQSup toView $ CRAcceptingContactRequest user ct else do gInfo <- acceptBusinessJoinRequestAsync user cReq @@ -1321,7 +1313,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequestAsync user cReq incognitoProfile True reqPQSup + ct <- acceptContactRequestAsync user cReq incognitoProfile reqPQSup toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId @@ -1331,10 +1323,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else do - -- TODO v5.7 remove old API (or v6.0?) - ct <- acceptContactRequestAsync user cReq profileMode False PQSupportOff - toView $ CRAcceptingGroupJoinRequest user gInfo ct + else messageError "processUserContactRequest: chat version range incompatible for accepting group join request" _ -> toView $ CRReceivedContactRequest user cReq memberCanSend :: GroupMember -> CM () -> CM () @@ -1540,8 +1529,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () - newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do - unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct + newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do let ExtMsgContent content _ fInv_ _ _ = mcExtMsgContent mc -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete -- case content of @@ -1747,7 +1735,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ExtMsgContent content mentions fInv_ itemTTL live_ = mcExtMsgContent mc ts@(_, ft_) = msgContentTexts content createBlockedByAdmin - | groupFeatureAllowed SGFFullDelete gInfo = do -- ignores member role when blocked by admin + | groupFeatureAllowed SGFFullDelete gInfo = do + -- ignores member role when blocked by admin ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs groupMsgToView gInfo ci' @@ -2466,8 +2455,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - let contactUsed = connDirect activeConn - ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed + ct <- withStore $ \db -> createDirectContact db user conn' p toView $ CRContactConnecting user ct pure (conn', False) XGrpLinkInv glInv -> do @@ -2505,17 +2493,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right _ -> messageError "x.grp.mem.intro ignored: member already exists" Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) - 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 - directConnIds <- case memChatVRange of - Nothing -> Just <$> createConn subMode + case memChatVRange of + Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" Just (ChatVersionRange mcvr) - | maxVersion mcvr >= groupDirectInvVersion -> pure Nothing - | otherwise -> Just <$> createConn subMode - let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange - void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode + | maxVersion mcvr >= groupDirectInvVersion -> do + subMode <- chatReadVar subscriptionMode + -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second + groupConnIds <- createConn subMode + let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds subMode + | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" where createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 44ee662c75..98d7374769 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -46,7 +46,6 @@ module Simplex.Chat.Store.Direct updateContactConnectionAlias, updatePCCIncognito, deletePCCIncognitoProfile, - updateContactUsed, updateContactUnreadChat, setUserChatsRead, updateContactStatus, @@ -240,10 +239,10 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createDirectContact :: DB.Connection -> User -> Connection -> Profile -> Bool -> ExceptT StoreError IO Contact -createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} contactUsed = do +createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact +createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do currentTs <- liftIO getCurrentTime - (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs contactUsed + (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs @@ -255,7 +254,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p profile, activeConn = Just conn, viaGroup = Nothing, - contactUsed, + contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, @@ -471,11 +470,6 @@ deletePCCIncognitoProfile db User {userId} profileId = |] (userId, profileId) -updateContactUsed :: DB.Connection -> User -> Contact -> IO () -updateContactUsed db User {userId} Contact {contactId} = do - updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (updatedAt, userId, contactId) - updateContactUnreadChat :: DB.Connection -> User -> Contact -> Bool -> IO () updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 1b73a5b761..b97c28a510 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -110,7 +110,6 @@ module Simplex.Chat.Store.Groups updateGroupMemberSettings, updateGroupMemberBlocked, getXGrpMemIntroContDirect, - getXGrpMemIntroContGroup, getHostConnId, createMemberContact, getMemberContact, @@ -1397,33 +1396,23 @@ getForwardInvitedMembers db vr user forwardMember highlyAvailable = do WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db user@User {userId} - gInfo@GroupInfo {groupId} + gInfo _host@GroupMember {memberContactId, activeConn} chatV memInfo@(MemberInfo _ _ memChatVRange memberProfile) memRestrictions_ (groupCmdId, groupAgentConnId) - directConnIds - customUserProfileId subMode = do let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn memRestriction = restriction <$> memRestrictions_ currentTs <- liftIO getCurrentTime - newMember <- case directConnIds of - Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId ConnNew chatV mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff - liftIO $ setCommandConnId db user directCmdId directConnId - (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs False - liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId) - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Just contactId, memProfileId} - Nothing -> do - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode @@ -1850,12 +1839,6 @@ mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keep assertNotUser db user fromCt liftIO $ do currentTs <- getCurrentTime - -- next query fixes incorrect unused contacts deletion - when (contactDirect toCt && not (contactUsed toCt)) $ - DB.execute - db - "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" - (currentTs, userId, toContactId) DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" @@ -2061,36 +2044,6 @@ getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do Just groupConnReq -> Just (hostConnId, XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq}) _ -> Nothing -getXGrpMemIntroContGroup :: DB.Connection -> User -> GroupMember -> IO (Maybe (Int64, ConnReqInvitation)) -getXGrpMemIntroContGroup db User {userId} GroupMember {groupMemberId} = do - fmap join . maybeFirstRow toCont $ - DB.query - db - [sql| - SELECT ch.connection_id, c.conn_req_inv - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 - |] - (userId, userId, groupMemberId, GCHostMember) - where - toCont :: (Int64, Maybe ConnReqInvitation) -> Maybe (Int64, ConnReqInvitation) - toCont (hostConnId, connReq_) = case connReq_ of - Just connReq -> Just (hostConnId, connReq) - _ -> Nothing - getHostConnId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostConnId db user@User {userId} groupId = do hostMemberId <- getHostMemberId_ db user groupId diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b9a227b85c..f8acae4a91 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2562,8 +2562,7 @@ getGroupChatItemIdByText db User {userId, localDisplayName = userName} groupId c SELECT i.chat_item_id FROM chat_items i JOIN group_members m ON m.group_member_id = i.group_member_id - JOIN contacts c ON c.contact_id = m.contact_id - WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + WHERE i.user_id = ? AND i.group_id = ? AND m.local_display_name = ? AND i.item_text like ? ORDER BY i.chat_item_id DESC LIMIT 1 |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 29dab148dc..00d90ebf82 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -126,6 +126,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history import Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions +import Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -251,7 +252,8 @@ schemaMigrations = ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), ("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) + ("20250126_mentions", m20250126_mentions, Just down_m20250126_mentions), + ("20250129_delete_unused_contacts", m20250129_delete_unused_contacts, Just down_m20250129_delete_unused_contacts) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs new file mode 100644 index 0000000000..4ce7a0a793 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs @@ -0,0 +1,64 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250129_delete_unused_contacts :: Query +m20250129_delete_unused_contacts = + [sql| +CREATE TEMPORARY TABLE temp_delete_contacts ( + contact_id INTEGER PRIMARY KEY, + contact_profile_id INTEGER NOT NULL, + local_display_name TEXT NOT NULL +); + +INSERT INTO temp_delete_contacts(contact_id, contact_profile_id, local_display_name) +SELECT contact_id, contact_profile_id, local_display_name +FROM contacts +WHERE contact_used = 0 AND is_user = 0 + AND contact_id NOT IN (SELECT contact_id FROM users) + AND contact_id NOT IN (SELECT contact_id FROM contact_requests); + +CREATE TEMPORARY TABLE temp_delete_profiles (contact_profile_id INTEGER PRIMARY KEY); + +INSERT OR IGNORE INTO temp_delete_profiles(contact_profile_id) +SELECT custom_user_profile_id FROM connections +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts) + AND custom_user_profile_id IS NOT NULL; + +UPDATE group_members SET contact_id = NULL +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM connections +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM contacts +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM contact_profiles +WHERE + (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_profiles) + OR contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_contacts)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections); + +DELETE FROM display_names +WHERE local_display_name IN (SELECT local_display_name FROM temp_delete_contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests); + +DROP TABLE temp_delete_contacts; +DROP TABLE temp_delete_profiles; +|] + +down_m20250129_delete_unused_contacts :: Query +down_m20250129_delete_unused_contacts = + [sql| +|] 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 acec82aaf4..411878f34c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -646,30 +646,6 @@ Query: Plan: -Query: - INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at, chat_ts) - SELECT contact_profile_id, group_id, ?, ?, ?, ?, ? - FROM group_members - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_contact_id (contact_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_contact_id (contact_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_contact_id (contact_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) -SEARCH calls USING COVERING INDEX idx_calls_contact_id (contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_contact_id (fwd_from_contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_contact_id (contact_id=?) -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_via_contact (via_contact=?) -SEARCH files USING COVERING INDEX idx_files_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by (invited_by=?) -SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) - Query: INSERT INTO group_members ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, @@ -1145,15 +1121,13 @@ Query: SELECT i.chat_item_id FROM chat_items i JOIN group_members m ON m.group_member_id = i.group_member_id - JOIN contacts c ON c.contact_id = m.contact_id - WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + WHERE i.user_id = ? AND i.group_id = ? AND m.local_display_name = ? AND i.item_text like ? ORDER BY i.chat_item_id DESC LIMIT 1 Plan: SEARCH i USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1227,14 +1201,6 @@ Query: Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET contact_id = ?, updated_at = ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_members SET contact_id = ?, updated_at = ? @@ -2787,66 +2753,6 @@ Plan: SEARCH c USING INDEX idx_connections_updated_at (user_id=?) SEARCH uc USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT ch.connection_id, c.conn_req_inv - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 - -Plan: -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -SEARCH ct 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_contact_id (contact_id=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 2 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - -Query: - SELECT ch.connection_id, g.group_id, m.group_member_id, m.member_id, c.conn_req_inv - FROM contacts ct - JOIN group_members m ON m.contact_id = ct.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.group_member_id = m.group_member_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ? - -Plan: -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc -SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 2 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - Query: SELECT chat_item_id FROM chat_item_messages @@ -3112,30 +3018,6 @@ Plan: SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT ct.contact_id - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 - AND p.display_name = ? AND p.full_name = ? - AND p.image = ? -Plan: -SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT ct.contact_id - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 - AND p.display_name = ? AND p.full_name = ? - AND p.image is NULL -Plan: -SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT d.file_descr_id, d.file_descr_text, d.file_descr_part_no, d.file_descr_complete FROM xftp_file_descriptions d @@ -3269,18 +3151,6 @@ Plan: SEARCH m USING INDEX idx_group_members_user_id (user_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT m.member_role, gp.preferences - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members m USING (group_id) - WHERE g.user_id = ? AND m.contact_id = ? - -Plan: -SEARCH m USING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT pgm.message_id, m.shared_msg_id, m.msg_body, m.chat_msg_event, pgm.group_member_intro_id FROM pending_group_messages pgm @@ -3482,22 +3352,6 @@ Query: Plan: SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET contact_id = ?, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), - updated_at = ? - WHERE contact_id = ? - AND user_id = ? - -Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) -SCALAR SUBQUERY 1 -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -SCALAR SUBQUERY 2 -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_members SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -5092,25 +4946,6 @@ Query: DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) -Query: DELETE FROM contacts WHERE contact_id = ? AND user_id = ? -Plan: -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_contact_id (contact_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_contact_id (contact_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_contact_id (contact_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) -SEARCH calls USING COVERING INDEX idx_calls_contact_id (contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_contact_id (fwd_from_contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_contact_id (contact_id=?) -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_via_contact (via_contact=?) -SEARCH files USING COVERING INDEX idx_files_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by (invited_by=?) -SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) - Query: DELETE FROM contacts WHERE user_id = ? AND contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) @@ -5596,10 +5431,6 @@ Query: SELECT group_id FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 -Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) - Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) @@ -5620,10 +5451,6 @@ Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contac Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 -Plan: -SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) - Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) @@ -5684,10 +5511,6 @@ Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) - Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) @@ -5740,10 +5563,6 @@ Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? -Plan: -SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) - Query: UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -5756,10 +5575,6 @@ Query: UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1 Plan: SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) -Query: UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ? -Plan: -SEARCH connections USING INDEX idx_connections_via_contact (via_contact=?) - Query: UPDATE contact_requests SET contact_id = ? WHERE user_id = ? AND local_display_name = ? Plan: SEARCH contact_requests USING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) @@ -5780,18 +5595,10 @@ Query: UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contac Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? -Plan: -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contacts SET contact_used = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? -Plan: -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) @@ -5864,10 +5671,6 @@ Query: UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) -Query: UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ? -Plan: -SEARCH group_members USING INDEX idx_group_members_invited_by (invited_by=?) - Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) @@ -5884,10 +5687,6 @@ 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 sent_inv_queue_info = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -5980,10 +5779,6 @@ Query: UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ? Plan: SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ? -Plan: -SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE users SET active_user = 0 Plan: SCAN users diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 5a50ae25d3..6688f4cae6 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -381,10 +381,10 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () createContact db User {userId} profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db userId profile "" Nothing currentTs True + void $ createContact_ db userId profile "" Nothing currentTs -createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> Bool -> ExceptT StoreError IO (Text, ContactId, ProfileId) -createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs contactUsed = +createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId) +createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -394,7 +394,7 @@ createContact_ db userId Profile {displayName, fullName, image, contactLink, pre DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used) VALUES (?,?,?,?,?,?,?,?)" - (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI contactUsed) + (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI True) contactId <- insertedRowId db pure $ Right (ldn, contactId, profileId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 445ec0b7c0..69f9799948 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -315,7 +315,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g - CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 12de4cf742..6994a8528a 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -51,10 +51,10 @@ directoryServiceTests = do it "should NOT allow approving if roles are incorrect" testNotApprovedBadRoles describe "should require re-approval if profile is changed by" $ do it "the registration owner" testRegOwnerChangedProfile - it "another owner" testAnotherOwnerChangedProfile + it "another owner" testAnotherOwnerChangedProfile -- TODO fix - doesn't work if another owner is not connected as contact describe "should require profile update if group link is removed by " $ do it "the registration owner" testRegOwnerRemovedLink - it "another owner" testAnotherOwnerRemovedLink + it "another owner" testAnotherOwnerRemovedLink -- TODO fix - doesn't work if another owner is not connected as contact describe "duplicate groups (same display name and full name)" $ do it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration @@ -230,7 +230,7 @@ testSuspendResume ps = bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!" groupFound bob "privacy" superUser #> "@SimpleX-Directory privacy" - groupFoundN_ (Just 1) 2 superUser "privacy" + groupFoundN_ "" (Just 1) 2 superUser "privacy" superUser #> "@SimpleX-Directory /link 1:privacy" superUser <# "SimpleX-Directory> > /link 1:privacy" superUser <## " The link to join the group ID 1 (privacy):" @@ -284,10 +284,10 @@ testSetRole ps = testJoinGroup :: HasCallStack => TestParams -> IO () testJoinGroup ps = - withDirectoryServiceCfg ps testCfgGroupLinkViaContact $ \superUser dsLink -> - withNewTestChatCfg ps testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do - withNewTestChatCfg ps testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> - withNewTestChatCfg ps testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -301,10 +301,10 @@ testJoinGroup ps = cath <## "2 members" cath ##> ("/c " <> groupLink) cath <## "connection request sent!" - cath <## "SimpleX-Directory_1: contact is connected" - cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" + cath <## "use @SimpleX-Directory to send messages" cath <# ("#privacy SimpleX-Directory> " <> welcomeMsg) cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" @@ -316,11 +316,9 @@ testJoinGroup ps = concurrentlyN_ [ do bob <## "dan (Daniel): accepting request to join group #privacy..." - bob <## "dan (Daniel): contact is connected" - bob <## "dan invited to group #privacy via your group link" bob <## "#privacy: dan joined the group", do - dan <## "bob (Bob): contact is connected" + dan <## "#privacy: joining the group..." dan <## "#privacy: you joined the group" dan <# ("#privacy bob> " <> welcomeMsg) dan @@ -456,9 +454,9 @@ testInviteToOwnersGroup ps = testDelistedOwnerLeaves :: HasCallStack => TestParams -> IO () testDelistedOwnerLeaves ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -468,13 +466,16 @@ testDelistedOwnerLeaves ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () testDelistedOwnerRemoved ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -483,38 +484,45 @@ testDelistedOwnerRemoved ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () testNotDelistedMemberLeaves ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath leaveGroup "privacy" cath bob <## "#privacy: cath left the group" (superUser "@SimpleX-Directory_1 privacy" + groupFoundN_ "_1" Nothing 2 cath "privacy" testNotDelistedMemberRemoved :: HasCallStack => TestParams -> IO () testNotDelistedMemberRemoved ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath removeMember "privacy" bob cath (superUser "@SimpleX-Directory_1 privacy" + groupFoundN_ "_1" Nothing 2 cath "privacy" testDelistedServiceRemoved :: HasCallStack => TestParams -> IO () testDelistedServiceRemoved ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -525,7 +533,8 @@ testDelistedServiceRemoved ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." - groupNotFound cath "privacy" + cath `connectVia` dsLink + groupNotFound_ "_1" cath "privacy" testDelistedGroupDeleted :: HasCallStack => TestParams -> IO () testDelistedGroupDeleted ps = @@ -553,12 +562,15 @@ testDelistedGroupDeleted ps = testDelistedRoleChanges :: HasCallStack => TestParams -> IO () testDelistedRoleChanges ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy SimpleX-Directory member" @@ -599,12 +611,15 @@ testDelistedRoleChanges ps = testNotDelistedMemberRoleChanged :: HasCallStack => TestParams -> IO () testNotDelistedMemberRoleChanged ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" bob <## "#privacy: you changed the role of cath from owner to member" @@ -663,9 +678,9 @@ testNotApprovedBadRoles ps = testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () testRegOwnerChangedProfile ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -675,6 +690,9 @@ testRegOwnerChangedProfile ps = bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy:" cath <## "full name changed to: Privacy and Security" + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." reapproveGroup 3 superUser bob @@ -682,12 +700,15 @@ testRegOwnerChangedProfile ps = testAnotherOwnerChangedProfile :: HasCallStack => TestParams -> IO () testAnotherOwnerChangedProfile ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" cath ##> "/gp privacy privacy Privacy and Security" cath <## "full name changed to: Privacy and Security" bob <## "cath updated group #privacy:" @@ -701,9 +722,9 @@ testAnotherOwnerChangedProfile ps = testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () testRegOwnerRemovedLink ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -720,6 +741,9 @@ testRegOwnerRemovedLink ps = cath <## "description changed to:" cath <## "Welcome!" superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" bob ##> ("/set welcome #privacy " <> welcomeWithLink) bob <## "description changed to:" @@ -734,12 +758,15 @@ testRegOwnerRemovedLink ps = testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () testAnotherOwnerRemovedLink ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" bob ##> "/show welcome #privacy" bob <## "Welcome message:" welcomeWithLink <- getTermLine bob @@ -883,9 +910,9 @@ testDuplicateProhibitApproval ps = testListUserGroups :: HasCallStack => TestParams -> IO () testListUserGroups ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -893,7 +920,7 @@ testListUserGroups ps = fullAddMember "privacy" "Privacy" bob cath GRMember joinGroup "privacy" cath bob cath <## "#privacy: member SimpleX-Directory_1 is connected" - cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" cath <## "use @SimpleX-Directory to send messages" registerGroupId superUser bob "security" "Security" 2 2 registerGroupId superUser cath "anonymity" "Anonymity" 3 1 @@ -937,7 +964,6 @@ testRestoreDirectory ps = do groupFound bob "security" groupFoundN 3 cath "privacy" cath #> "@SimpleX-Directory security" - cath <## "SimpleX-Directory: quantum resistant end-to-end encryption enabled" groupFoundN' 2 cath "security" listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () @@ -1161,8 +1187,8 @@ connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do u ##> ("/c " <> dsLink) u <## "connection request sent!" - u <## "SimpleX-Directory: contact is connected" - u <# "SimpleX-Directory> Welcome to SimpleX-Directory service!" + u .<## ": contact is connected" + u .<# "> Welcome to SimpleX-Directory service!" u <## "Send a search string to find groups or /help to learn how to add groups to directory." u <## "" u <## "For example, send privacy to find groups about privacy." @@ -1206,19 +1232,22 @@ groupFoundN count u name = do groupFoundN' count u name groupFoundN' :: Int -> TestCC -> String -> IO () -groupFoundN' = groupFoundN_ Nothing +groupFoundN' = groupFoundN_ "" Nothing -groupFoundN_ :: Maybe Int -> Int -> TestCC -> String -> IO () -groupFoundN_ shownId_ count u name = do - u <# ("SimpleX-Directory> > " <> name) +groupFoundN_ :: String -> Maybe Int -> Int -> TestCC -> String -> IO () +groupFoundN_ suffix shownId_ count u name = do + u <# ("SimpleX-Directory" <> suffix <> "> > " <> name) u <## " Found 1 group(s)." - u <#. ("SimpleX-Directory> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) + u <#. ("SimpleX-Directory" <> suffix <> "> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") groupNotFound :: TestCC -> String -> IO () -groupNotFound u s = do - u #> ("@SimpleX-Directory " <> s) - u <# ("SimpleX-Directory> > " <> s) +groupNotFound = groupNotFound_ "" + +groupNotFound_ :: String -> TestCC -> String -> IO () +groupNotFound_ suffix u s = do + u #> ("@SimpleX-Directory" <> suffix <> " " <> s) + u <# ("SimpleX-Directory" <> suffix <> "> > " <> s) u <## " No groups found" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 1a04badc3e..3376204f14 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -254,30 +254,6 @@ prevVersion (Version v) = Version (v - 1) nextVersion :: Version v -> Version v nextVersion (Version v) = Version (v + 1) -testCfgCreateGroupDirect :: ChatConfig -testCfgCreateGroupDirect = - mkCfgCreateGroupDirect testCfg - -mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig -mkCfgCreateGroupDirect cfg = - cfg - { chatVRange = groupCreateDirectVRange, - agentConfig = testAgentCfgSlow - } - -groupCreateDirectVRange :: VersionRangeChat -groupCreateDirectVRange = mkVersionRange (VersionChat 1) (VersionChat 1) - -testCfgGroupLinkViaContact :: ChatConfig -testCfgGroupLinkViaContact = - mkCfgGroupLinkViaContact testCfg - -mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig -mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} - -groupLinkViaContactVRange :: VersionRangeChat -groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) - createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index ea4a30b412..998835b867 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -60,7 +60,6 @@ chatDirectTests = do it "deleting contact deletes profile" testDeleteContactDeletesProfile it "delete contact keeping conversation" testDeleteContactKeepConversation it "delete conversation keeping contact" testDeleteConversationKeepContact - it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message edit history" testDirectMessageEditHistory @@ -612,42 +611,6 @@ testDeleteConversationKeepContact = alice @@@ [("@bob", "hi")] alice <##> bob -testDeleteUnusedContactSilent :: HasCallStack => TestParams -> IO () -testDeleteUnusedContactSilent = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] - bob `hasContactProfiles` ["bob", "alice", "cath"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - -- bob deletes cath, cath's bob contact is deleted silently - bob ##> "/d cath" - bob <## "cath: contact is deleted" - bob ##> "/contacts" - bob <## "alice (Alice)" - threadDelay 50000 - cath ##> "/contacts" - cath <## "alice (Alice)" - -- group messages work - alice #> "#team hello" - concurrentlyN_ - [ bob <# "#team alice> hello", - cath <# "#team alice> hello" - ] - bob #> "#team hi there" - concurrentlyN_ - [ alice <# "#team bob> hi there", - cath <# "#team bob> hi there" - ] - cath #> "#team hey" - concurrentlyN_ - [ alice <# "#team cath> hey", - bob <# "#team cath> hey" - ] - testDirectMessageQuotedReply :: HasCallStack => TestParams -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ @@ -2567,7 +2530,7 @@ testSetChatItemTTL = testSetDirectChatTTL :: HasCallStack => TestParams -> IO () testSetDirectChatTTL = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers alice cath diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index ee957d3daa..6817f19e87 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -25,14 +25,15 @@ import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText, supportedChatVRange) -import Simplex.Chat.Types (MemberId (..), VersionRangeChat) +import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) +import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport +import Simplex.Messaging.Version import Test.Hspec hiding (it) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) @@ -47,10 +48,9 @@ chatGroupTests :: SpecWith TestParams chatGroupTests = do describe "chat groups" $ do describe "add contacts, create group and send/receive messages" testGroupMatrix + it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "mark multiple messages as read" testMarkReadGroup it "initial chat pagination" testChatPaginationInitial - it "v1: add contacts, create group and send/receive messages" testGroup - it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "send large message" testGroupLargeMessage it "create group with incognito membership" testNewGroupIncognito it "create and join group with 4 members" testGroup2 @@ -72,7 +72,6 @@ chatGroupTests = do it "group live message" testGroupLiveMessage it "update group profile" testUpdateGroupProfile it "update member role" testUpdateMemberRole - it "unused contacts are deleted after all their groups are deleted" testGroupDeleteUnusedContacts it "group description is shown as the first message to new members" testGroupDescription it "moderate message of another group member" testGroupModerate it "moderate own message (should process as deletion)" testGroupModerateOwn @@ -88,32 +87,21 @@ chatGroupTests = do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do it "create group link, join via group link" testGroupLink + it "invitees were previously connected as contacts" testGroupLinkInviteesWereConnected + it "all members were previously connected as contacts" testGroupLinkAllMembersWereConnected it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin - it "sending message to contact created via group link marks it used" testGroupLinkContactUsed - it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership - it "unused host contact is deleted after all groups with it are deleted" testGroupLinkUnusedHostContactDeleted - it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted + it "host incognito" testGroupLinkHostIncognito + it "invitee incognito" testGroupLinkInviteeIncognito + it "incognito - join/invite" testGroupLinkIncognitoJoinInvite it "group link member role" testGroupLinkMemberRole - it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete + it "host profile received" testGroupLinkHostProfileReceived + it "existing contact merged" testGroupLinkExistingContactMerged describe "group link connection plan" $ do - it "group link ok to connect; known group" testPlanGroupLinkOkKnown - it "group is known if host contact was deleted" testPlanHostContactDeletedGroupLinkKnown + it "ok to connect; known group" testPlanGroupLinkKnown it "own group link" testPlanGroupLinkOwn - it "connecting via group link" testPlanGroupLinkConnecting + it "group link without contact - connecting" testPlanGroupLinkConnecting + it "group link without contact - connecting (slow handshake)" testPlanGroupLinkConnectingSlow it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin - describe "group links without contact" $ do - it "join via group link without creating contact" testGroupLinkNoContact - it "invitees were previously connected as contacts" testGroupLinkNoContactInviteesWereConnected - it "all members were previously connected as contacts" testGroupLinkNoContactAllMembersWereConnected - it "group link member role" testGroupLinkNoContactMemberRole - it "host incognito" testGroupLinkNoContactHostIncognito - it "invitee incognito" testGroupLinkNoContactInviteeIncognito - it "host profile received" testGroupLinkNoContactHostProfileReceived - it "existing contact merged" testGroupLinkNoContactExistingContactMerged - describe "group links without contact connection plan" $ do - it "group link without contact - known group" testPlanGroupLinkNoContactKnown - it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting - it "group link without contact - connecting (slow handshake)" testPlanGroupLinkNoContactConnectingSlow #if !defined(dbPostgres) -- TODO [postgres] restore from outdated db backup (same as in agent) describe "group message errors" $ do @@ -127,11 +115,7 @@ chatGroupTests = do it "should send delivery receipts in group" testSendGroupDeliveryReceipts it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts describe "direct connections in group are not established based on chat protocol version" $ do - describe "3 members group" $ do - testNoDirect _0 _0 True - testNoDirect _0 _1 True - testNoDirect _1 _0 False - testNoDirect _1 _1 False + it "direct contacts are not created" testNoGroupDirectConns it "members have different local display names in different groups" testNoDirectDifferentLDNs describe "merge members and contacts" $ do it "new member should merge with existing contact" testMergeMemberExistingContact @@ -196,37 +180,18 @@ chatGroupTests = do it "should send updated mentions in history" testGroupHistoryWithMentions describe "uniqueMsgMentions" testUniqueMsgMentions describe "updatedMentionNames" testUpdatedMentionNames - where - _0 = supportedChatVRange -- don't create direct connections - _1 = groupCreateDirectVRange - -- having host configured with older version doesn't have effect in tests - -- because host uses current code and sends version in MemberInfo - testNoDirect vrMem2 vrMem3 noConns = - it - ( "host " - <> vRangeStr supportedChatVRange - <> (", 2nd mem " <> vRangeStr vrMem2) - <> (", 3rd mem " <> vRangeStr vrMem3) - <> (if noConns then " : 2 3" else " : 2 <##> 3") - ) - $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns - -testGroup :: HasCallStack => TestParams -> IO () -testGroup = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> testGroupShared alice bob cath False True testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> testGroupShared alice bob cath True True + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> testGroupShared alice bob cath True testGroupMatrix :: SpecWith TestParams testGroupMatrix = - versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False False + versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False -testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> Bool -> IO () -testGroupShared alice bob cath checkMessages directConnections = do +testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> IO () +testGroupShared alice bob cath checkMessages = do connectUsers alice bob connectUsers alice cath alice ##> "/g team" @@ -278,8 +243,6 @@ testGroupShared alice bob cath checkMessages directConnections = do (alice <# "#team cath> hey team") (bob <# "#team cath> hey team") msgItem2 <- lastItemId alice - when directConnections $ - bob <##> cath when checkMessages $ getReadChats msgItem1 msgItem2 -- list groups alice ##> "/gs" @@ -336,8 +299,6 @@ testGroupShared alice bob cath checkMessages directConnections = do (cath "#team hello" cath <## "you are no longer a member of the group" - when directConnections $ - bob <##> cath -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" @@ -350,7 +311,7 @@ testGroupShared alice bob cath checkMessages directConnections = do alice <# "#team bob> received" when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] - bob @@@ [("@alice", "contact deleted"), ("@cath", "hey"), ("#team", "received")] + bob @@@ [("@alice", "contact deleted"), ("#team", "received")] -- test clearing chat threadDelay 1000000 alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") @@ -370,9 +331,9 @@ testGroupShared alice bob cath checkMessages directConnections = do alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, sndGroupFeatures <> [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 around=" <> msgItem1 <> " count=2", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) - bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] + bob @@@ [("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) - cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] + cath @@@ [("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] cath #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "connected"), (0, "hello"), (0, "hi there"), (1, "hey team")]) alice #$> ("/_read chat #1", id, "ok") bob #$> ("/_read chat #1", id, "ok") @@ -444,7 +405,7 @@ testGroupLargeMessage = testNewGroupIncognito :: HasCallStack => TestParams -> IO () testNewGroupIncognito = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -463,22 +424,35 @@ testNewGroupIncognito = bob ##> ("/c " <> gLink) bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." - _ <- getTermLine alice concurrentlyN_ - [ do - alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) - alice <## "use /i bob_1 to print out this incognito profile again" - alice <## "bob_1 invited to group #team via your group link" - alice <## "#team: bob_1 joined the group", + [ alice <## "#team: bob_1 joined the group", do - bob <## (aliceIncognito <> ": contact is connected") + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] alice <##> bob - alice ?#> "@bob_1 hi, I'm incognito" - bob <# (aliceIncognito <> "> hi, I'm incognito") + alice ##> "@#team bob_1 hi, I'm incognito" + alice + <### [ "member #team bob_1 does not have direct connection, creating", + "contact for member #team bob_1 is created", + "sent invitation to connect directly to member #team bob_1", + WithTime "i @bob_1 hi, I'm incognito" + ] + bob + <### [ ConsoleString ("#team " <> aliceIncognito <> " is creating direct contact " <> aliceIncognito <> " with you"), + WithTime (aliceIncognito <> "> hi, I'm incognito") + ] + bob <## (aliceIncognito <> ": you can send messages to contact") + _ <- getTermLine alice + concurrentlyN_ + [ do + alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob_1 to print out this incognito profile again", + bob <## (aliceIncognito <> ": contact is connected") + ] + bob #> ("@" <> aliceIncognito <> " hey, I'm bob") alice ?<# "bob_1> hey, I'm bob" @@ -494,7 +468,7 @@ testNewGroupIncognito = testGroup2 :: HasCallStack => TestParams -> IO () testGroup2 = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob connectUsers alice cath @@ -545,14 +519,14 @@ testGroup2 = dan <## "#club: you joined the group" dan <### [ "#club: member alice_1 (Alice) is connected", - "contact alice_1 is merged into alice", + "contact and member are merged: alice, #club alice_1", "use @alice to send messages", "#club: member cath (Catherine) is connected" ], do alice <## "#club: bob added dan_1 (Daniel) to the group (connecting...)" alice <## "#club: new member dan_1 is connected" - alice <## "contact dan_1 is merged into dan" + alice <## "contact and member are merged: dan, #club dan_1" alice <## "use @dan to send messages", do cath <## "#club: bob added dan (Daniel) to the group (connecting...)" @@ -582,8 +556,6 @@ testGroup2 = bob <# "#club dan> how is it going?", cath <# "#club dan> how is it going?" ] - bob <##> cath - dan <##> cath dan <##> alice -- show last messages alice ##> "/t #club 17" @@ -666,7 +638,6 @@ testGroup2 = dan <## "you are no longer a member of the group" dan ##> "/d #club" dan <## "#club: you deleted the group" - dan <##> cath dan <##> alice -- member leaves bob ##> "/l club" @@ -689,7 +660,6 @@ testGroup2 = bob <## "you are no longer a member of the group" bob ##> "/d #club" bob <## "#club: you deleted the group" - bob <##> cath bob <##> alice testGroupDelete :: HasCallStack => TestParams -> IO () @@ -959,7 +929,7 @@ testDeleteGroupMemberProfileKept = testGroupRemoveAdd :: HasCallStack => TestParams -> IO () testGroupRemoveAdd = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -986,14 +956,10 @@ testGroupRemoveAdd = [ alice <## "#team: bob joined the group", do bob <## "#team_1: you joined the group" - bob <## "#team_1: member cath_1 (Catherine) is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages", + bob <## "#team_1: member cath_1 (Catherine) is connected", do cath <## "#team: alice added bob_1 (Bob) to the group (connecting...)" cath <## "#team: new member bob_1 is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages" ] alice #> "#team hi" concurrently_ @@ -1002,11 +968,11 @@ testGroupRemoveAdd = bob #> "#team_1 hey" concurrently_ (alice <# "#team bob> hey") - (cath <# "#team bob> hey") + (cath <# "#team bob_1> hey") cath #> "#team hello" concurrently_ (alice <# "#team cath> hello") - (bob <# "#team_1 cath> hello") + (bob <# "#team_1 cath_1> hello") testGroupList :: HasCallStack => TestParams -> IO () testGroupList = @@ -1040,7 +1006,7 @@ testGroupList = testGroupMessageQuotedReply :: HasCallStack => TestParams -> IO () testGroupMessageQuotedReply = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -1526,93 +1492,6 @@ testUpdateMemberRole = alice ##> "/d #team" alice <## "#team: you have insufficient permissions for this action, the required role is owner" -testGroupDeleteUnusedContacts :: HasCallStack => TestParams -> IO () -testGroupDeleteUnusedContacts = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - -- create group 1 - createGroup3 "team" alice bob cath - -- create group 2 - alice ##> "/g club" - alice <## "group #club is created" - alice <## "to add members use /a club or /create link #club" - alice ##> "/a club bob" - concurrentlyN_ - [ alice <## "invitation to join the group #club sent to bob", - do - bob <## "#club: alice invites you to join the group as member" - bob <## "use /j club to accept" - ] - bob ##> "/j club" - concurrently_ - (alice <## "#club: bob joined the group") - (bob <## "#club: you joined the group") - alice ##> "/a club cath" - concurrentlyN_ - [ alice <## "invitation to join the group #club sent to cath", - do - cath <## "#club: alice invites you to join the group as member" - cath <## "use /j club to accept" - ] - cath ##> "/j club" - concurrentlyN_ - [ alice <## "#club: cath joined the group", - do - cath <## "#club: you joined the group" - cath <## "#club: member bob_1 (Bob) is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages", - do - bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)" - bob <## "#club: new member cath_1 is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages" - ] - -- list contacts - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - cath ##> "/contacts" - cath <## "alice (Alice)" - cath <## "bob (Bob)" - -- delete group 1, contacts and profiles are kept - deleteGroup alice bob cath "team" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath ##> "/contacts" - cath <## "alice (Alice)" - cath <## "bob (Bob)" - cath `hasContactProfiles` ["alice", "bob", "cath"] - -- delete group 2, unused contacts and profiles are deleted - deleteGroup alice bob cath "club" - threadDelay 3000000 - bob ##> "/contacts" - bob <## "alice (Alice)" - bob `hasContactProfiles` ["alice", "bob"] - cath ##> "/contacts" - cath <## "alice (Alice)" - cath `hasContactProfiles` ["alice", "cath"] - where - cfg = mkCfgCreateGroupDirect $ testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} - deleteGroup :: HasCallStack => TestCC -> TestCC -> TestCC -> String -> IO () - deleteGroup alice bob cath group = do - alice ##> ("/d #" <> group) - concurrentlyN_ - [ alice <## ("#" <> group <> ": you deleted the group"), - do - bob <## ("#" <> group <> ": alice deleted the group") - bob <## ("use /d #" <> group <> " to delete the local copy of the group"), - do - cath <## ("#" <> group <> ": alice deleted the group") - cath <## ("use /d #" <> group <> " to delete the local copy of the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - cath ##> ("/d #" <> group) - cath <## ("#" <> group <> ": you deleted the group") - testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob @@ -1630,7 +1509,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile ] alice ##> "/group_profile team" alice <## "#team" - groupInfo alice + groupInfo' alice alice ##> "/group_descr team Welcome to the team!" alice <## "description changed to:" alice <## "Welcome to the team!" @@ -1641,7 +1520,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "#team" alice <## "description:" alice <## "Welcome to the team!" - groupInfo alice + groupInfo' alice connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1671,8 +1550,8 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile bobAddedDan cath ] where - groupInfo :: HasCallStack => TestCC -> IO () - groupInfo alice = do + groupInfo' :: HasCallStack => TestCC -> IO () + groupInfo' alice = do alice <## "group preferences:" alice <## "Disappearing messages: off" alice <## "Direct messages: on" @@ -1689,7 +1568,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile testGroupModerate :: HasCallStack => TestParams -> IO () testGroupModerate = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath disableFullDeletion3 "team" alice bob cath @@ -1772,7 +1651,7 @@ testGroupModerateMultiple = testGroupModerateFullDelete :: HasCallStack => TestParams -> IO () testGroupModerateFullDelete = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath disableFullDeletion3 "team" alice bob cath @@ -1841,7 +1720,7 @@ testGroupDelayedModeration ps = do bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "2 contacts connected (use /cs for the list)" + cath <## "1 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" bob @@ -1854,7 +1733,8 @@ testGroupDelayedModeration ps = do r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "connected"), (0, "hi [marked deleted by alice]")] where - cfg = testCfgCreateGroupDirect + -- version before forwarding, so cath doesn't expect alice to forward messages (groupForwardVersion = 4) + cfg = testCfg {chatVRange = mkVersionRange (VersionChat 1) (VersionChat 3)} testGroupDelayedModerationFullDelete :: HasCallStack => TestParams -> IO () testGroupDelayedModerationFullDelete ps = do @@ -1899,7 +1779,7 @@ testGroupDelayedModerationFullDelete ps = do bob <## "updated group preferences:" bob <## "Full deletion: on" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "2 contacts connected (use /cs for the list)" + cath <## "1 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" bob @@ -1912,7 +1792,8 @@ testGroupDelayedModerationFullDelete ps = do r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "Full deletion: on"), (0, "connected"), (0, "moderated [deleted by alice]")] where - cfg = testCfgCreateGroupDirect + -- version before forwarding, so cath doesn't expect alice to forward messages (groupForwardVersion = 4) + cfg = testCfg {chatVRange = mkVersionRange (VersionChat 1) (VersionChat 3)} testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = @@ -2141,113 +2022,9 @@ testGroupAsync ps = do dan <##> cath dan <##> alice -testGroupLink :: HasCallStack => TestParams -> IO () -testGroupLink = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/show link #team" - alice <## "no group link, to create: /create link #team" - alice ##> "/create link #team" - _ <- getGroupLink alice "team" GRMember True - alice ##> "/delete link #team" - alice <## "Group link is deleted - joined members will remain connected." - alice <## "To create a new group link use /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - alice ##> "/show link #team" - _ <- getGroupLink alice "team" GRMember False - alice ##> "/create link #team" - alice <## "you already have link for this group, to show: /show link #team" - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "invited via your group link"), (0, "connected")]) - -- contacts connected via group link are not in chat previews - alice @@@ [("#team", "connected")] - bob @@@ [("#team", "connected")] - alice <##> bob - alice @@@ [("@bob", "hey"), ("#team", "connected")] - - -- user address doesn't interfere - alice ##> "/ad" - cLink <- getContactLink alice True - cath ##> ("/c " <> cLink) - alice <#? cath - alice ##> "/ac cath" - alice <## "cath (Catherine): accepting contact request, you can send messages to contact" - concurrently_ - (cath <## "alice (Alice): contact is connected") - (alice <## "cath (Catherine): contact is connected") - alice <##> cath - - -- third member - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath_1 (Catherine): accepting request to join group #team..." - -- if contact existed it is merged - concurrentlyN_ - [ alice - <### [ "cath_1 (Catherine): contact is connected", - "contact cath_1 is merged into cath", - "use @cath to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - cath - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#team: you joined the group", - "#team: member bob (Bob) is connected" - ], - do - bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - bob <## "#team: new member cath is connected" - ] - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - bob #> "#team hi there" - concurrently_ - (alice <# "#team bob> hi there") - (cath <# "#team bob> hi there") - cath #> "#team hey team" - concurrently_ - (alice <# "#team cath> hey team") - (bob <# "#team cath> hey team") - - threadDelay 100000 - - -- leaving team removes link - alice ##> "/l team" - concurrentlyN_ - [ do - alice <## "#team: you left the group" - alice <## "use /d #team to delete the group", - bob <## "#team: alice left the group", - cath <## "#team: alice left the group" - ] - alice ##> "/show link #team" - alice <## "no group link, to create: /create link #team" - testGroupLinkDeleteGroupRejoin :: HasCallStack => TestParams -> IO () testGroupLinkDeleteGroupRejoin = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 alice ##> "/g team" @@ -2259,16 +2036,11 @@ testGroupLinkDeleteGroupRejoin = bob <## "connection request sent!" alice <## "bob (Bob): accepting request to join group #team..." concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", + [ alice <## "#team: bob joined the group", do - bob <## "alice (Alice): contact is connected" + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] - -- use contact so it's not deleted when deleting group - bob <##> alice bob ##> "/l team" concurrentlyN_ [ do @@ -2283,62 +2055,20 @@ testGroupLinkDeleteGroupRejoin = bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], + [ alice <## "#team: bob_1 joined the group", bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", + <### [ "#team: joining the group...", "#team: you joined the group" ] ] alice #> "#team hello" bob <# "#team alice> hello" bob #> "#team hi there" - alice <# "#team bob> hi there" + alice <# "#team bob_1> hi there" -testGroupLinkContactUsed :: HasCallStack => TestParams -> IO () -testGroupLinkContactUsed = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - -- sending/receiving a message marks contact as used - threadDelay 100000 - alice @@@ [("#team", "connected")] - bob @@@ [("#team", "connected")] - alice #> "@bob hello" - bob <# "alice> hello" - threadDelay 500000 - alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") - alice @@@ [("@bob", ""), ("#team", "connected")] - bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") - bob @@@ [("@alice", ""), ("#team", "connected")] - -testGroupLinkIncognitoMembership :: HasCallStack => TestParams -> IO () -testGroupLinkIncognitoMembership = - testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ +testGroupLinkIncognitoJoinInvite :: HasCallStack => TestParams -> IO () +testGroupLinkIncognitoJoinInvite = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- bob connected incognito to alice alice ##> "/c" @@ -2374,41 +2104,25 @@ testGroupLinkIncognitoMembership = cath ##> ("/c " <> gLink) cath <## "connection request sent!" bob <## "cath (Catherine): accepting request to join group #team..." - _ <- getTermLine bob concurrentlyN_ - [ do - bob <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## "use /i cath to print out this incognito profile again" - bob <## "cath invited to group #team via your group link" - bob <## "#team: cath joined the group", + [ bob <## "#team: cath joined the group", do - cath <## (bobIncognito <> ": contact is connected") + cath <## "#team: joining the group..." cath <## "#team: you joined the group" cath <## "#team: member alice (Alice) is connected", do alice <## ("#team: " <> bobIncognito <> " added cath (Catherine) to the group (connecting...)") alice <## "#team: new member cath is connected" ] - bob ?#> "@cath hi, I'm incognito" - cath <# (bobIncognito <> "> hi, I'm incognito") - cath #> ("@" <> bobIncognito <> " hey, I'm cath") - bob ?<# "cath> hey, I'm cath" -- dan joins incognito dan ##> ("/c i " <> gLink) danIncognito <- getTermLine dan dan <## "connection request sent incognito!" bob <## (danIncognito <> ": accepting request to join group #team...") - _ <- getTermLine bob - _ <- getTermLine dan concurrentlyN_ - [ do - bob <## (danIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## ("use /i " <> danIncognito <> " to print out this incognito profile again") - bob <## (danIncognito <> " invited to group #team via your group link") - bob <## ("#team: " <> danIncognito <> " joined the group"), + [ bob <## ("#team: " <> danIncognito <> " joined the group"), do - dan <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> danIncognito) - dan <## ("use /i " <> bobIncognito <> " to print out this incognito profile again") + dan <## "#team: joining the group..." dan <## ("#team: you joined the group incognito as " <> danIncognito) dan <### [ "#team: member alice (Alice) is connected", @@ -2421,10 +2135,6 @@ testGroupLinkIncognitoMembership = cath <## ("#team: " <> bobIncognito <> " added " <> danIncognito <> " to the group (connecting...)") cath <## ("#team: new member " <> danIncognito <> " is connected") ] - bob ?#> ("@" <> danIncognito <> " hi, I'm incognito") - dan ?<# (bobIncognito <> "> hi, I'm incognito") - dan ?#> ("@" <> bobIncognito <> " hey, me too") - bob ?<# (danIncognito <> "> hey, me too") alice #> "#team hello" concurrentlyN_ [ bob ?<# "#team alice> hello", @@ -2450,372 +2160,9 @@ testGroupLinkIncognitoMembership = cath <# ("#team " <> danIncognito <> "> how is it going?") ] -testGroupLinkUnusedHostContactDeleted :: HasCallStack => TestParams -> IO () -testGroupLinkUnusedHostContactDeleted = - testChatCfg2 cfg aliceProfile bobProfile $ - \alice bob -> do - -- create group 1 - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLinkTeam <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLinkTeam) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - -- create group 2 - alice ##> "/g club" - alice <## "group #club is created" - alice <## "to add members use /a club or /create link #club" - alice ##> "/create link #club" - gLinkClub <- getGroupLink alice "club" GRMember True - bob ##> ("/c " <> gLinkClub) - bob <## "connection request sent!" - alice <## "bob_1 (Bob): accepting request to join group #club..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #club via your group link", - EndsWith "joined the group" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#club: you joined the group" - ] - ] - -- list contacts - bob ##> "/contacts" - bob <## "alice (Alice)" - -- delete group 1, host contact and profile are kept - bobLeaveDeleteGroup alice bob "team" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob `hasContactProfiles` ["alice", "bob"] - -- delete group 2, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "club" - threadDelay 3000000 - bob ##> "/contacts" - (bob TestCC -> TestCC -> String -> IO () - bobLeaveDeleteGroup alice bob group = do - bob ##> ("/l " <> group) - concurrentlyN_ - [ do - bob <## ("#" <> group <> ": you left the group") - bob <## ("use /d #" <> group <> " to delete the group"), - alice <## ("#" <> group <> ": bob left the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - -testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => TestParams -> IO () -testGroupLinkIncognitoUnusedHostContactsDeleted = - testChatCfg2 cfg aliceProfile bobProfile $ - \alice bob -> do - bobIncognitoTeam <- createGroupBobIncognito alice bob "team" "alice" - bobIncognitoClub <- createGroupBobIncognito alice bob "club" "alice_1" - bobIncognitoTeam `shouldNotBe` bobIncognitoClub - -- list contacts - bob ##> "/contacts" - bob <## "i alice (Alice)" - bob <## "i alice_1 (Alice)" - bob `hasContactProfiles` ["alice", "alice", "bob", T.pack bobIncognitoTeam, T.pack bobIncognitoClub] - -- delete group 1, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "team" bobIncognitoTeam - threadDelay 3000000 - bob ##> "/contacts" - bob <## "i alice_1 (Alice)" - bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognitoClub] - -- delete group 2, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "club" bobIncognitoClub - threadDelay 3000000 - bob ##> "/contacts" - (bob TestCC -> TestCC -> String -> String -> IO String - createGroupBobIncognito alice bob group bobsAliceContact = do - alice ##> ("/g " <> group) - alice <## ("group #" <> group <> " is created") - alice <## ("to add members use /a " <> group <> " or /create link #" <> group) - alice ##> ("/create link #" <> group) - gLinkTeam <- getGroupLink alice group GRMember True - bob ##> ("/c i " <> gLinkTeam) - bobIncognito <- getTermLine bob - bob <## "connection request sent incognito!" - alice <## (bobIncognito <> ": accepting request to join group #" <> group <> "...") - _ <- getTermLine bob - concurrentlyN_ - [ do - alice <## (bobIncognito <> ": contact is connected") - alice <## (bobIncognito <> " invited to group #" <> group <> " via your group link") - alice <## ("#" <> group <> ": " <> bobIncognito <> " joined the group"), - do - bob <## (bobsAliceContact <> " (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## ("use /i " <> bobsAliceContact <> " to print out this incognito profile again") - bob <## ("#" <> group <> ": you joined the group incognito as " <> bobIncognito) - ] - pure bobIncognito - bobLeaveDeleteGroup :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () - bobLeaveDeleteGroup alice bob group bobIncognito = do - bob ##> ("/l " <> group) - concurrentlyN_ - [ do - bob <## ("#" <> group <> ": you left the group") - bob <## ("use /d #" <> group <> " to delete the group"), - alice <## ("#" <> group <> ": " <> bobIncognito <> " left the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - -testGroupLinkMemberRole :: HasCallStack => TestParams -> IO () -testGroupLinkMemberRole = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team admin" - alice <## "#team: initial role for group member cannot be admin, use member or observer" - alice ##> "/create link #team observer" - gLink <- getGroupLink alice "team" GRObserver True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice ##> "/set link role #team admin" - alice <## "#team: initial role for group member cannot be admin, use member or observer" - alice ##> "/set link role #team member" - _ <- getGroupLink alice "team" GRMember False - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath (Catherine): accepting request to join group #team..." - -- if contact existed it is merged - concurrentlyN_ - [ alice - <### [ "cath (Catherine): contact is connected", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - cath - <### [ "alice (Alice): contact is connected", - "#team: you joined the group", - "#team: member bob (Bob) is connected" - ], - do - bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - bob <## "#team: new member cath is connected" - ] - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - cath #> "#team hello too" - concurrently_ - (alice <# "#team cath> hello too") - (bob <# "#team cath> hello too") - bob ##> "#team hey" - bob <## "#team: you don't have permission to send messages" - alice ##> "/mr #team bob member" - alice <## "#team: you changed the role of bob from observer to member" - concurrently_ - (bob <## "#team: alice changed your role from observer to member") - (cath <## "#team: alice changed the role of bob from observer to member") - bob #> "#team hey now" - concurrently_ - (alice <# "#team bob> hey now") - (cath <# "#team bob> hey now") - -testGroupLinkLeaveDelete :: HasCallStack => TestParams -> IO () -testGroupLinkLeaveDelete = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - connectUsers alice bob - connectUsers cath bob - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob_1 (Bob): accepting request to join group #team..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#team: you joined the group" - ] - ] - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath (Catherine): accepting request to join group #team..." - concurrentlyN_ - [ alice - <### [ "cath (Catherine): contact is connected", - "cath invited to group #team via your group link", - "#team: cath joined the group" - ], - cath - <### [ "alice (Alice): contact is connected", - "#team: you joined the group", - "#team: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - bob - <### [ "#team: alice added cath_1 (Catherine) to the group (connecting...)", - "#team: new member cath_1 is connected", - "contact cath_1 is merged into cath", - "use @cath to send messages" - ] - ] - bob ##> "/l team" - concurrentlyN_ - [ do - bob <## "#team: you left the group" - bob <## "use /d #team to delete the group", - alice <## "#team: bob left the group", - cath <## "#team: bob left the group" - ] - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - bob ##> "/d #team" - bob <## "#team: you deleted the group" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - -testPlanGroupLinkOkKnown :: HasCallStack => TestParams -> IO () -testPlanGroupLinkOkKnown = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: ok to connect" - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice #> "#team hi" - bob <# "#team alice> hi" - bob #> "#team hey" - alice <# "#team bob> hey" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - bob ##> ("/c " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - -testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => TestParams -> IO () -testPlanHostContactDeletedGroupLinkKnown = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice #> "#team hi" - bob <# "#team alice> hi" - bob #> "#team hey" - alice <# "#team bob> hey" - - alice <##> bob - threadDelay 500000 - bob ##> "/d alice" - bob <## "alice: contact is deleted" - alice <## "bob (Bob) deleted contact with you" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - bob ##> ("/c " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - testPlanGroupLinkOwn :: HasCallStack => TestParams -> IO () testPlanGroupLinkOwn ps = - withNewTestChatCfg ps (mkCfgGroupLinkViaContact testCfgSlow) "alice" aliceProfile $ \alice -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" @@ -2834,18 +2181,14 @@ testPlanGroupLinkOwn ps = alice <## "connection request sent!" alice <## "alice_1 (Alice): accepting request to join group #team..." alice - <### [ "alice_1 (Alice): contact is connected", - "alice_1 invited to group #team via your group link", - "#team: alice_1 joined the group", - "alice_2 (Alice): contact is connected", - "#team_1: you joined the group", - "contact alice_2 is merged into alice_1", - "use @alice_1 to send messages" + <### [ "#team: alice_1 joined the group", + "#team_1: joining the group...", + "#team_1: you joined the group" ] alice `send` "#team 1" alice <### [ WithTime "#team 1", - WithTime "#team_1 alice_1> 1" + WithTime "#team_1 alice_2> 1" ] alice `send` "#team_1 2" alice @@ -2859,71 +2202,9 @@ testPlanGroupLinkOwn ps = alice ##> ("/_connect plan 1 " <> gLinkSchema2) alice <## "group link: own link for group #team" - -- group works if merged contact is deleted - alice ##> "/d alice_1" - alice <## "alice_1: contact is deleted" - - alice `send` "#team 3" - alice - <### [ WithTime "#team 3", - WithTime "#team_1 alice_1> 3" - ] - alice `send` "#team_1 4" - alice - <### [ WithTime "#team_1 4", - WithTime "#team alice_1> 4" - ] - -testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () -testPlanGroupLinkConnecting ps = do - -- gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do - gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - getGroupLink alice "team" GRMember True - -- withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do - threadDelay 100000 - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: connecting, allowed to reconnect" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: connecting, allowed to reconnect" - - threadDelay 100000 - -- withTestChatCfg ps cfg "alice" $ \alice -> do - withTestChatCfg ps cfg "alice" $ \alice -> do - alice - <### [ "1 group links active", - "#team: group is empty", - "bob (Bob): accepting request to join group #team..." - ] - -- withTestChatCfg ps cfg "bob" $ \bob -> do - withTestChatCfg ps cfg "bob" $ \bob -> do - threadDelay 500000 - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: connecting" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: connecting" - - bob ##> ("/c " <> gLink) - bob <## "group link: connecting" - where - cfg = mkCfgGroupLinkViaContact testCfgSlow - testPlanGroupLinkLeaveRejoin :: HasCallStack => TestParams -> IO () testPlanGroupLinkLeaveRejoin = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 alice ##> "/g team" @@ -2936,12 +2217,9 @@ testPlanGroupLinkLeaveRejoin = bob <## "connection request sent!" alice <## "bob (Bob): accepting request to join group #team..." concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", + [ alice <## "#team: bob joined the group", do - bob <## "alice (Alice): contact is connected" + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] @@ -2978,25 +2256,17 @@ testPlanGroupLinkLeaveRejoin = bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], + [ alice <## "#team: bob_1 joined the group", bob - <### [ "alice_1 (Alice): contact is connected", - "#team_1: you joined the group", - "contact alice_1 is merged into alice", - "use @alice to send messages" + <### [ "#team_1: joining the group...", + "#team_1: you joined the group" ] ] alice #> "#team hi" - bob <# "#team_1 alice> hi" + bob <# "#team_1 alice_1> hi" bob #> "#team_1 hey" - alice <# "#team bob> hey" + alice <# "#team bob_1> hey" bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: known group #team_1" @@ -3010,8 +2280,8 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" -testGroupLinkNoContact :: HasCallStack => TestParams -> IO () -testGroupLinkNoContact = +testGroupLink :: HasCallStack => TestParams -> IO () +testGroupLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do threadDelay 100000 @@ -3048,16 +2318,34 @@ testGroupLinkNoContact = bob #> "#team hi there" alice <# "#team bob> hi there" + -- user address doesn't interfere + alice ##> "/ad" + cLink <- getContactLink alice True + cath ##> ("/c " <> cLink) + alice <#? cath + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request, you can send messages to contact" + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice <##> cath + + -- third member cath ##> ("/c " <> gLink) cath <## "connection request sent!" concurrentlyN_ [ do - alice <## "cath (Catherine): accepting request to join group #team..." - alice <## "#team: cath joined the group", - do - cath <## "#team: joining the group..." - cath <## "#team: you joined the group" - cath <## "#team: member bob (Bob) is connected", + alice <## "cath_1 (Catherine): accepting request to join group #team..." + alice <## "#team: cath_1 joined the group" + alice <## "contact and member are merged: cath, #team cath_1" + alice <## "use @cath to send messages", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member bob (Bob) is connected", + "contact and member are merged: alice, #team alice_1", + "use @alice to send messages" + ], do bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" @@ -3071,8 +2359,28 @@ testGroupLinkNoContact = alice <# "#team bob> hi cath" cath <# "#team bob> hi cath" -testGroupLinkNoContactInviteesWereConnected :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactInviteesWereConnected = + -- leaving group removes link + alice ##> "/l team" + concurrentlyN_ + [ do + alice <## "#team: you left the group" + alice <## "use /d #team to delete the group", + bob <## "#team: alice left the group", + cath <## "#team: alice left the group" + ] + alice ##> "/show link #team" + alice <## "no group link, to create: /create link #team" + + -- deleting group keeps contacts + alice ##> "/contacts" + alice <## "cath (Catherine)" + alice ##> "/d #team" + alice <## "#team: you deleted the group" + alice ##> "/contacts" + alice <## "cath (Catherine)" + +testGroupLinkInviteesWereConnected :: HasCallStack => TestParams -> IO () +testGroupLinkInviteesWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers bob cath @@ -3143,8 +2451,8 @@ testGroupLinkNoContactInviteesWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactAllMembersWereConnected :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactAllMembersWereConnected = +testGroupLinkAllMembersWereConnected :: HasCallStack => TestParams -> IO () +testGroupLinkAllMembersWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -3234,8 +2542,8 @@ testGroupLinkNoContactAllMembersWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactMemberRole :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactMemberRole = +testGroupLinkMemberRole :: HasCallStack => TestParams -> IO () +testGroupLinkMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do threadDelay 100000 @@ -3315,8 +2623,8 @@ testGroupLinkNoContactMemberRole = bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" -testGroupLinkNoContactHostIncognito :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactHostIncognito = +testGroupLinkHostIncognito :: HasCallStack => TestParams -> IO () +testGroupLinkHostIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/g i team" @@ -3348,8 +2656,8 @@ testGroupLinkNoContactHostIncognito = bob #> "#team hi there" alice ?<# "#team bob> hi there" -testGroupLinkNoContactInviteeIncognito :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactInviteeIncognito = +testGroupLinkInviteeIncognito :: HasCallStack => TestParams -> IO () +testGroupLinkInviteeIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 @@ -3382,8 +2690,8 @@ testGroupLinkNoContactInviteeIncognito = bob ?#> "#team hi there" alice <# ("#team " <> bobIncognito <> "> hi there") -testGroupLinkNoContactHostProfileReceived :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactHostProfileReceived = +testGroupLinkHostProfileReceived :: HasCallStack => TestParams -> IO () +testGroupLinkHostProfileReceived = testChat2 aliceProfile bobProfile $ \alice bob -> do let profileImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" @@ -3410,8 +2718,8 @@ testGroupLinkNoContactHostProfileReceived = aliceImage <- getProfilePictureByName bob "alice" aliceImage `shouldBe` Just profileImage -testGroupLinkNoContactExistingContactMerged :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactExistingContactMerged = +testGroupLinkExistingContactMerged :: HasCallStack => TestParams -> IO () +testGroupLinkExistingContactMerged = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -3453,8 +2761,8 @@ testGroupLinkNoContactExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" -testPlanGroupLinkNoContactKnown :: HasCallStack => TestParams -> IO () -testPlanGroupLinkNoContactKnown = +testPlanGroupLinkKnown :: HasCallStack => TestParams -> IO () +testPlanGroupLinkKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 @@ -3490,8 +2798,8 @@ testPlanGroupLinkNoContactKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnecting :: HasCallStack => TestParams -> IO () -testPlanGroupLinkNoContactConnecting ps = do +testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnecting ps = do gLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" @@ -3537,8 +2845,8 @@ testPlanGroupLinkNoContactConnecting ps = do bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnectingSlow :: HasCallStack => TestParams -> IO () -testPlanGroupLinkNoContactConnectingSlow ps = do +testPlanGroupLinkConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnectingSlow ps = do gLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" @@ -3873,14 +3181,10 @@ testConfigureGroupDeliveryReceipts ps = [ alice <## "#club: cath joined the group", do cath <## "#club: you joined the group" - cath <## "#club: member bob_1 (Bob) is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages", + cath <## "#club: member bob_1 (Bob) is connected", do bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)" bob <## "#club: new member cath_1 is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages" ] threadDelay 1000000 @@ -3957,54 +3261,33 @@ testConfigureGroupDeliveryReceipts ps = receipt bob alice cath "team" "25" noReceipt bob alice cath "club" "26" where - cfg = mkCfgCreateGroupDirect $ testCfg {showReceipts = True} + cfg = testCfg {showReceipts = True} receipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 % ("#" <> gName <> " " <> msg) cc1 ⩗ ("#" <> gName <> " " <> msg) partialReceipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 % ("#" <> gName <> " " <> msg) noReceipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> TestParams -> IO () -testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns ps = - withNewTestChatCfg ps testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg ps testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do - withNewTestChatCfg ps testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do - createGroup3 "team" alice bob cath - if noDirectConns - then contactsDontExist bob cath - else contactsExist bob cath - where - contactsDontExist bob cath = do +testNoGroupDirectConns :: HasCallStack => TestParams -> IO () +testNoGroupDirectConns = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath bob ##> "/contacts" bob <## "alice (Alice)" cath ##> "/contacts" cath <## "alice (Alice)" - contactsExist bob cath = do - bob ##> "/contacts" - bob - <### [ "alice (Alice)", - "cath (Catherine)" - ] - cath ##> "/contacts" - cath - <### [ "alice (Alice)", - "bob (Bob)" - ] - bob <##> cath testNoDirectDifferentLDNs :: HasCallStack => TestParams -> IO () testNoDirectDifferentLDNs = @@ -4194,7 +3477,7 @@ testMergeContactMultipleMembers = testMergeGroupLinkHostMultipleContacts :: HasCallStack => TestParams -> IO () testMergeGroupLinkHostMultipleContacts = - testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ + testChat2 bobProfile cathProfile $ \bob cath -> do connectUsers bob cath @@ -4219,16 +3502,14 @@ testMergeGroupLinkHostMultipleContacts = bob <## "cath_2 (Catherine): accepting request to join group #party..." concurrentlyN_ [ bob - <### [ "cath_2 (Catherine): contact is connected", - EndsWith "invited to group #party via your group link", - EndsWith "joined the group", - StartsWith "contact cath_2 is merged into cath", + <### [ EndsWith "joined the group", + "contact and member are merged: cath, #party cath_2", StartsWith "use @cath" ], cath - <### [ "bob_2 (Bob): contact is connected", + <### [ "#party: joining the group...", "#party: you joined the group", - StartsWith "contact bob_2 is merged into bob", + "contact and member are merged: bob, #party bob_2", StartsWith "use @bob" ] ] @@ -4442,7 +3723,7 @@ testMemberContactInvitedConnectionReplaced ps = do testMemberContactIncognito :: HasCallStack => TestParams -> IO () testMemberContactIncognito = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do -- create group, bob joins incognito threadDelay 100000 @@ -4455,15 +3736,10 @@ testMemberContactIncognito = bobIncognito <- getTermLine bob bob <## "connection request sent incognito!" alice <## (bobIncognito <> ": accepting request to join group #team...") - _ <- getTermLine bob concurrentlyN_ - [ do - alice <## (bobIncognito <> ": contact is connected") - alice <## (bobIncognito <> " invited to group #team via your group link") - alice <## ("#team: " <> bobIncognito <> " joined the group"), + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), do - bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## "use /i alice to print out this incognito profile again" + bob <## "#team: joining the group..." bob <## ("#team: you joined the group incognito as " <> bobIncognito) ] -- cath joins incognito @@ -4471,15 +3747,10 @@ testMemberContactIncognito = cathIncognito <- getTermLine cath cath <## "connection request sent incognito!" alice <## (cathIncognito <> ": accepting request to join group #team...") - _ <- getTermLine cath concurrentlyN_ - [ do - alice <## (cathIncognito <> ": contact is connected") - alice <## (cathIncognito <> " invited to group #team via your group link") - alice <## ("#team: " <> cathIncognito <> " joined the group"), + [ alice <## ("#team: " <> cathIncognito <> " joined the group"), do - cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) - cath <## "use /i alice to print out this incognito profile again" + cath <## "#team: joining the group..." cath <## ("#team: you joined the group incognito as " <> cathIncognito) cath <## ("#team: member " <> bobIncognito <> " is connected"), do @@ -4641,6 +3912,12 @@ testRecreateMemberContactManyGroups = bob ##> "/d alice" bob <## "alice: contact is deleted" + -- group messages work + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + -- alice creates member contact with bob alice ##> "@#team bob hi" alice @@ -6837,6 +6114,6 @@ testUpdatedMentionNames = do let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentions in msgContentText mc' mm = M.fromList . map (second mentionedMember) - mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = memberInfo <$> name_} + mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = ciMentionMember <$> name_} where - memberInfo name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} + ciMentionMember name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index f12f7276fd..ffdd84406c 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -104,9 +104,11 @@ chatProfileTests = do testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + connectUsers alice bob + connectUsers alice cath + connectUsers bob cath alice ##> "/p" alice <## "user profile: alice (Alice)" alice <## "use /p to change it" @@ -1451,7 +1453,7 @@ testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ testJoinGroupIncognito :: HasCallStack => TestParams -> IO () testJoinGroupIncognito = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- non incognito connections connectUsers alice bob @@ -1526,13 +1528,13 @@ testJoinGroupIncognito = dan <### [ ConsoleString $ "#secret_club: member " <> cathIncognito <> " is connected", "#secret_club: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", + "contact and member are merged: bob, #secret_club bob_1", "use @bob to send messages" ], do bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" bob <## "#secret_club: new member dan_1 is connected" - bob <## "contact dan_1 is merged into dan" + bob <## "contact and member are merged: dan, #secret_club dan_1" bob <## "use @dan to send messages", do cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" @@ -1563,17 +1565,7 @@ testJoinGroupIncognito = bob <# "#secret_club dan> how is it going?", cath ?<# "#secret_club dan_1> how is it going?" ] - -- cath and bob can send messages via new direct connection, cath is incognito - bob #> ("@" <> cathIncognito <> " hi, I'm bob") - cath ?<# "bob_1> hi, I'm bob" - cath ?#> "@bob_1 hey, I'm incognito" - bob <# (cathIncognito <> "> hey, I'm incognito") - -- cath and dan can send messages via new direct connection, cath is incognito - dan #> ("@" <> cathIncognito <> " hi, I'm dan") - cath ?<# "dan_1> hi, I'm dan" - cath ?#> "@dan_1 hey, I'm incognito" - dan <# (cathIncognito <> "> hey, I'm incognito") - -- non incognito connections are separate + -- non incognito direct connections are separate bob <##> cath dan <##> cath -- list groups @@ -1632,11 +1624,6 @@ testJoinGroupIncognito = ] cath ##> "#secret_club hello" cath <## "you are no longer a member of the group" - -- cath can still message members directly - bob #> ("@" <> cathIncognito <> " I removed you from group") - cath ?<# "bob_1> I removed you from group" - cath ?#> "@bob_1 ok" - bob <# (cathIncognito <> "> ok") testCantInviteContactIncognito :: HasCallStack => TestParams -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ @@ -2205,7 +2192,7 @@ testAllowFullDeletionGroup = testProhibitDirectMessages :: HasCallStack => TestParams -> IO () testProhibitDirectMessages = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -2221,7 +2208,7 @@ testProhibitDirectMessages = alice #> "@cath hello again" cath <# "alice> hello again" bob ##> "@cath hello again" - bob <## "direct messages to indirect contact cath are prohibited" + bob <## "bad chat command: direct messages not allowed" (cath "@dan hi" - alice <## "direct messages to indirect contact dan are prohibited" + alice <## "bad chat command: direct messages not allowed" bob ##> "@dan hi" - bob <## "direct messages to indirect contact dan are prohibited" + bob <## "bad chat command: direct messages not allowed" (dan "@alice hi" - dan <## "direct messages to indirect contact alice are prohibited" + dan <## "bad chat command: direct messages not allowed" dan ##> "@bob hi" - dan <## "direct messages to indirect contact bob are prohibited" + dan <## "bad chat command: direct messages not allowed" dan #> "@cath hi" cath <# "dan> hi" cath #> "@dan hi" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9cb8439500..a6eab378d9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -361,6 +361,13 @@ cc <#. line = do unless prefix $ print ("expected to start from: " <> line, ", got: " <> l) prefix `shouldBe` True +(.<#) :: HasCallStack => TestCC -> String -> Expectation +cc .<# line = do + l <- dropTime <$> getTermLine cc + let suffix = line `isSuffixOf` l + unless suffix $ print ("expected to end with: " <> line, ", got: " <> l) + suffix `shouldBe` True + (<##..) :: HasCallStack => TestCC -> [String] -> Expectation cc <##.. ls = do l <- getTermLine cc diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index a2dde191cb..c791c8bdac 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -103,7 +103,8 @@ testSchemaMigrations = withTmpFiles $ do schema <- getSchema testDB testSchema Migrations.run st True $ MTRUp [m] schema' <- getSchema testDB testSchema - schema' `shouldNotBe` schema + unless (name m `elem` skipComparisonForUpMigrations) $ + schema' `shouldNotBe` schema Migrations.run st True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema @@ -112,6 +113,12 @@ testSchemaMigrations = withTmpFiles $ do schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' +skipComparisonForUpMigrations :: [String] +skipComparisonForUpMigrations = + [ -- schema doesn't change + "20250129_delete_unused_contacts" + ] + skipComparisonForDownMigrations :: [String] skipComparisonForDownMigrations = [ -- on down migration msg_delivery_events table moves down to the end of the file From 68e63c7eb6361e5d90901b24acab5e0651361557 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:33:01 +0000 Subject: [PATCH 020/289] docs/servers: update installation instructions (#5561) * docs/servers: update installation instructions * docs/servers: refactor installation instructions * update * update xftp-server --------- Co-authored-by: Evgeny Poberezkin --- docs/SERVER.md | 320 +++++++++++++++++++++++++++++++++++--------- docs/XFTP-SERVER.md | 138 ++++++++++++++++--- 2 files changed, 380 insertions(+), 78 deletions(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 9c3f2f619e..88ef74c058 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -7,35 +7,51 @@ revision: 12.10.2024 | Updated 12.10.2024 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md), [PL](/docs/lang/pl/SERVER.md) | -### Table of Contents +## Table of Contents -- [Quick start](#quick-start) -- [Detailed guide](#detailed-guide) - - [Overview](#overview) - - [Installation](#installation) - - [Configuration](#configuration) - - [Interactively](#interactively) - - [Via command line options](#via-command-line-options) - - [Further configuration](#further-configuration) - - [Server security](#server-security) - - [Initialization](#initialization) - - [Private keys](#private-keys) - - [Online certificate rotation](#online-certificate-rotation) - - [Tor: installation and configuration](#tor-installation-and-configuration) - - [Installation for onion address](#installation-for-onion-address) - - [SOCKS port for SMP PROXY](#socks-port-for-smp-proxy) - - [Server information page](#server-information-page) - - [Documentation](#documentation) - - [SMP server address](#smp-server-address) - - [Systemd commands](#systemd-commands) - - [Control port](#control-port) - - [Daily statistics](#daily-statistics) - - [Updating your SMP server](#updating-your-smp-server) - - [Configuring the app to use the server](#configuring-the-app-to-use-the-server) +- [Overview](#overview) +- [Quick start](#quick-start) with systemd service +- [Installation options](#installation-options) + - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) + - [docker container](#docker-container) + - [Linode marketplace](#linode-marketplace) +- [Configuration](#configuration) + - [Interactively](#interactively) + - [Via command line options](#via-command-line-options) +- [Further configuration](#further-configuration) +- [Server security](#server-security) + - [Initialization](#initialization) + - [Private keys](#private-keys) + - [Online certificate rotation](#online-certificate-rotation) +- [Tor: installation and configuration](#tor-installation-and-configuration) + - [Installation for onion address](#installation-for-onion-address) + - [SOCKS port for SMP PROXY](#socks-port-for-smp-proxy) +- [Server information page](#server-information-page) +- [Documentation](#documentation) + - [SMP server address](#smp-server-address) + - [Systemd commands](#systemd-commands) + - [Control port](#control-port) + - [Daily statistics](#daily-statistics) +- [Updating your SMP server](#updating-your-smp-server) +- [Configuring the app to use the server](#configuring-the-app-to-use-the-server) + +## Overview + +SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp11, smp12 and smp14.simplex.im), but you can easily change app configuration to use other servers. + +SimpleX clients only determine which server is used to receive the messages, separately for each contact (or group connection with a group member), and these servers are only temporary, as the delivery address can change. + +To create SMP server, you'll need: + +1. VPS or any other server. +2. Your own domain, pointed at the server (`smp.example.com`) +3. A basic Linux knowledge. + +_Please note_: when you change the servers in the app configuration, it only affects which servers will be used for the new contacts, the existing contacts will not automatically move to the new servers, but you can move them manually using ["Change receiving address"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) button in contact/member information pages – it will be automated in the future. ## Quick start -To create SMP server, you'll need: +To create SMP server as a systemd service, you'll need: - VPS or any other server. - Your server domain, with A and AAAA records specifying server IPv4 and IPv6 addresses (`smp1.example.com`) @@ -228,34 +244,43 @@ To create SMP server, you'll need: echo "$smp,$tor" ``` -## Detailed guide +## Installation options -### Overview +You can install SMP server in one of the following ways: -SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp11, smp12 and smp14.simplex.im), but you can easily change app configuration to use other servers. +- [systemd service](#systemd-service) + - using [installation script](#installation-script) - **recommended** + - or [manually](#manual-deployment) +- [Docker container](#docker-container) from DockerHub +- [Linode marketplace](#linode-marketplace) -SimpleX clients only determine which server is used to receive the messages, separately for each contact (or group connection with a group member), and these servers are only temporary, as the delivery address can change. +### systemd service -To create SMP server, you'll need: +#### Installation script -1. VPS or any other server. -2. Your own domain, pointed at the server (`smp.example.com`) -3. A basic Linux knowledge. +This installation script will automatically install binaries, systemd services and additional scripts that will manage backups, updates and uninstallation. This is the recommended option due to its flexibility, easy updating, and being battle tested on our servers. -_Please note_: when you change the servers in the app configuration, it only affects which servers will be used for the new contacts, the existing contacts will not automatically move to the new servers, but you can move them manually using ["Change receiving address"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) button in contact/member information pages – it will be automated in the future. +**Please note** that currently only Ubuntu distribution is supported. -### Installation +Run the following script on the server: -1. First, install `smp-server`: +```sh +curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\ +if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then + chmod +x ./simplex-server-install.sh + ./simplex-server-install.sh + rm ./simplex-server-install.sh +else + echo "SHA-256 checksum is incorrect!" + rm ./simplex-server-install.sh +fi +``` - - Manual deployment (see below) +Type `1` and hit enter to install `smp-server`. - - Semi-automatic deployment: - - [Installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) +#### Manual deployment -Manual installation requires some preliminary actions: +Manual installation is the most advanced deployment that provides the most flexibility. Generally recommended only for advanced users. 1. Install binary: @@ -317,7 +342,180 @@ Manual installation requires some preliminary actions: And execute `sudo systemctl daemon-reload`. -### Configuration +### Docker container + +You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. + +This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. + +This will download images from [Docker Hub](https://hub.docker.com/r/simplexchat). + +#### Docker: Automatic setup + +This configuration provides quick and easy way to setup your SMP server: Caddy will automatically manage Let's Encrypt certificates and redirect HTTP to HTTPS, while smp-server will serve both [server information page](#server-information-page) and SMP Protocol by 443 port. 5223 port is used as fallback. + +**Please note** that you _must_ have `80` and `443` ports unallocated by other servers. + +1. Create `smp-server` directory and switch to it: + + ```sh + mkdir smp-server && cd smp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-smp-complete.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-complete.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - smp-server + + services: + oneshot: + image: ubuntu:latest + environment: + CADDYCONF: | + ${CADDY_OPTS:-} + + http://{$$ADDR} { + redir https://{$$ADDR}{uri} permanent + } + + {$$ADDR}:8443 { + tls { + key_type rsa4096 + } + } + command: sh -c 'if [ ! -f /etc/caddy/Caddyfile ]; then printf "$${CADDYCONF}" > /etc/caddy/Caddyfile; fi' + volumes: + - ./caddy_conf:/etc/caddy + + caddy: + image: caddy:latest + depends_on: + oneshot: + condition: service_completed_successfully + cap_add: + - NET_ADMIN + environment: + ADDR: ${ADDR?"Please specify the domain."} + volumes: + - ./caddy_conf:/etc/caddy + - caddy_data:/data + - caddy_config:/config + ports: + - 80:80 + restart: unless-stopped + healthcheck: + test: "test -d /data/caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR} || exit 1" + interval: 1s + retries: 60 + + smp-server: + image: ${SIMPLEX_IMAGE:-simplexchat/smp-server:latest} + depends_on: + caddy: + condition: service_healthy + environment: + ADDR: ${ADDR?"Please specify the domain."} + PASS: ${PASS:-} + volumes: + - ./smp_configs:/etc/opt/simplex + - ./smp_state:/var/opt/simplex + - type: volume + source: caddy_data + target: /certificates + volume: + subpath: "caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR}" + ports: + - 443:443 + - 5223:5223 + restart: unless-stopped + + volumes: + caddy_data: + caddy_config: + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-smp-complete.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-complete.env). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + + # Optional + #PASS='123123' + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +#### Docker: Manual setup + +If you know what you are doing, this configuration provides bare SMP server setup without automatically managed Let's Encrypt certificates by Caddy to serve [server information page](#server-information-page) with 5223 port set as primary. + +This configuration allows you to retain the ability to manage 80 and 443 ports yourself. As a downside, SMP server **can not* be served to 443 port. + +1. Create `smp-server` directory and switch to it: + + ```sh + mkdir smp-server && cd smp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-smp-manual.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-manual.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - smp-server + + services: + smp-server: + image: ${SIMPLEX_IMAGE:-simplexchat/smp-server:latest} + environment: + WEB_MANUAL: ${WEB_MANUAL:-1} + ADDR: ${ADDR?"Please specify the domain."} + PASS: ${PASS:-} + volumes: + - ./smp_configs:/etc/opt/simplex + - ./smp_state:/var/opt/simplex + ports: + - 5223:5223 + restart: unless-stopped + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-smp-manual.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-manual.env). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + + # Optional + #PASS='123123' + WEB_MANUAL=1 + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +### Linode marketplace + +You can deploy smp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + +## Configuration To see which options are available, execute `smp-server` without flags: @@ -337,7 +535,7 @@ You can get further help by executing `sudo su smp -c "smp-server -h"` After that, we need to configure `smp-server`: -#### Interactively +### Interactively Execute the following command: @@ -367,7 +565,7 @@ These statistics include daily counts of created, secured and deleted queues, se Enter your domain or ip address that your smp-server is running on - it will be included in server certificates and also printed as part of server address. -#### Via command line options +### Via command line options Execute the following command: @@ -431,7 +629,7 @@ Server address: smp://d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss=:V8ONoJ6ICwnrZ The server address above should be used in your client configuration, and if you added server password it should only be shared with the other people who you want to allow using your server to receive the messages (all your contacts will be able to send messages - it does not require a password). If you passed IP address or hostnames during the initialisation, they will be printed as part of server address, otherwise replace `` with the actual server hostnames. -### Further configuration +## Further configuration All generated configuration, along with a description for each parameter, is available inside configuration file in `/etc/opt/simplex/smp-server.ini` for further customization. Depending on the smp-server version, the configuration file looks something like this: @@ -560,9 +758,9 @@ cert: /etc/opt/simplex/web.crt key: /etc/opt/simplex/web.key ``` -### Server security +## Server security -#### Initialization +### Initialization Although it's convenient to initialize smp-server configuration directly on the server, operators **ARE ADVISED** to initialize smp-server fully offline to protect your SMP server CA private key. @@ -582,7 +780,7 @@ Follow the steps to quickly initialize the server offline: rsync -hzasP $HOME/simplex/smp/config/ @:/etc/opt/simplex/ ``` -#### Private keys +### Private keys Connection to the smp server occurs via a TLS connection. During the TLS handshake, the client verifies smp-server CA and server certificates by comparing its fingerprint with the one included in server address. If server TLS credential is compromised, this key can be used to sign a new one, keeping the same server identity and established connections. In order to protect your smp-server from bad actors, operators **ARE ADVISED** to move CA private key to a safe place. That could be: @@ -607,7 +805,7 @@ Follow the steps to secure your CA keys: rm /etc/opt/simplex/ca.key ``` -#### Online certificate rotation +### Online certificate rotation Operators of smp servers **ARE ADVISED** to rotate online certificate regularly (e.g., every 3 months). In order to do this, follow the steps: @@ -683,9 +881,9 @@ Operators of smp servers **ARE ADVISED** to rotate online certificate regularly 10. Done! -### Tor: installation and configuration +## Tor: installation and configuration -#### Installation for onion address +### Installation for onion address SMP-server can also be deployed to be available via [Tor](https://www.torproject.org) network. Run the following commands as `root` user. @@ -766,7 +964,7 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject cat /var/lib/tor/simplex-smp/hostname ``` -#### SOCKS port for SMP PROXY +### SOCKS port for SMP PROXY SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp servers available exclusively through [Tor](https://www.torproject.org) network to be accessible to the clients that do not use Tor. Run the following commands as `root` user. @@ -813,7 +1011,7 @@ SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp ... ``` -### Server information page +## Server information page SMP server **SHOULD** be configured to serve Web page with server information that can include admin info, server info, provider info, etc. It will also serve connection links, generated using the mobile/desktop apps. Run the following commands as `root` user. @@ -995,13 +1193,13 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. 10. Access the webpage you've deployed from your browser (`https://smp.example.org`). You should see the smp-server information that you've provided in your ini file. -### Documentation +## Documentation All necessary files for `smp-server` are located in `/etc/opt/simplex/` folder. Stored messages, connections, statistics and server log are located in `/var/opt/simplex/` folder. -#### SMP server address +### SMP server address SMP server address has the following format: @@ -1021,7 +1219,7 @@ smp://[:]@[,] Your configured hostname(s) of `smp-server`. You can check your configured hosts in `/etc/opt/simplex/smp-server.ini`, under `[TRANSPORT]` section in `host:` field. -#### Systemd commands +### Systemd commands To start `smp-server` on host boot, run: @@ -1080,7 +1278,7 @@ Nov 23 19:23:21 5588ab759e80 smp-server[30878]: not expiring inactive clients Nov 23 19:23:21 5588ab759e80 smp-server[30878]: creating new queues requires password ``` -#### Control port +### Control port Enabling control port in the configuration allows administrator to see information about the smp-server in real-time. Additionally, it allows to delete queues for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. @@ -1154,7 +1352,7 @@ Here's the full list of commands, their descriptions and who can access them. | `help` | Help menu. | - | | `quit` | Exit the control port. | - | -#### Daily statistics +### Daily statistics You can enable `smp-server` statistics for `Grafana` dashboard by setting value `on` in `/etc/opt/simplex/smp-server.ini`, under `[STORE_LOG]` section in `log_stats:` field. @@ -1280,7 +1478,7 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) -### Updating your SMP server +## Updating your SMP server To update your smp-server to latest version, choose your installation method and follow the steps: @@ -1366,7 +1564,7 @@ To update your smp-server to latest version, choose your installation method and docker image prune ``` -### Configuring the app to use the server +## Configuring the app to use the server To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 88428a0dc3..ba4770644e 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -4,6 +4,23 @@ revision: 31.07.2023 --- # Hosting your own XFTP Server +## Table of Contents + +- [Overview](#overview) +- [Installation options](#installation-options) + - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) + - [docker container](#docker-сontainer) + - [Linode marketplace](#linode-marketplace) +- [Tor installation](#tor-installation) +- [Configuration](#configuration) +- [Documentation](#documentation) + - [XFTP server address](#xftp-server-address) + - [Systemd commands](#systemd-commands) + - [Control port](#control-port) + - [Daily statistics](#daily-statistics) +- [Updating your XFTP server](#updating-your-xftp-server) +- [Configuring the app to use the server](#configuring-the-app-to-use-the-server) + ## Overview XFTP is a new file transfer protocol focussed on meta-data protection - it is based on the same principles as SimpleX Messaging Protocol used in SimpleX Chat messenger: @@ -15,20 +32,45 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba - no identifiers or ciphertext in common between sent and received relay traffic, same as for messages delivered by SMP relays. - protection of sender IP address from the recipients. -## Installation +## Installation options -0. First, install `xftp-server`: +You can install XFTP server in one of the following ways: - - Manual deployment (see below) +- [systemd service](#systemd-service) + - using [installation script](#installation-script) - **recommended** + - or [manually](#manual-deployment) +- [Docker container](#docker-container) from DockerHub +- [Linode marketplace](#linode-marketplace) - - Semi-automatic deployment: - - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) +### systemd service -Manual installation requires some preliminary actions: +#### Installation script -0. Install binary: +This installation script will automatically install binaries, systemd services and additional scripts that will manage backups, updates and uninstallation. This is the recommended option due to its flexibility, easy updating, and being battle tested on our servers. + +**Please note** that currently only Ubuntu distribution is supported. + +Run the following script on the server: + +```sh +curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\ +if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then + chmod +x ./simplex-server-install.sh + ./simplex-server-install.sh + rm ./simplex-server-install.sh +else + echo "SHA-256 checksum is incorrect!" + rm ./simplex-server-install.sh +fi +``` + +Type `2` and hit enter to install `xftp-server`. + +#### Manual deployment + +Manual installation is the most advanced deployment that provides the most flexibility. Generally recommended only for advanced users. + +1. Install binary: - Using offical binaries: @@ -41,20 +83,20 @@ Manual installation requires some preliminary actions: Please refer to [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution) -1. Create user and group for `xftp-server`: +2. Create user and group for `xftp-server`: ```sh sudo useradd -m xftp ``` -2. Create necessary directories and assign permissions: +3. Create necessary directories and assign permissions: ```sh sudo mkdir -p /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp sudo chown xftp:xftp /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp ``` -3. Allow xftp-server port in firewall: +4. Allow xftp-server port in firewall: ```sh # For Ubuntu @@ -64,7 +106,7 @@ Manual installation requires some preliminary actions: sudo firewall-cmd --reload ``` -4. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content: +5. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content: ```sh [Unit] @@ -87,6 +129,68 @@ Manual installation requires some preliminary actions: And execute `sudo systemctl daemon-reload`. +### Docker сontainer + +You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. + +This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. + +This will download images from [Docker Hub](https://hub.docker.com/r/simplexchat). + +1. Create `xftp-server` directory and switch to it: + + ```sh + mkdir xftp-server && cd xftp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-xftp.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-xftp.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - xftp-server + + services: + xftp-server: + image: ${SIMPLEX_XFTP_IMAGE:-simplexchat/xftp-server:latest} + environment: + ADDR: ${ADDR?"Please specify the domain."} + QUOTA: ${QUOTA?"Please specify disk quota."} + PASS: ${PASS:-} + volumes: + - ./xftp_configs:/etc/opt/simplex-xftp + - ./xftp_state:/var/opt/simplex-xftp + - ./xftp_files:/srv/xftp + ports: + - 443:443 + restart: unless-stopped + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-xftp.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-xftp.yml). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + QUOTA=120gb + + # Optional + #PASS='123123' + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +### Linode marketplace + +You can deploy xftp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + ## Tor installation xftp-server can also be deployed to serve from [tor](https://www.torproject.org) network. Run the following commands as `root` user. @@ -359,11 +463,11 @@ Feb 27 19:21:11 localhost xftp-server[2350]: Store log: /var/opt/simplex-xftp/fi Feb 27 19:21:11 localhost xftp-server[2350]: Uploading new files allowed. Feb 27 19:21:11 localhost xftp-server[2350]: Listening on port 443... Feb 27 19:21:11 localhost xftp-server[2350]: [INFO 2023-02-27 19:21:11 +0000 src/Simplex/FileTransfer/Server/Env.hs:85] Total / available storage: 64424509440 / 64424509440 -```` +``` ### Control port -Enabling control port in the configuration allows administrator to see information about the smp-server in real-time. Additionally, it allows to delete file chunks for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. +Enabling control port in the configuration allows administrator to see information about the xftp-server in real-time. Additionally, it allows to delete file chunks for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. 1. Generate two passwords for each user: @@ -487,7 +591,7 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) -# Updating your XFTP server +## Updating your XFTP server To update your XFTP server to latest version, choose your installation method and follow the steps: @@ -545,6 +649,6 @@ To update your XFTP server to latest version, choose your installation method an docker image prune ``` -### Configuring the app to use the server +## Configuring the app to use the server Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server). From f7d133a63ca68594fe56297dec545560818c5316 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 Jan 2025 13:32:56 +0000 Subject: [PATCH 021/289] Revert "ui: disable report item feature (#5498)" This reverts commit 748287b724d4e3d3cb130e85d85f7dd5cf29d53e. --- apps/ios/Shared/Views/Chat/ChatView.swift | 6 +++--- .../chat/simplex/common/views/chat/item/ChatItemView.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index baceb5b4ab..768244de8e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1343,9 +1343,9 @@ struct ChatView: View { if ci.chatDir != .groupSnd { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) - } // else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { - // reportButton(ci) - // } + } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { + reportButton(ci) + } } } else if ci.meta.itemDeleted != nil { if revealed { 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 3b2bf63f28..e0650e1d80 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 @@ -400,9 +400,9 @@ fun ChatItemView( val groupInfo = cItem.memberToModerate(cInfo)?.first if (groupInfo != null) { ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) - } // else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { - // ReportItemAction(cItem, composeState, showMenu) - // } + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + ReportItemAction(cItem, composeState, showMenu) + } } if (cItem.canBeDeletedForSelf) { Divider() From 13324801706a00429e161a54db338b95d654390b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 18:47:38 +0400 Subject: [PATCH 022/289] scripts: sqlite to postgres migration instruction (#5504) --- scripts/db/README.md | 85 ++++++++++++++++++++++++++++++++++++++++++ scripts/db/sqlite.load | 20 ++++++++++ 2 files changed, 105 insertions(+) create mode 100644 scripts/db/README.md create mode 100644 scripts/db/sqlite.load diff --git a/scripts/db/README.md b/scripts/db/README.md new file mode 100644 index 0000000000..067aa35400 --- /dev/null +++ b/scripts/db/README.md @@ -0,0 +1,85 @@ +# Transfer data from SQLite to Postgres database + +1. \* Decrypt SQLite database if it is encrypted. + + ```sh + sqlcipher encrypted_simplex_v1_agent.db + ``` + + ```sql + PRAGMA key = 'password'; + ATTACH DATABASE 'simplex_v1_agent.db' AS plaintext KEY ''; + SELECT sqlcipher_export('plaintext'); + DETACH DATABASE plaintext; + ``` + + Repeat for `simplex_v1_chat.db`. + +2. Prepare Postgres database. + + Build `simplex-chat` executable with `client_postgres` flag and run it to initialize new Postgres chat database. + + This should create `simplex_v1` database with `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas, and `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. + +3. Load data from decrypted SQLite databases to Postgres database via pgloader. + + Install pgloader and add it to PATH. + + ```sh + SQLITE_DBPATH='simplex_v1_agent.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_agent_schema' pgloader --on-error-stop sqlite.load + + SQLITE_DBPATH='simplex_v1_chat.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_chat_schema' pgloader --on-error-stop sqlite.load + ``` + +4. Update sequences for Postgres tables. + + ```sql + DO $$ + DECLARE + rec RECORD; + BEGIN + EXECUTE 'SET SEARCH_PATH TO simplex_v1_agent_schema'; + + FOR rec IN + SELECT + table_name, + column_name, + pg_get_serial_sequence(table_name, column_name) AS seq_name + FROM + information_schema.columns + WHERE + table_schema = 'simplex_v1_agent_schema' + AND identity_generation = 'ALWAYS' + LOOP + EXECUTE format( + 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', + rec.seq_name, rec.column_name, rec.table_name + ); + END LOOP; + END $$; + ``` + + Repeat for `simplex_v1_chat_schema`. + +5. Compare number of rows between Postgres and SQLite tables. + + To check number of rows for all tables in Postgres database schema run: + + ```sql + WITH tbl AS ( + SELECT table_schema, table_name + FROM information_schema.Tables + WHERE table_name NOT LIKE 'pg_%' + AND table_schema IN ('simplex_v1_agent_schema') + ) + SELECT + table_schema AS schema_name, + table_name, + (xpath('/row/c/text()', query_to_xml( + format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' + )))[1]::text::int AS records_count + FROM tbl + ORDER BY records_count DESC; + ``` + + Repeat for `simplex_v1_chat_schema`. diff --git a/scripts/db/sqlite.load b/scripts/db/sqlite.load new file mode 100644 index 0000000000..105e8c39d2 --- /dev/null +++ b/scripts/db/sqlite.load @@ -0,0 +1,20 @@ +LOAD DATABASE + FROM {{SQLITE_DBPATH}} + INTO {{POSTGRES_CONN}} + + WITH include no drop, + truncate, + disable triggers, + create no tables, + create no indexes, + -- pgloader implementation doesn't find "GENERATED ALWAYS AS IDENTITY" sequences, + -- instead we reset sequences manually via custom query after load + reset no sequences, + data only + + EXCLUDING TABLE NAMES LIKE 'migrations', 'sqlite_sequence' + + SET work_mem to '16MB', + maintenance_work_mem to '512 MB', + search_path to '{{POSTGRES_SCHEMA}}' +; From 9e000d6bce18594f4f6aa09af57a6838a0772831 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 18:47:59 +0400 Subject: [PATCH 023/289] core: optimize group deletion (#5565) * core: optimize group deletion * withFastStore * fix indexes * updated plans * remove prints * remove print * undo diff * core: optimize group delete - delayed group cleanup, delete unused contacts before deleting group (#5579) * core: delete unused group contacts, don't create new ones * remove from exceptions * plans * fix tests * remove fixtures * update plans * update plans * fix test * remove unused functino * update plans * remove withFastStore * core: time group deletion (#5596) * core: time group deletion * queries * works, test fails * fix * update plans * update migration, queries * not null * remove deleted --------- Co-authored-by: Evgeny Poberezkin * cleanup * remove unused field * fix * fix * plans * fix plan save * plans --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/SimpleXChat/ChatTypes.swift | 2 - .../chat/simplex/common/model/ChatModel.kt | 2 - simplex-chat.cabal | 1 + src/Simplex/Chat/Library/Commands.hs | 14 +- src/Simplex/Chat/Library/Internal.hs | 7 +- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 11 +- src/Simplex/Chat/Store/Groups.hs | 152 +++++++++------ .../Postgres/Migrations/M20241220_initial.hs | 31 ++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20250129_delete_unused_contacts.hs | 2 + .../SQLite/Migrations/M20250130_indexes.hs | 36 ++++ .../Store/SQLite/Migrations/chat_lint.sql | 2 - .../SQLite/Migrations/chat_query_plans.txt | 176 +++++++++++------- .../Store/SQLite/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 1 - tests/SchemaDump.hs | 22 +-- 18 files changed, 298 insertions(+), 186 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ae49ee3f3f..5002de4209 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1938,7 +1938,6 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var businessChat: BusinessChatInfo? public var fullGroupPreferences: FullGroupPreferences public var membership: GroupMember - public var hostConnCustomUserProfileId: Int64? public var chatSettings: ChatSettings var createdAt: Date var updatedAt: Date @@ -1974,7 +1973,6 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { groupProfile: GroupProfile.sampleData, fullGroupPreferences: FullGroupPreferences.sampleData, membership: GroupMember.sampleData, - hostConnCustomUserProfileId: nil, chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .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 c3bea3be90..e9b1b3ae66 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 @@ -1721,7 +1721,6 @@ data class GroupInfo ( val businessChat: BusinessChatInfo? = null, val fullGroupPreferences: FullGroupPreferences, val membership: GroupMember, - val hostConnCustomUserProfileId: Long? = null, val chatSettings: ChatSettings, override val createdAt: Instant, override val updatedAt: Instant, @@ -1770,7 +1769,6 @@ data class GroupInfo ( groupProfile = GroupProfile.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, - hostConnCustomUserProfileId = null, chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), updatedAt = Clock.System.now(), diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4511131dbf..5b6175679c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -223,6 +223,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts + Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4accff2471..ed30f5bcc8 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1076,12 +1076,12 @@ processChatCommand' vr = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user chatId + Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user chatId let GroupMember {memberRole = membershipMemRole} = membership let isOwner = membershipMemRole == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner - filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo + filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId . procCmd $ do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo @@ -1090,11 +1090,10 @@ processChatCommand' vr = \case deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () - -- functions below are called in separate transactions to prevent crashes on android - -- (possibly, race condition on integrity check?) - withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members - withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members - withStore' $ \db -> deleteGroup db user gInfo + withFastStore' $ \db -> deleteGroupChatItems db user gInfo + withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo + 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" @@ -3541,6 +3540,7 @@ cleanupManager = do cleanupUser cleanupInterval stepDelay user = do cleanupTimedItems cleanupInterval user `catchChatError` (toView . CRChatError (Just user)) liftIO $ threadDelay' stepDelay + -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted cleanupDeletedContacts user `catchChatError` (toView . CRChatError (Just user)) liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 8cdad6cd60..faf503eb83 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -61,6 +61,7 @@ import Simplex.Chat.Operators import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store +import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups @@ -1200,11 +1201,9 @@ deleteMembersConnections user members = deleteMembersConnections' user members F deleteMembersConnections' :: User -> [GroupMember] -> Bool -> CM () deleteMembersConnections' user members waitDelivery = do - let memberConns = - filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ - mapMaybe (\GroupMember {activeConn} -> activeConn) members + let memberConns = mapMaybe (\GroupMember {activeConn} -> activeConn) members deleteAgentConnectionsAsync' user (map aConnId memberConns) waitDelivery - lift . void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns + lift . void . withStoreBatch' $ \db -> map (\Connection {connId} -> deleteConnectionRecord db user connId) memberConns deleteMemberConnection :: User -> GroupMember -> CM () deleteMemberConnection user mem = deleteMemberConnection' user mem False diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 589b8e39f2..dce5ca0a79 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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 98d7374769..d473c81758 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -35,7 +35,6 @@ module Simplex.Chat.Store.Direct deleteContactFiles, deleteContact, deleteContactWithoutGroups, - setContactDeleted, getDeletedContacts, getContactByName, getContact, @@ -294,7 +293,7 @@ deleteContact db user@User {userId} ct@Contact {contactId, localDisplayName, act assertNotUser db user ct liftIO $ do DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) + ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE contact_id = ? LIMIT 1" (Only contactId) if isNothing ctMember then do deleteContactProfile_ db userId contactId @@ -322,13 +321,7 @@ deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDis forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId -setContactDeleted :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () -setContactDeleted db user@User {userId} ct@Contact {contactId} = do - assertNotUser db user ct - liftIO $ do - currentTs <- getCurrentTime - DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) - +-- TODO remove in future versions: only used for legacy contact cleanup getDeletedContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact] getDeletedContacts db vr user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index b97c28a510..578cd61677 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -55,16 +55,15 @@ module Simplex.Chat.Store.Groups getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, - deleteGroupConnectionsAndFiles, - deleteGroupItemsAndMembers, + deleteGroupChatItems, + deleteGroupMembers, + cleanupHostGroupLinkConn, deleteGroup, - getUserGroups, getUserGroupsToSubscribe, getUserGroupDetails, getUserGroupsWithSummary, getGroupSummary, getContactGroupPreferences, - checkContactHasGroups, getGroupInvitation, createNewContactMember, createNewContactMemberAsync, @@ -275,7 +274,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, @@ -347,7 +346,6 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc businessChat = Nothing, fullGroupPreferences, membership, - hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, @@ -362,7 +360,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do +createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -399,11 +397,11 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ db [sql| INSERT INTO groups - (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, connRequest, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange @@ -418,7 +416,6 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ businessChat = Nothing, fullGroupPreferences, membership, - hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, @@ -546,11 +543,11 @@ createGroupInvitedViaLink db [sql| INSERT INTO groups - (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db insertHost_ currentTs groupId = do let fromMemberProfile = profileFromName fromMemberName @@ -639,33 +636,75 @@ getGroupToSubscribe db User {userId, userContactId} groupId = do toShortMember (groupMemberId, localDisplayName, agentConnId) = ShortGroupMember groupMemberId groupId localDisplayName agentConnId -deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () -deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do - forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m) - DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ?" (userId, groupId) - -deleteGroupItemsAndMembers :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () -deleteGroupItemsAndMembers db user@User {userId} g@GroupInfo {groupId} members = do +deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () +deleteGroupChatItems db User {userId} GroupInfo {groupId} = DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) - void $ runExceptT cleanupHostGroupLinkConn_ -- to allow repeat connection via the same group link if one was used + +deleteGroupMembers :: DB.Connection -> User -> GroupInfo -> IO () +deleteGroupMembers db User {userId} GroupInfo {groupId} = do + DB.execute_ db "DROP TABLE IF EXISTS temp_delete_members" + #if defined(dbPostgres) + DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id BIGINT, member_profile_id BIGINT, local_display_name TEXT)" + #else + DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" + #endif + DB.execute + db + [sql| + INSERT INTO temp_delete_members (contact_profile_id, member_profile_id, local_display_name) + SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? + |] + (Only groupId) DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId) - forM_ members $ cleanupMemberProfileAndName_ db user - forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId - where - cleanupHostGroupLinkConn_ = do - hostId <- getHostMemberId_ db user groupId - liftIO $ - DB.execute - db - [sql| - UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL - WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( - SELECT contact_id - FROM group_members - WHERE user_id = ? AND group_member_id = ? - ) - |] - (userId, userId, hostId) + DB.execute + db + [sql| + DELETE FROM contact_profiles + WHERE + user_id = ? + AND (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_members) + OR contact_profile_id IN (SELECT member_profile_id FROM temp_delete_members WHERE member_profile_id IS NOT NULL)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections) + |] + (Only userId) + DB.execute + db + [sql| + DELETE FROM display_names + WHERE + user_id = ? + AND local_display_name IN (SELECT local_display_name FROM temp_delete_members) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests) + |] + (Only userId) + DB.execute_ db "DROP TABLE temp_delete_members" + +-- to allow repeat connection via the same group link if one was used +cleanupHostGroupLinkConn :: DB.Connection -> User -> GroupInfo -> IO () +cleanupHostGroupLinkConn db user@User {userId} GroupInfo {groupId} = do + runExceptT (getHostMemberId_ db user groupId) >>= \case + Left _ -> pure () + Right hostId -> + DB.execute + db + [sql| + UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + |] + (userId, userId, hostId) deleteGroup :: DB.Connection -> User -> GroupInfo -> IO () deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do @@ -688,11 +727,6 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroups :: DB.Connection -> VersionRangeChat -> User -> IO [Group] -getUserGroups db vr user@User {userId} = do - groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) - rights <$> mapM (runExceptT . getGroup db vr user) groupIds - getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] getUserGroupsToSubscribe db user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) @@ -707,7 +741,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, 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 @@ -763,10 +797,6 @@ getContactGroupPreferences db User {userId} Contact {contactId} = do |] (userId, contactId) -checkContactHasGroups :: DB.Connection -> User -> Contact -> IO (Maybe GroupId) -checkContactHasGroups db User {userId} Contact {contactId} = - maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) - getGroupInfoByName :: DB.Connection -> VersionRangeChat -> User -> GroupName -> ExceptT StoreError IO GroupInfo getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName @@ -801,19 +831,21 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention getMentionedGroupMember db User {userId} groupId gmId = - ExceptT $ firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ - DB.query - db - (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") - (groupId, gmId, userId) + ExceptT $ + firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") + (groupId, gmId, userId) getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MsgMention -> IO CIMention getMentionedMemberByMemberId db User {userId} groupId MsgMention {memberId} = - fmap (fromMaybe mentionedMember) $ maybeFirstRow toMentionedMember $ - DB.query - db - (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") - (groupId, memberId, userId) + fmap (fromMaybe mentionedMember) $ + maybeFirstRow toMentionedMember $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") + (groupId, memberId, userId) where mentionedMember = CIMention {memberId, memberRef = Nothing} @@ -1470,7 +1502,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index 48d35b23d2..08ce2d81bc 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -128,7 +128,6 @@ CREATE TABLE groups( updated_at TIMESTAMPTZ NOT NULL, chat_item_id BIGINT DEFAULT NULL, enable_ntfs SMALLINT, - host_conn_custom_user_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, unread_chat SMALLINT NOT NULL DEFAULT 0, chat_ts TIMESTAMPTZ, favorite SMALLINT NOT NULL DEFAULT 0, @@ -692,7 +691,6 @@ CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); -CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( user_id, contact_id, @@ -765,9 +763,6 @@ CREATE INDEX idx_group_members_contact_profile_id ON group_members( CREATE INDEX idx_group_members_user_id ON group_members(user_id); CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); -CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( - host_conn_custom_user_profile_id -); CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); CREATE INDEX idx_messages_group_id ON messages(group_id); @@ -1034,7 +1029,27 @@ CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_s group_member_id ); CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); -CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); -CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); -CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions( + chat_item_id +); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions( + chat_item_id, + display_name +); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions( + chat_item_id, + member_id +); +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( + user_id, + group_id, + item_status, + user_mention +); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( + group_id, + shared_msg_id +); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 00d90ebf82..5865cd180e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -127,6 +127,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history 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.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -253,7 +254,8 @@ schemaMigrations = ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), ("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) + ("20250129_delete_unused_contacts", m20250129_delete_unused_contacts, Just down_m20250129_delete_unused_contacts), + ("20250130_indexes", m20250130_indexes, Just down_m20250130_indexes) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs index 4ce7a0a793..91b3f688c1 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs @@ -43,12 +43,14 @@ WHERE OR contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_contacts)) AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections); DELETE FROM display_names WHERE local_display_name IN (SELECT local_display_name FROM temp_delete_contacts) AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) AND local_display_name NOT IN (SELECT local_display_name FROM users) AND local_display_name NOT IN (SELECT local_display_name FROM groups) AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs new file mode 100644 index 0000000000..c0dbd05fcf --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250130_indexes :: Query +m20250130_indexes = + [sql| +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); + +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_chat_items_shared_msg_id; +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items(group_id, shared_msg_id); + + +DROP INDEX idx_groups_host_conn_custom_user_profile_id; +ALTER TABLE groups DROP COLUMN host_conn_custom_user_profile_id; +|] + +down_m20250130_indexes :: Query +down_m20250130_indexes = + [sql| +ALTER TABLE groups ADD COLUMN host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups(host_conn_custom_user_profile_id); + + +DROP INDEX idx_chat_items_group_id_shared_msg_id; +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); + +DROP INDEX idx_connections_group_member_id; + +DROP INDEX idx_chat_items_group_id; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql index 86047070fb..e69de29bb2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql @@ -1,2 +0,0 @@ -CREATE INDEX 'chat_items_group_id' ON 'chat_items'('group_id'); --> groups(group_id) -CREATE INDEX 'connections_group_member_id' ON 'connections'('group_member_id'); --> group_members(group_member_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 411878f34c..42462038a9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -9,7 +9,7 @@ Plan: Query: INSERT INTO groups - (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) @@ -17,9 +17,9 @@ Plan: Query: INSERT INTO groups - (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, @@ -605,19 +605,6 @@ 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 connections SET via_contact_uri_hash = NULL, xcontact_id = NULL - WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( - SELECT contact_id - FROM group_members - WHERE user_id = ? AND group_member_id = ? - ) - -Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) -LIST SUBQUERY 1 -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 = ? @@ -742,7 +729,7 @@ SEARCH i USING INTEGER PRIMARY KEY (rowid=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) 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_shared_msg_id (shared_msg_id=?) LEFT-JOIN +SEARCH ri USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) LEFT-JOIN SEARCH rm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH rp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH dbm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN @@ -795,7 +782,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, @@ -838,7 +825,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, 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 @@ -951,7 +938,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: SELECT chat_item_id @@ -961,8 +948,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) -USE TEMP B-TREE FOR ORDER BY +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: SELECT chat_item_id, contact_id, group_id, note_folder_id @@ -1115,7 +1101,7 @@ Query: Plan: SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) -SEARCH i USING INDEX idx_chat_items_shared_msg_id (shared_msg_id=?) +SEARCH i USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) Query: SELECT i.chat_item_id @@ -1126,9 +1112,8 @@ Query: LIMIT 1 Plan: -SEARCH i USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) +SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -USE TEMP B-TREE FOR ORDER BY Query: SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id @@ -1193,6 +1178,19 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +LIST SUBQUERY 1 +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contacts SET local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -2847,8 +2845,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) -USE TEMP B-TREE FOR ORDER BY +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: SELECT chat_item_id @@ -3475,6 +3472,35 @@ SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=? SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +Query: + DELETE FROM contact_profiles + WHERE + user_id = ? + AND (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_members) + OR contact_profile_id IN (SELECT member_profile_id FROM temp_delete_members WHERE member_profile_id IS NOT NULL)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections) + +Plan: +SEARCH contact_profiles USING COVERING INDEX idx_contact_profiles_user_id (user_id=?) +LIST SUBQUERY 1 +SCAN temp_delete_members +LIST SUBQUERY 2 +SCAN temp_delete_members +USING INDEX idx_group_members_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_group_members_member_profile_id FOR IN-OPERATOR +USING INDEX idx_contacts_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_contact_requests_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_connections_custom_user_profile_id FOR IN-OPERATOR +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + Query: DELETE FROM contact_profiles WHERE contact_profile_id in ( @@ -3491,7 +3517,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3510,7 +3535,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3531,7 +3555,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3552,7 +3575,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3591,7 +3613,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3604,7 +3625,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3629,9 +3649,41 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) +Query: + DELETE FROM display_names + WHERE + user_id = ? + AND local_display_name IN (SELECT local_display_name FROM temp_delete_members) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SCAN temp_delete_members +LIST SUBQUERY 2 +SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name +LIST SUBQUERY 3 +SCAN contacts USING COVERING INDEX sqlite_autoindex_contacts_1 +USING INDEX sqlite_autoindex_users_2 FOR IN-OPERATOR +LIST SUBQUERY 5 +SCAN groups USING COVERING INDEX sqlite_autoindex_groups_1 +LIST SUBQUERY 6 +SCAN user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 +LIST SUBQUERY 7 +SCAN contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM display_names WHERE user_id = ? @@ -3931,6 +3983,13 @@ Query: Plan: +Query: + INSERT INTO temp_delete_members (contact_profile_id, member_profile_id, local_display_name) + SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? + +Plan: +SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=?) + Query: SELECT chat_item_id, timed_ttl FROM chat_items @@ -4341,7 +4400,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, @@ -4362,7 +4421,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, @@ -4817,6 +4876,9 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_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 +Query: CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT) +Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)": table temp_delete_members already exists + Query: DELETE FROM app_settings Plan: @@ -4932,16 +4994,6 @@ SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=? SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) -Query: DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ? -Plan: -SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) - Query: DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) @@ -4982,14 +5034,6 @@ SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) -Query: DELETE FROM files WHERE user_id = ? AND group_id = ? -Plan: -SEARCH files USING INDEX idx_files_group_id (group_id=?) -SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) -SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) -SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) - Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) @@ -5005,7 +5049,7 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_mem SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SCAN connections USING COVERING INDEX idx_connections_group_member +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) @@ -5028,7 +5072,7 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_mem SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SCAN connections USING COVERING INDEX idx_connections_group_member +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) @@ -5044,7 +5088,7 @@ SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_grou SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group_id (group_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_group_id (fwd_from_group_id=?) -SCAN chat_items USING COVERING INDEX idx_chat_items_groups_user_mention +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_id (group_id=?) SEARCH messages USING COVERING INDEX idx_messages_group_id (group_id=?) SEARCH user_contact_links USING COVERING INDEX idx_user_contact_links_group_id (group_id=?) SEARCH files USING COVERING INDEX idx_files_group_id (group_id=?) @@ -5172,9 +5216,15 @@ SEARCH contact_profiles USING COVERING INDEX idx_contact_profiles_user_id (user_ Query: DROP TABLE IF EXISTS temp_conn_ids Plan: +Query: DROP TABLE IF EXISTS temp_delete_members +Plan: + Query: DROP TABLE temp_conn_ids Plan: +Query: DROP TABLE temp_delete_members +Plan: + Query: INSERT INTO app_settings (app_settings) VALUES (?) Plan: @@ -5399,9 +5449,9 @@ Query: SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name Plan: SEARCH contacts USING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) -Query: SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 +Query: SELECT contact_id FROM group_members WHERE contact_id = ? LIMIT 1 Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) Query: SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ? Plan: @@ -5451,10 +5501,6 @@ Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contac Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 -Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) - Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ? Plan: SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) @@ -5549,7 +5595,7 @@ SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3 Plan: -SCAN connections +SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id=?) Query: UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ? Plan: diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 58374fe51c..f4cf2bccb8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -120,7 +120,6 @@ CREATE TABLE groups( updated_at TEXT CHECK(updated_at NOT NULL), chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL, enable_ntfs INTEGER, - host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL), chat_ts TEXT, favorite INTEGER NOT NULL DEFAULT 0, @@ -658,7 +657,6 @@ CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); -CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( user_id, contact_id, @@ -731,9 +729,6 @@ CREATE INDEX idx_group_members_contact_profile_id ON group_members( CREATE INDEX idx_group_members_user_id ON group_members(user_id); CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); -CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( - host_conn_custom_user_profile_id -); CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); CREATE INDEX idx_messages_group_id ON messages(group_id); @@ -1017,3 +1012,9 @@ CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( item_status, user_mention ); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( + group_id, + shared_msg_id +); diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6688f4cae6..a28f4251f9 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -577,18 +577,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 ProfileId, 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) :. 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)) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, hostConnCustomUserProfileId, 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) :. 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, hostConnCustomUserProfileId, 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} 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)) = @@ -610,7 +610,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.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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, -- 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, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9c425c059b..89f8c94d51 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -408,7 +408,6 @@ data GroupInfo = GroupInfo businessChat :: Maybe BusinessChatInfo, fullGroupPreferences :: FullGroupPreferences, membership :: GroupMember, - hostConnCustomUserProfileId :: Maybe ProfileId, chatSettings :: ChatSettings, createdAt :: UTCTime, updatedAt :: UTCTime, diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index c791c8bdac..6c429e2a56 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -41,19 +41,6 @@ testAgentDB = "tests/tmp/test_agent.db" appSchema :: FilePath appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" --- Some indexes found by `.lint fkey-indexes` are not added to schema, explanation: --- --- - CREATE INDEX 'chat_items_group_id' ON 'chat_items'('group_id'); --> groups(group_id) --- --- Covering index is used instead. See for example: --- EXPLAIN QUERY PLAN DELETE FROM groups; --- (uses idx_chat_items_groups_item_status) --- --- - CREATE INDEX 'connections_group_member_id' ON 'connections'('group_member_id'); --> group_members(group_member_id) --- --- Covering index is used instead. See for example: --- EXPLAIN QUERY PLAN DELETE FROM group_members; --- (uses idx_connections_group_member) appLint :: FilePath appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" @@ -136,7 +123,9 @@ skipComparisonForDownMigrations = -- sequence table moves down to the end of the file "20241023_chat_item_autoincrement_id", -- indexes move down to the end of the file - "20241125_indexes" + "20241125_indexes", + -- indexes move down to the end of the file + "20250130_indexes" ] getSchema :: FilePath -> FilePath -> IO String @@ -158,7 +147,10 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS appChatQueryPlans chatQueryStats (createChatStore (DBOpts testDB "" False True TQOff) MCError) - (`DB.execute_` "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)") + (\db -> do + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" + ) (agentSavedPlans, agentSavedPlans') <- updatePlans appAgentQueryPlans From 5591b72febd4012702349dd4377da52fed7e7000 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 22:28:32 +0400 Subject: [PATCH 024/289] ui: show spinner on chat deletion (#5597) * android: show spinner on group deletion * ios --- .../Views/ChatList/ChatPreviewView.swift | 132 ++++++------ .../common/views/chatlist/ChatPreviewView.kt | 202 +++++++++--------- 2 files changed, 175 insertions(+), 159 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 654bb56441..51454cc764 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -27,72 +27,80 @@ struct ChatPreviewView: View { var body: some View { let cItem = chat.chatItems.last - return HStack(spacing: 8) { - ZStack(alignment: .bottomTrailing) { - ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) - chatPreviewImageOverlayIcon() - .padding([.bottom, .trailing], 1) - } - .padding(.leading, 4) - - let chatTs = if let cItem { - cItem.meta.itemTs - } else { - chat.chatInfo.chatTs - } - VStack(spacing: 0) { - HStack(alignment: .top) { - chatPreviewTitle() - Spacer() - (formatTimestampText(chatTs)) - .font(.subheadline) - .frame(minWidth: 60, alignment: .trailing) - .foregroundColor(theme.colors.secondary) - .padding(.top, 4) + return ZStack { + HStack(spacing: 8) { + ZStack(alignment: .bottomTrailing) { + ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) + chatPreviewImageOverlayIcon() + .padding([.bottom, .trailing], 1) } - .padding(.bottom, 4) - .padding(.horizontal, 8) - - ZStack(alignment: .topTrailing) { - let chat = activeContentPreview?.chat ?? chat - let ci = activeContentPreview?.ci ?? chat.chatItems.last - let mc = ci?.content.msgContent - HStack(alignment: .top) { - let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil - let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil - if let ci, showContentPreview { - chatItemContentPreview(chat, ci) - } - let mcIsVoice = switch mc { case .voice: true; default: false } - if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id { - let hasFilePreview = if case .file = mc { true } else { false } - chatMessagePreview(cItem, hasFilePreview) - } else { - Spacer() - chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing) - } - } - .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in - checkActiveContentPreview(chat, ci, mc) - } - .onChange(of: activeContentPreview) { _ in - checkActiveContentPreview(chat, ci, mc) - } - .onChange(of: showFullscreenGallery) { _ in - checkActiveContentPreview(chat, ci, mc) - } - chatStatusImage() - .padding(.top, dynamicChatInfoSize * 1.44) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 8) + .padding(.leading, 4) - Spacer() + let chatTs = if let cItem { + cItem.meta.itemTs + } else { + chat.chatInfo.chatTs + } + VStack(spacing: 0) { + HStack(alignment: .top) { + chatPreviewTitle() + Spacer() + (formatTimestampText(chatTs)) + .font(.subheadline) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(theme.colors.secondary) + .padding(.top, 4) + } + .padding(.bottom, 4) + .padding(.horizontal, 8) + + ZStack(alignment: .topTrailing) { + let chat = activeContentPreview?.chat ?? chat + let ci = activeContentPreview?.ci ?? chat.chatItems.last + let mc = ci?.content.msgContent + HStack(alignment: .top) { + let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil + let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil + if let ci, showContentPreview { + chatItemContentPreview(chat, ci) + } + let mcIsVoice = switch mc { case .voice: true; default: false } + if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id { + let hasFilePreview = if case .file = mc { true } else { false } + chatMessagePreview(cItem, hasFilePreview) + } else { + Spacer() + chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing) + } + } + .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in + checkActiveContentPreview(chat, ci, mc) + } + .onChange(of: activeContentPreview) { _ in + checkActiveContentPreview(chat, ci, mc) + } + .onChange(of: showFullscreenGallery) { _ in + checkActiveContentPreview(chat, ci, mc) + } + chatStatusImage() + .padding(.top, dynamicChatInfoSize * 1.44) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 8) + + Spacer() + } + .frame(maxHeight: .infinity) + } + .opacity(deleting ? 0.4 : 1) + .padding(.bottom, -8) + + if deleting { + ProgressView() + .scaleEffect(2) } - .frame(maxHeight: .infinity) } - .padding(.bottom, -8) .onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in deleting = contains // Stop voice when deleting the chat 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 ba7334522a..63a0d6fc7f 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 @@ -366,106 +366,114 @@ fun ChatPreviewView( } } - Row { - Box(contentAlignment = Alignment.BottomEnd) { - ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier) - Box(Modifier.padding(end = 6.sp.toDp(), bottom = 6.sp.toDp())) { - chatPreviewImageOverlayIcon() + Box(contentAlignment = Alignment.Center) { + Row { + Box(contentAlignment = Alignment.BottomEnd) { + ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier) + Box(Modifier.padding(end = 6.sp.toDp(), bottom = 6.sp.toDp())) { + chatPreviewImageOverlayIcon() + } + } + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Row { + Box(Modifier.weight(1f)) { + chatPreviewTitle() + } + Spacer(Modifier.width(8.sp.toDp())) + val ts = getTimestampText(chat.chatItems.lastOrNull()?.meta?.itemTs ?: chat.chatInfo.chatTs) + ChatListTimestampView(ts) + } + Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) { + Row(Modifier.padding(top = 3.sp.toDp()).weight(1f)) { + val activeVoicePreview: MutableState<(ActiveVoicePreview)?> = remember(chat.id) { mutableStateOf(null) } + val chat = activeVoicePreview.value?.chat ?: chat + val ci = activeVoicePreview.value?.ci ?: chat.chatItems.lastOrNull() + val mc = ci?.content?.msgContent + val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null + val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null + if (ci != null && showContentPreview) { + chatItemContentPreview(chat, ci) + } + if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { + Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) { + chatPreviewText() + } + } + LaunchedEffect(AudioPlayer.currentlyPlaying.value, activeVoicePreview.value) { + val playing = AudioPlayer.currentlyPlaying.value + when { + playing == null -> activeVoicePreview.value = null + activeVoicePreview.value == null -> if (mc is MsgContent.MCVoice && playing.fileSource.filePath == ci.file?.fileSource?.filePath) { + activeVoicePreview.value = ActiveVoicePreview(chat, ci, mc) + } + + else -> if (playing.fileSource.filePath != ci?.file?.fileSource?.filePath) { + activeVoicePreview.value = null + } + } + } + LaunchedEffect(chatModel.deletedChats.value) { + val voicePreview = activeVoicePreview.value + // Stop voice when deleting the chat + if (chatModel.deletedChats.value.contains(chatModel.remoteHostId() to chat.id) && voicePreview?.ci != null) { + AudioPlayer.stop(voicePreview.ci) + } + } + } + + Spacer(Modifier.width(8.sp.toDp())) + + Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { + val n = chat.chatStats.unreadCount + val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) + if (n > 0 || chat.chatStats.unreadChat) { + 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()) + ) + } else if (showNtfsIcon) { + Icon( + painterResource(MR.images.ic_notifications_off_filled), + contentDescription = generalGetString(MR.strings.notifications), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .padding(start = 2.sp.toDp()) + .size(18.sp.toDp()) + .offset(x = 2.5.sp.toDp(), y = 2.sp.toDp()) + ) + } else if (chat.chatInfo.chatSettings?.favorite == true) { + Icon( + painterResource(MR.images.ic_star_filled), + contentDescription = generalGetString(MR.strings.favorite_chat), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(20.sp.toDp()) + .offset(x = 2.5.sp.toDp()) + ) + } + Box( + Modifier.offset(y = 28.sp.toDp()), + contentAlignment = Alignment.Center + ) { + chatStatusImage() + } + } + } } } - Spacer(Modifier.width(8.dp)) - Column(Modifier.weight(1f)) { - Row { - Box(Modifier.weight(1f)) { - chatPreviewTitle() - } - Spacer(Modifier.width(8.sp.toDp())) - val ts = getTimestampText(chat.chatItems.lastOrNull()?.meta?.itemTs ?: chat.chatInfo.chatTs) - ChatListTimestampView(ts) - } - Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) { - Row(Modifier.padding(top = 3.sp.toDp()).weight(1f)) { - val activeVoicePreview: MutableState<(ActiveVoicePreview)?> = remember(chat.id) { mutableStateOf(null) } - val chat = activeVoicePreview.value?.chat ?: chat - val ci = activeVoicePreview.value?.ci ?: chat.chatItems.lastOrNull() - val mc = ci?.content?.msgContent - val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null - val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null - if (ci != null && showContentPreview) { - chatItemContentPreview(chat, ci) - } - if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { - Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) { - chatPreviewText() - } - } - LaunchedEffect(AudioPlayer.currentlyPlaying.value, activeVoicePreview.value) { - val playing = AudioPlayer.currentlyPlaying.value - when { - playing == null -> activeVoicePreview.value = null - activeVoicePreview.value == null -> if (mc is MsgContent.MCVoice && playing.fileSource.filePath == ci.file?.fileSource?.filePath) { - activeVoicePreview.value = ActiveVoicePreview(chat, ci, mc) - } - else -> if (playing.fileSource.filePath != ci?.file?.fileSource?.filePath) { - activeVoicePreview.value = null - } - } - } - LaunchedEffect(chatModel.deletedChats.value) { - val voicePreview = activeVoicePreview.value - // Stop voice when deleting the chat - if (chatModel.deletedChats.value.contains(chatModel.remoteHostId() to chat.id) && voicePreview?.ci != null) { - AudioPlayer.stop(voicePreview.ci) - } - } - } - Spacer(Modifier.width(8.sp.toDp())) - - Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { - val n = chat.chatStats.unreadCount - val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) - if (n > 0 || chat.chatStats.unreadChat) { - 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()) - ) - } else if (showNtfsIcon) { - Icon( - painterResource(MR.images.ic_notifications_off_filled), - contentDescription = generalGetString(MR.strings.notifications), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .padding(start = 2.sp.toDp()) - .size(18.sp.toDp()) - .offset(x = 2.5.sp.toDp(), y = 2.sp.toDp()) - ) - } else if (chat.chatInfo.chatSettings?.favorite == true) { - Icon( - painterResource(MR.images.ic_star_filled), - contentDescription = generalGetString(MR.strings.favorite_chat), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(20.sp.toDp()) - .offset(x = 2.5.sp.toDp()) - ) - } - Box( - Modifier.offset(y = 28.sp.toDp()), - contentAlignment = Alignment.Center - ) { - chatStatusImage() - } - } - } + val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } + if (deleting) { + DefaultProgressView(description = null) } } } From 92772d3d09ba4a43385dafdd2f900e4a43dbb287 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 31 Jan 2025 18:29:28 +0000 Subject: [PATCH 025/289] ui: optionally use TCP port 443 as default for messaging servers (#5598) * ui: optionally use TCP port 443 as default for messaging servers * android * netCfg logic --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../AdvancedNetworkSettings.swift | 8 ++ apps/ios/SimpleXChat/AppGroup.swift | 6 +- .../chat/simplex/common/model/SimpleXAPI.kt | 5 + .../AdvancedNetworkSettings.kt | 116 ++++++++++-------- .../commonMain/resources/MR/base/strings.xml | 3 + 5 files changed, 85 insertions(+), 53 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 754ca3cf6b..7570b1c3e0 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -208,6 +208,14 @@ struct AdvancedNetworkSettings: View { } } + Section { + Toggle("Use web port", isOn: $netCfg.smpWebPort) + } header: { + Text("TCP port for messaging") + } footer: { + Text("Use TCP port \(netCfg.smpWebPort ? "443" : "5223") when no port is specified.") + } + Section("TCP connection") { timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index c754f0740d..45a05a1cd6 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -40,6 +40,7 @@ let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE = "networkSMPProxyMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK = "networkSMPProxyFallback" +let GROUP_DEFAULT_NETWORK_SMP_WEB_PORT = "networkSMPWebPort" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -71,6 +72,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue, + GROUP_DEFAULT_NETWORK_SMP_WEB_PORT: false, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb, @@ -336,6 +338,7 @@ public func getNetCfg() -> NetCfg { let sessionMode = networkSessionModeGroupDefault.get() let smpProxyMode = networkSMPProxyModeGroupDefault.get() let smpProxyFallback = networkSMPProxyFallbackGroupDefault.get() + let smpWebPort = groupDefaults.bool(forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT) let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let tcpTimeoutPerKb = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) @@ -359,7 +362,7 @@ public func getNetCfg() -> NetCfg { sessionMode: sessionMode, smpProxyMode: smpProxyMode, smpProxyFallback: smpProxyFallback, - smpWebPort: false, + smpWebPort: smpWebPort, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpTimeoutPerKb: tcpTimeoutPerKb, @@ -378,6 +381,7 @@ public func setNetCfg(_ cfg: NetCfg, networkProxy: NetworkProxy?) { networkSMPProxyFallbackGroupDefault.set(cfg.smpProxyFallback) let socksProxy = networkProxy?.toProxyString() groupDefaults.set(socksProxy, forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) + groupDefaults.set(cfg.smpWebPort, forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.tcpTimeoutPerKb, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) 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 bf011c0bb7..94b0412821 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 @@ -172,6 +172,7 @@ class AppPreferences { val networkSMPProxyFallback = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, NetCfg.defaults.smpProxyFallback.name) val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) + val networkSMPWebPort = mkBoolPreference(SHARED_PREFS_NETWORK_SMP_WEB_PORT, false) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout) val networkTCPTimeoutPerKb = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB, NetCfg.defaults.tcpTimeoutPerKb, NetCfg.proxyDefaults.tcpTimeoutPerKb) @@ -410,6 +411,7 @@ class AppPreferences { private const val SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK = "NetworkSMPProxyFallback" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" + private const val SHARED_PREFS_NETWORK_SMP_WEB_PORT = "NetworkSMPWebPort" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -3249,6 +3251,7 @@ object ChatController { val sessionMode = appPrefs.networkSessionMode.get() val smpProxyMode = SMPProxyMode.valueOf(appPrefs.networkSMPProxyMode.get()!!) val smpProxyFallback = SMPProxyFallback.valueOf(appPrefs.networkSMPProxyFallback.get()!!) + val smpWebPort = appPrefs.networkSMPWebPort.get() val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val tcpTimeoutPerKb = appPrefs.networkTCPTimeoutPerKb.get() @@ -3271,6 +3274,7 @@ object ChatController { sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, + smpWebPort = smpWebPort, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpTimeoutPerKb = tcpTimeoutPerKb, @@ -3291,6 +3295,7 @@ object ChatController { appPrefs.networkSessionMode.set(cfg.sessionMode) appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode.name) appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback.name) + appPrefs.networkSMPWebPort.set(cfg.smpWebPort) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkTCPTimeoutPerKb.set(cfg.tcpTimeoutPerKb) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index fc042cc46c..79314fa26f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.res.MR import java.text.DecimalFormat @@ -44,6 +45,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U val sessionMode = remember { mutableStateOf(currentCfgVal.sessionMode) } val smpProxyMode = remember { mutableStateOf(currentCfgVal.smpProxyMode) } val smpProxyFallback = remember { mutableStateOf(currentCfgVal.smpProxyFallback) } + val smpWebPort = remember { mutableStateOf(currentCfgVal.smpWebPort) } val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) } val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) } @@ -82,6 +84,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode.value, smpProxyMode = smpProxyMode.value, smpProxyFallback = smpProxyFallback.value, + smpWebPort = smpWebPort.value, tcpConnectTimeout = networkTCPConnectTimeout.value, tcpTimeout = networkTCPTimeout.value, tcpTimeoutPerKb = networkTCPTimeoutPerKb.value, @@ -96,6 +99,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode.value = cfg.sessionMode smpProxyMode.value = cfg.smpProxyMode smpProxyFallback.value = cfg.smpProxyFallback + smpWebPort.value = cfg.smpWebPort networkTCPConnectTimeout.value = cfg.tcpConnectTimeout networkTCPTimeout.value = cfg.tcpTimeout networkTCPTimeoutPerKb.value = cfg.tcpTimeoutPerKb @@ -150,6 +154,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, + smpWebPort, networkTCPConnectTimeout, networkTCPTimeout, networkTCPTimeoutPerKb, @@ -182,6 +187,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode: MutableState, smpProxyMode: MutableState, smpProxyFallback: MutableState, + smpWebPort: MutableState, networkTCPConnectTimeout: MutableState, networkTCPTimeout: MutableState, networkTCPTimeoutPerKb: MutableState, @@ -214,75 +220,80 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U } SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) SectionDividerSpaced(maxTopPadding = true) - } - if (currentRemoteHost == null) { SectionView(stringResource(MR.strings.network_session_mode_transport_isolation).uppercase()) { SessionModePicker(sessionMode, showModal, updateSessionMode) } SectionDividerSpaced() - } + SectionView(stringResource(MR.strings.network_smp_web_port_section_title).uppercase()) { + PreferenceToggle(stringResource(MR.strings.network_smp_web_port_toggle), checked = smpWebPort.value) { + smpWebPort.value = it + } + } + SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) + SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, - listOf(10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000), secondsLabel - ) - } - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, - listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel - ) - } - SectionItemView { - // can't be higher than 130ms to avoid overflow on 32bit systems - TimeoutSettingRow( - stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel - ) - } - // SectionItemView { - // IntSettingRow( - // stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency, - // listOf(1, 2, 4, 8, 12, 16, 24), "" - // ) - // } - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval, - listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel - ) - } - SectionItemView { - IntSettingRow( - stringResource(MR.strings.network_option_ping_count), networkSMPPingCount, - listOf(1, 2, 3, 5, 8), "" - ) - } - SectionItemView { - EnableKeepAliveSwitch(networkEnableKeepAlive) - } - if (networkEnableKeepAlive.value) { + SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { SectionItemView { - IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel) + TimeoutSettingRow( + stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, + listOf(10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000), secondsLabel + ) } SectionItemView { - IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel) + TimeoutSettingRow( + stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, + listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel + ) } SectionItemView { - IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "") + // can't be higher than 130ms to avoid overflow on 32bit systems + TimeoutSettingRow( + stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, + listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel + ) } - } else { + // SectionItemView { + // IntSettingRow( + // stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency, + // listOf(1, 2, 4, 8, 12, 16, 24), "" + // ) + // } SectionItemView { - Text("TCP_KEEPIDLE", color = MaterialTheme.colors.secondary) + TimeoutSettingRow( + stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval, + listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel + ) } SectionItemView { - Text("TCP_KEEPINTVL", color = MaterialTheme.colors.secondary) + IntSettingRow( + stringResource(MR.strings.network_option_ping_count), networkSMPPingCount, + listOf(1, 2, 3, 5, 8), "" + ) } SectionItemView { - Text("TCP_KEEPCNT", color = MaterialTheme.colors.secondary) + EnableKeepAliveSwitch(networkEnableKeepAlive) + } + if (networkEnableKeepAlive.value) { + SectionItemView { + IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel) + } + SectionItemView { + IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel) + } + SectionItemView { + IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "") + } + } else { + SectionItemView { + Text("TCP_KEEPIDLE", color = MaterialTheme.colors.secondary) + } + SectionItemView { + Text("TCP_KEEPINTVL", color = MaterialTheme.colors.secondary) + } + SectionItemView { + Text("TCP_KEEPCNT", color = MaterialTheme.colors.secondary) + } } } } @@ -537,6 +548,7 @@ fun PreviewAdvancedNetworkSettingsLayout() { sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) }, smpProxyFallback = remember { mutableStateOf(SMPProxyFallback.Allow) }, + smpWebPort = remember { mutableStateOf(false) }, networkTCPConnectTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeoutPerKb = remember { mutableStateOf(10_000) }, 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 82e4753dc9..d3256e285c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -946,6 +946,9 @@ Message routing fallback Show message status To protect your IP address, private routing uses your SMP servers to deliver messages. + TCP port for messaging + Use web port + Use TCP port %1$s when no port is specified. Appearance Customize theme INTERFACE COLORS From f71aa3104c71c287356c1edc7c2c684403339f99 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 Jan 2025 22:02:18 +0000 Subject: [PATCH 026/289] core: update simplexmq 6.3.0.3 --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 17ec0e7392..0c4923940d 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: ae41717b9b4323295b5dc7e6157f80acd7c63f28 + tag: 45373e7f1f755ac3dfaa3f8efa03d8dc7b2d69ce source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fad8557436..d064aa098b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ae41717b9b4323295b5dc7e6157f80acd7c63f28" = "1vfv2c42sf181v3i8ixavp4r7ssp4cd7gn8xjayajxj35kjh3hm6"; + "https://github.com/simplex-chat/simplexmq.git"."45373e7f1f755ac3dfaa3f8efa03d8dc7b2d69ce" = "0rz1n3hl59b6khnhrvwhlwdrmrb9fm291i3h03ddlqkrpg1x36m6"; "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 442282be936cfcee2afb38d1ccab39bd7950bc67 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 1 Feb 2025 14:41:48 +0000 Subject: [PATCH 027/289] update library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 641c1e9fd1..02136a245a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -519,9 +519,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; 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 = ""; }; @@ -677,9 +677,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -760,8 +760,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */, ); path = Libraries; sourceTree = ""; From 43e374cf203e9e150a5b0da7c604d70648e57f09 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 2 Feb 2025 23:30:52 +0000 Subject: [PATCH 028/289] core: only include mentions in unread count for groups with mentions-only notifications (#5601) * core: only include mentions in unread count for groups with mentions-only notifications * remove whitespace * update nft servers * update query plans --- src/Simplex/Chat/Library/Commands.hs | 6 +++--- src/Simplex/Chat/Store/Groups.hs | 6 +++--- src/Simplex/Chat/Store/Profiles.hs | 3 ++- .../Chat/Store/SQLite/Migrations/chat_query_plans.txt | 3 ++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ed30f5bcc8..22cf9ac68c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -131,9 +131,9 @@ import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) _defaultNtfServers :: [NtfServer] _defaultNtfServers = - [ "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" - -- "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", - -- "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion" + [ -- "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" + "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", + "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion" ] maxImageSize :: Integer diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 578cd61677..75a6c446b6 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -643,11 +643,11 @@ deleteGroupChatItems db User {userId} GroupInfo {groupId} = deleteGroupMembers :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupMembers db User {userId} GroupInfo {groupId} = do DB.execute_ db "DROP TABLE IF EXISTS temp_delete_members" - #if defined(dbPostgres) +#if defined(dbPostgres) DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id BIGINT, member_profile_id BIGINT, local_display_name TEXT)" - #else +#else DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" - #endif +#endif DB.execute db [sql| diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 4fca5fb1a4..e1041c4263 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -174,7 +174,8 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo SELECT COUNT(1) FROM chat_items i JOIN groups g USING (group_id) - WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + WHERE i.user_id = ? AND i.item_status = ? + AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) |] (userId, CISRcvNew) pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} 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 42462038a9..9cb7a582b4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -154,7 +154,8 @@ Query: SELECT COUNT(1) FROM chat_items i JOIN groups g USING (group_id) - WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + WHERE i.user_id = ? AND i.item_status = ? + AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) Plan: SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) From 82dffd55a92ef822e5a7c9fe50348e1dff4ce83f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 3 Feb 2025 08:55:46 +0000 Subject: [PATCH 029/289] core: fixes for mentions (initial chat load, update mentions, markdown) (#5603) * core: fix mentions markdown * test * core: fix initial load for the first unread item * core: fix updating messages with mentions * fix CPP * query plans --- src/Simplex/Chat/Library/Subscriber.hs | 6 ++-- src/Simplex/Chat/Markdown.hs | 2 +- src/Simplex/Chat/Store/Messages.hs | 20 ++++++----- .../SQLite/Migrations/chat_query_plans.txt | 4 +++ tests/ChatTests/Groups.hs | 8 ++++- tests/MarkdownTests.hs | 33 ++++++++++++++----- 6 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index cb9cb1ed11..e929c4f325 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1793,7 +1793,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do - cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user gInfo groupMemberId sharedMsgId case cci of CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> if sameMemberId memberId m' @@ -1992,9 +1992,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = receiveFileChunk ft Nothing meta chunk xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM () - xFileCancelGroup GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do + xFileCancelGroup g@GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user g groupMemberId sharedMsgId case (msgDir, chatDir) of (SMDRcv, CIGroupRcv m) -> do if sameMemberId memberId m diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 5adf9f2dc9..0d1432c7e5 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -173,7 +173,7 @@ markdownP = mconcat <$> A.many' fragmentP '`' -> formattedP '`' Snippet '#' -> A.char '#' *> secretP '!' -> coloredP <|> wordP - '@' -> mentionP + '@' -> mentionP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f8acae4a91..038186652e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -127,6 +127,7 @@ module Simplex.Chat.Store.Messages ) where +import qualified Control.Exception as E import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class @@ -1335,8 +1336,8 @@ getGroupChatAround_ db user g contentFilter aroundId count search = do getGroupChatAround' db user g 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@GroupInfo {groupId} contentFilter aroundId count search stats = do - aroundCI <- getGroupChatItem db user groupId aroundId +getGroupChatAround' db user g 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 @@ -2316,9 +2317,12 @@ updateGroupCIMentions db g ci@ChatItem {mentions} mentions' unless (null mentions) $ deleteMentions if null mentions' then pure ci - else createGroupCIMentions db g ci mentions' + -- This is a fallback for the error that should not happen in practice. + -- In theory, it may happen in item mentions in database are different from item record. + else createMentions `E.catch` \e -> if constraintError e then deleteMentions >> createMentions else E.throwIO e where deleteMentions = DB.execute db "DELETE FROM chat_item_mentions WHERE chat_item_id = ?" (Only $ chatItemId' ci) + createMentions = createGroupCIMentions db g ci mentions' deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do @@ -2439,8 +2443,8 @@ markMessageReportsDeleted db User {userId} GroupInfo {groupId} ChatItem {meta = |] (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId) -getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do +getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId sharedMsgId = do itemId <- ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query @@ -2453,7 +2457,7 @@ getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId shared LIMIT 1 |] (userId, groupId, groupMemberId, sharedMsgId) - getGroupChatItem db user groupId itemId + getGroupCIWithReactions db user g itemId getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupId -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId = do @@ -2739,8 +2743,8 @@ 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@GroupInfo {groupId} GroupMember {groupMemberId} -> do - (CChatItem msgDir ci) <- getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + CDGroupRcv g GroupMember {groupMemberId} -> do + (CChatItem msgDir ci) <- getGroupChatItemBySharedMsgId db user g groupMemberId sharedMsgId pure $ AChatItem SCTGroup msgDir (GroupChat g) ci getChatItemVersions :: DB.Connection -> ChatItemId -> IO [ChatItemVersion] 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 9cb7a582b4..ed3853a743 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4887,6 +4887,10 @@ Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ? Plan: SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?) +Query: DELETE FROM chat_item_mentions WHERE chat_item_id = ? +Plan: +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) + Query: DELETE FROM chat_item_moderations WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? Plan: SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6817f19e87..2e4e777959 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -5964,7 +5964,13 @@ testMemberMention = [ alice <# "#team cath> hello @Alice", bob <# "#team cath> hello @Alice" ] - cath ##> "! #team hello @alice @bob" + cath ##> "! #team hello @alice" -- make it a mention + cath <# "#team [edited] hello @alice" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello @alice", + bob <# "#team cath> [edited] hello @alice" + ] + cath ##> "! #team hello @alice @bob" -- add a mention cath <# "#team [edited] hello @alice @bob" concurrentlyN_ [ alice <# "#team cath> [edited] hello @alice @bob", diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 335d514981..ec4e336fe9 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -43,23 +43,26 @@ s <<== ft = T.concat (map markdownText ft) `shouldBe` s (<<==>>) :: Text -> MarkdownList -> Expectation s <<==>> ft = (s ==>> ft) >> (s <<== ft) +bold :: Text -> Markdown +bold = markdown Bold + textFormat :: Spec textFormat = describe "text format (bold)" do it "correct markdown" do "this is *bold formatted* text" - <==> "this is " <> markdown Bold "bold formatted" <> " text" + <==> "this is " <> bold "bold formatted" <> " text" "*bold formatted* text" - <==> markdown Bold "bold formatted" <> " text" + <==> bold "bold formatted" <> " text" "this is *bold*" - <==> "this is " <> markdown Bold "bold" + <==> "this is " <> bold "bold" " *bold* text" - <==> " " <> markdown Bold "bold" <> " text" + <==> " " <> bold "bold" <> " text" " *bold* text" - <==> " " <> markdown Bold "bold" <> " text" + <==> " " <> bold "bold" <> " text" "this is *bold* " - <==> "this is " <> markdown Bold "bold" <> " " + <==> "this is " <> bold "bold" <> " " "this is *bold* " - <==> "this is " <> markdown Bold "bold" <> " " + <==> "this is " <> bold "bold" <> " " it "ignored as markdown" do "this is * unformatted * text" <==> "this is * unformatted * text" @@ -73,9 +76,11 @@ textFormat = describe "text format (bold)" do <==> "this is*unformatted* text" "this is *unformatted text" <==> "this is *unformatted text" + "*this* is *unformatted text" + <==> bold "this" <> " is *unformatted text" it "ignored internal markdown" do "this is *long _bold_ (not italic)* text" - <==> "this is " <> markdown Bold "long _bold_ (not italic)" <> " text" + <==> "this is " <> bold "long _bold_ (not italic)" <> " text" "snippet: `this is *bold text*`" <==> "snippet: " <> markdown Snippet "this is *bold text*" @@ -113,6 +118,8 @@ secretText = describe "secret text" do <==> "this is#unformatted# text" "this is #unformatted text" <==> "this is #unformatted text" + "*this* is #unformatted text" + <==> bold "this" <> " is #unformatted text" it "ignored internal markdown" do "snippet: `this is #secret_text#`" <==> "snippet: " <> markdown Snippet "this is #secret_text#" @@ -150,6 +157,8 @@ textColor = describe "text color (red)" do <==> "this is!1 unformatted! text" "this is !1 unformatted text" <==> "this is !1 unformatted text" + "*this* is !1 unformatted text" + <==> bold "this" <> " is !1 unformatted text" it "ignored internal markdown" do "this is !1 long *red* (not bold)! text" <==> "this is " <> red "long *red* (not bold)" <> " text" @@ -179,6 +188,7 @@ textWithUri = describe "text with Uri" do it "ignored as markdown" do "_https://simplex.chat" <==> "_https://simplex.chat" "this is _https://simplex.chat" <==> "this is _https://simplex.chat" + "this is https://" <==> "this is https://" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" ("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) @@ -208,6 +218,7 @@ textWithEmail = describe "text with Email" do "this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat" "this is chat@ simplex.chat" <==> "this is chat@ simplex.chat" "this is chat @ simplex.chat" <==> "this is chat @ simplex.chat" + "*this* is chat @ simplex.chat" <==> bold "this" <> " is chat @ simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -227,8 +238,9 @@ textWithPhone = describe "text with Phone" do <==> "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" it "ignored as markdown (too short)" $ "test 077777 test" <==> "test 077777 test" - it "ignored as markdown (double spaces)" $ + it "ignored as markdown (double spaces)" $ do "test 07777 777 777 test" <==> "test 07777 777 777 test" + "*test* 07777 777 777 test" <==> bold "test" <> " 07777 777 777 test" mention :: Text -> Text -> Markdown mention = Markdown . Just . Mention @@ -243,7 +255,10 @@ textWithMentions = describe "text with mentions" do "hello @'alice jones'!" <==> "hello " <> mention "alice jones" "@'alice jones'" <> "!" it "ignored as markdown" $ do "hello @'alice jones!" <==> "hello @'alice jones!" + "hello @bob @'alice jones!" <==> "hello " <> mention "bob" "@bob" <> " @'alice jones!" "hello @ alice!" <==> "hello @ alice!" + "hello @bob @ alice!" <==> "hello " <> mention "bob" "@bob" <> " @ alice!" + "hello @bob @" <==> "hello " <> mention "bob" "@bob" <> " @" uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri From 760ea17fb9308f3473618d281fe736a1b638ac01 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 3 Feb 2025 18:05:40 +0000 Subject: [PATCH 030/289] android, desktop: group member mentions (#5574) * initial wip * initial parser * limit mentions * wip types and ohter changes * small animation * better limit * show mentioned member when mention is in selectable area * better space handling * animation working * changes * auto tagging * centralize state * focus in desktop fix * close picker on click outside * use profile display name, avoid local * show box with max number of mentions * scrollbar in group mentions desktop * sending and displaying mentions in views based on latest core code * latest types and updates new api * desktop selection area fix * show mentions correctly * new notifications UI changes * local alias support * mention notifications working * mentions markdown changes * fix notifications * Revert "fix notifications" This reverts commit 59643c24725d3caee3c629df6732f4b5bc294f8f. * simple cleanup * mentions in info view * refactor/renames * show member name to replies of my messages as primary * show local alias and display name for mentions * show 4 rows and almost all of 5th as picker max height * only call list members api on new @ and searchn in all names * fix * correction * fixes * unread mentions chat stats * unread indication in chat * filtering of unread * show @ in chat previews * @ style * alone @ * forgotten change * deleted * remove whitespace * fix to make clear chat mark tags red * comments changes * @ as icon to avoid issues * change * simplify like ios * renames * wip using haskell parser * show mention name containing @ in quotes * cleanup and position of cursor after replace * move * show selected tick and edits working * cimention in map * eol * text selection * refactor --------- Co-authored-by: Evgeny Poberezkin Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../platform/PlatformTextField.android.kt | 27 +- .../platform/ScrollableColumn.android.kt | 4 +- .../chat/simplex/common/model/ChatModel.kt | 110 +++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 56 +++- .../simplex/common/platform/NtfManager.kt | 2 +- .../common/platform/PlatformTextField.kt | 9 +- .../common/platform/ScrollableColumn.kt | 2 + .../chat/simplex/common/views/TerminalView.kt | 13 +- .../simplex/common/views/chat/ChatInfoView.kt | 9 +- .../common/views/chat/ChatItemInfoView.kt | 13 +- .../simplex/common/views/chat/ChatView.kt | 51 ++- .../simplex/common/views/chat/ComposeView.kt | 159 +++++++--- .../common/views/chat/ContextItemView.kt | 11 +- .../views/chat/SelectableChatItemToolbars.kt | 4 +- .../simplex/common/views/chat/SendMsgView.kt | 25 +- .../views/chat/group/GroupChatInfoView.kt | 57 ++-- .../common/views/chat/group/GroupMentions.kt | 298 ++++++++++++++++++ .../common/views/chat/item/FramedItemView.kt | 25 +- .../common/views/chat/item/TextItemView.kt | 26 +- .../views/chatlist/ChatListNavLinkView.kt | 23 +- .../common/views/chatlist/ChatListView.kt | 2 +- .../common/views/chatlist/ChatPreviewView.kt | 76 +++-- .../common/views/helpers/AnimationUtils.kt | 2 + .../commonMain/resources/MR/base/strings.xml | 5 + .../MR/images/ic_alternate_email.svg | 1 + .../MR/images/ic_notification_important.svg | 1 + .../ic_notification_important_filled.svg | 1 + .../platform/PlatformTextField.desktop.kt | 33 +- .../platform/ScrollableColumn.desktop.kt | 10 +- 29 files changed, 830 insertions(+), 225 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg 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 7b820aa67e..64937b5ccc 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 @@ -15,10 +15,12 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.LayoutDirection @@ -55,9 +57,10 @@ actual fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { val cs = composeState.value @@ -117,6 +120,11 @@ actual fun PlatformTextField( } return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(selStart, selEnd), maxOf(selStart, selEnd)))) + super.onSelectionChanged(selStart, selEnd) + } } editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) editText.maxLines = 16 @@ -126,7 +134,8 @@ actual fun PlatformTextField( editText.background = ColorDrawable(Color.Transparent.toArgb()) editText.textDirection = if (isRtl) EditText.TEXT_DIRECTION_LOCALE else EditText.TEXT_DIRECTION_ANY_RTL editText.setPaddingRelative(paddingStart, paddingTop, paddingEnd, paddingBottom) - editText.setText(cs.message) + editText.setText(cs.message.text) + editText.setSelection(cs.message.selection.start, cs.message.selection.end) editText.hint = placeholder editText.setHintTextColor(hintColor.toArgb()) if (Build.VERSION.SDK_INT >= 29) { @@ -149,9 +158,10 @@ actual fun PlatformTextField( } editText.doOnTextChanged { text, _, _, _ -> if (!composeState.value.inProgress) { - onMessageChange(text.toString()) - } else if (text.toString() != composeState.value.message) { - editText.setText(composeState.value.message) + onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(editText.selectionStart, editText.selectionEnd), maxOf(editText.selectionStart, editText.selectionEnd)))) + } else if (text.toString() != composeState.value.message.text) { + editText.setText(composeState.value.message.text) + editText.setSelection(composeState.value.message.selection.start, composeState.value.message.selection.end) } } editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") } @@ -167,10 +177,9 @@ actual fun PlatformTextField( it.textSize = textStyle.value.fontSize.value * appPrefs.fontScale.get() it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview it.isFocusableInTouchMode = it.isFocusable - if (cs.message != it.text.toString()) { - it.setText(cs.message) - // Set cursor to the end of the text - it.setSelection(it.text.length) + if (cs.message.text != it.text.toString() || cs.message.selection.start != it.selectionStart || cs.message.selection.end != it.selectionEnd) { + it.setText(cs.message.text) + it.setSelection(cs.message.selection.start, cs.message.selection.end) } if (showKeyboard) { it.requestFocus() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt index b3d8e9b52f..0d07de28f8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt @@ -95,8 +95,10 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, + maxHeight: State?, + containerAlignment: Alignment, content: LazyListScope.() -> Unit -) { + ) { val state = state ?: rememberLazyListState() LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) { content() 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 e9b1b3ae66..a148392572 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 @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration +import chat.simplex.common.model.MsgFilter.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -72,6 +73,7 @@ object ChatModel { val deletedChats = mutableStateOf>>(emptyList()) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) + val membersLoaded = mutableStateOf(false) // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -473,7 +475,7 @@ object ChatModel { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { increaseUnreadCounter(rhId, currentUser.value!!) - chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1) + chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) } else chat.chatStats @@ -595,8 +597,9 @@ object ChatModel { val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) + val chatBefore = chats[i] chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) - markChatTagRead(chats[i]) + markChatTagRead(chatBefore) } // clear current chat if (chatId.value == cInfo.id) { @@ -656,7 +659,7 @@ object ChatModel { } fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List? = null) { - val markedRead = markItemsReadInCurrentChat(id, itemIds) + val (markedRead, mentionsMarkedRead) = markItemsReadInCurrentChat(id, itemIds) // update preview val chatIdx = getChatIndex(remoteHostId, id) if (chatIdx >= 0) { @@ -665,17 +668,19 @@ object ChatModel { if (lastId != null) { val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 + val unreadMentions = if (itemIds != null) chat.chatStats.unreadMentions - mentionsMarkedRead else 0 decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( - chatStats = chat.chatStats.copy(unreadCount = unreadCount) + chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions) ) updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) } } } - private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Int { + private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Pair { var markedRead = 0 + var mentionsMarkedRead = 0 if (chatId.value == id) { val items = chatItems.value var i = items.lastIndex @@ -693,6 +698,9 @@ object ChatModel { } markedReadIds.add(item.id) markedRead++ + if (item.meta.userMention) { + mentionsMarkedRead++ + } if (itemIds != null) { itemIdsFromRange.remove(item.id) // already set all needed items as read, can finish the loop @@ -703,7 +711,7 @@ object ChatModel { } chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) } - return markedRead + return markedRead to mentionsMarkedRead } private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { @@ -1176,7 +1184,6 @@ interface SomeChat { val ready: Boolean val chatDeleted: Boolean val sendMsgEnabled: Boolean - val ntfsEnabled: Boolean val incognito: Boolean fun featureEnabled(feature: ChatFeature): Boolean val timedMessagesTTL: Int? @@ -1208,7 +1215,11 @@ data class Chat( else -> false } - val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) { + All -> chatStats.unreadChat || chatStats.unreadCount > 0 + Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0 + else -> chatStats.unreadChat + } val id: String get() = chatInfo.id @@ -1234,6 +1245,7 @@ data class Chat( data class ChatStats( val unreadCount: Int = 0, // actual only via getChats() and getChat(.initial), otherwise, zero + val unreadMentions: Int = 0, val reportsCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false @@ -1260,7 +1272,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contact.ready override val chatDeleted get() = contact.chatDeleted override val sendMsgEnabled get() = contact.sendMsgEnabled - override val ntfsEnabled get() = contact.ntfsEnabled override val incognito get() = contact.incognito override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL @@ -1286,7 +1297,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = groupInfo.ready override val chatDeleted get() = groupInfo.chatDeleted override val sendMsgEnabled get() = groupInfo.sendMsgEnabled - override val ntfsEnabled get() = groupInfo.ntfsEnabled override val incognito get() = groupInfo.incognito override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature) override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL @@ -1311,7 +1321,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = noteFolder.ready override val chatDeleted get() = noteFolder.chatDeleted override val sendMsgEnabled get() = noteFolder.sendMsgEnabled - override val ntfsEnabled get() = noteFolder.ntfsEnabled override val incognito get() = noteFolder.incognito override fun featureEnabled(feature: ChatFeature) = noteFolder.featureEnabled(feature) override val timedMessagesTTL: Int? get() = noteFolder.timedMessagesTTL @@ -1336,7 +1345,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactRequest.ready override val chatDeleted get() = contactRequest.chatDeleted override val sendMsgEnabled get() = contactRequest.sendMsgEnabled - override val ntfsEnabled get() = contactRequest.ntfsEnabled override val incognito get() = contactRequest.incognito override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL @@ -1361,7 +1369,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactConnection.ready override val chatDeleted get() = contactConnection.chatDeleted override val sendMsgEnabled get() = contactConnection.sendMsgEnabled - override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL @@ -1387,7 +1394,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = false override val chatDeleted get() = false override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -1403,6 +1409,16 @@ sealed class ChatInfo: SomeChat, NamedChat { } } + fun ntfsEnabled(ci: ChatItem): Boolean = + ntfsEnabled(ci.meta.userMention) + + fun ntfsEnabled(userMention: Boolean): Boolean = + when (chatSettings?.enableNtfs) { + All -> true + Mentions -> userMention + else -> false + } + val chatSettings get() = when(this) { is Direct -> contact.chatSettings @@ -1435,6 +1451,10 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> null } + val nextNtfMode: MsgFilter? get() = this.chatSettings?.enableNtfs?.nextMode(mentions = this.hasMentions) + + val hasMentions: Boolean get() = this is Group + val contactCard: Boolean get() = when (this) { is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active @@ -1502,7 +1522,6 @@ data class Contact( ) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser @@ -1736,7 +1755,6 @@ data class GroupInfo ( override val ready get() = membership.memberActive override val chatDeleted get() = false override val sendMsgEnabled get() = membership.memberActive - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -1867,6 +1885,19 @@ data class GroupMember ( name } + val localAliasAndFullName: String + get() { + val p = memberProfile + val fullName = p.displayName + (if (p.fullName == "" || p.fullName == p.displayName) "" else " / ${p.fullName}") + + val name = if (p.localAlias.isNotEmpty()) { + "${p.localAlias} ($fullName)" + } else { + fullName + } + return pastMember(name) + } + val memberActive: Boolean get() = when (this.memberStatus) { GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false @@ -2075,7 +2106,6 @@ class NoteFolder( override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = true - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice override val timedMessagesTTL: Int? get() = null @@ -2112,7 +2142,6 @@ class UserContactRequest ( override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2152,7 +2181,6 @@ class PendingContactConnection( override val chatDeleted get() = false override val ready get() = false override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = customUserProfileId != null override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2253,6 +2281,30 @@ data class MemberReaction( val reactionTs: Instant ) +@Serializable +data class CIMentionMember( + val groupMemberId: Long, + val displayName: String, + val localAlias: String?, + val memberRole: GroupMemberRole +) + +@Serializable +data class CIMention( + val memberId: String, + val memberRef: CIMentionMember? +) { + constructor(groupMember: GroupMember): this( + groupMember.memberId, + CIMentionMember( + groupMember.groupMemberId, + groupMember.memberProfile.displayName, + groupMember.memberProfile.localAlias, + groupMember.memberRole + ) + ) +} + @Serializable class CIReaction( val chatDir: CIDirection, @@ -2267,6 +2319,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, val file: CIFile? = null @@ -2566,7 +2619,8 @@ data class ChatItem ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), quotedItem = null, @@ -2590,7 +2644,8 @@ data class ChatItem ( itemTimed = null, itemLive = true, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.SndMsgContent(MsgContent.MCText("")), quotedItem = null, @@ -2747,6 +2802,7 @@ data class CIMeta ( val itemEdited: Boolean, val itemTimed: CITimed?, val itemLive: Boolean?, + val userMention: Boolean, val deletable: Boolean, val editable: Boolean ) { @@ -2785,7 +2841,8 @@ data class CIMeta ( itemTimed = itemTimed, itemLive = itemLive, deletable = deletable, - editable = editable + editable = editable, + userMention = false, ) fun invalidJSON(): CIMeta = @@ -2804,7 +2861,8 @@ data class CIMeta ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false ) } } @@ -3821,6 +3879,10 @@ class FormattedText(val text: String, val format: Format? = null) { fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List): String = "${linkType.description} (${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" + + companion object { + fun plain(text: String): List = if (text.isEmpty()) emptyList() else listOf(FormattedText(text)) + } } @Serializable @@ -3833,6 +3895,7 @@ sealed class Format { @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() + @Serializable @SerialName("mention") class Mention(val memberName: String): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() @@ -3845,6 +3908,7 @@ sealed class Format { is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle is SimplexLink -> linkStyle + is Mention -> SpanStyle(fontWeight = FontWeight.Medium) is Email -> linkStyle is Phone -> linkStyle } 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 94b0412821..6021d48b0a 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 @@ -19,7 +19,7 @@ import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen -import chat.simplex.common.model.SMPErrorType.BLOCKED +import chat.simplex.common.model.MsgContent.MCUnknown import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -36,6 +36,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -1018,12 +1019,13 @@ object ChatController { } } - suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { - val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) + 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)) when { r is CR.ChatItemUpdated -> return r.chatItem r is CR.ChatItemNotChanged -> return r.chatItem r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> { + val mc = updatedMessage.msgContent AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), if (mc is MsgContent.MCImage || mc is MsgContent.MCVideo || mc is MsgContent.MCLink) { @@ -2523,7 +2525,7 @@ object ChatController { addChatItem(rhId, cInfo, cItem) } } - } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { + } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { withChats { increaseUnreadCounter(rhId, r.user) } @@ -2573,7 +2575,7 @@ object ChatController { is CR.ChatItemsDeleted -> { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> - if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) { + if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled(deletedChatItem.chatItem)) { withChats { decreaseUnreadCounter(rhId, r.user) } @@ -3370,7 +3372,7 @@ 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 mc: MsgContent, val live: Boolean): 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 ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() @@ -3548,7 +3550,7 @@ 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)} ${mc.cmdString}" + 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 ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" @@ -3895,7 +3897,13 @@ sealed class ChatPagination { } @Serializable -class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) +class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent, val mentions: Map) + +@Serializable +class UpdatedMessage(val msgContent: MsgContent, val mentions: Map) { + val cmdString: String get() = + if (msgContent is MCUnknown) "json $json" else "json ${json.encodeToString(this)}" +} @Serializable class ChatTagData(val emoji: String?, val text: String) @@ -4552,7 +4560,37 @@ data class ChatSettings( enum class MsgFilter { @SerialName("all") All, @SerialName("none") None, - @SerialName("mentions") Mentions, + @SerialName("mentions") Mentions; + + fun nextMode(mentions: Boolean): MsgFilter { + return when (this) { + All -> if (mentions) Mentions else None + Mentions -> None + None -> All + } + } + + fun text(mentions: Boolean): StringResource { + return when (this) { + All -> MR.strings.unmute_chat + Mentions -> MR.strings.mute_chat + None -> if (mentions) MR.strings.mute_all_chat else MR.strings.mute_chat + } + } + + val icon: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important + None -> MR.images.ic_notifications_off + } + + val iconFilled: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important_filled + None -> MR.images.ic_notifications_off_filled + } } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 51d26f8ff2..43ea0a499e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -39,7 +39,7 @@ abstract class NtfManager { fun notifyMessageReceived(rhId: Long?, user: UserLike, cInfo: ChatInfo, cItem: ChatItem) { if ( cItem.showNotification && - cInfo.ntfsEnabled && + cInfo.ntfsEnabled(cItem) && ( allowedToShowNotification() || chatModel.chatId.value != cInfo.id || 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 1dff386684..1daf5a7ba7 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 @@ -1,8 +1,10 @@ package chat.simplex.common.platform -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import chat.simplex.common.views.chat.ComposeMessage import chat.simplex.common.views.chat.ComposeState import java.net.URI @@ -16,8 +18,9 @@ expect fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester? = null, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt index e6d4514875..b1965ec845 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt @@ -45,6 +45,8 @@ expect fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State? = null, additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, + maxHeight: State? = null, + containerAlignment: Alignment = Alignment.TopStart, content: LazyListScope.() -> 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 67fae65897..22b013ff60 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 @@ -10,8 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow @@ -45,16 +47,16 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState) { + fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( text, if (text.isEmpty()) emptyList() else formattedText, sender = sender, + mentions = mentions, + userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, @@ -80,7 +82,12 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu) + TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } + ) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { 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 7a53c18e5d..51dc568e24 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 @@ -13,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.layout.layoutId @@ -129,6 +130,7 @@ fun ChatView( val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } + SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> if (searchText.value == value) return@onSearchValueChanged @@ -144,7 +146,7 @@ fun ChatView( chatInfo = activeChatInfo, unreadCount, composeState, - composeView = { + composeView = { focusRequester -> if (selectedChatItems.value == null) { Column( Modifier.fillMaxWidth(), @@ -165,7 +167,8 @@ fun ChatView( } ComposeView( chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, - showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }, + focusRequester = focusRequester ) } } else { @@ -244,6 +247,7 @@ fun ChatView( chatModel.chatId.value = null chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -532,7 +536,7 @@ fun ChatView( } }) { close -> var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) LaunchedEffect(cItem.id) { withContext(Dispatchers.Default) { for (apiResp in controller.messagesChannel) { @@ -654,7 +658,7 @@ fun ChatLayout( chatInfo: State, unreadCount: State, composeState: MutableState, - composeView: (@Composable () -> Unit), + composeView: (@Composable (FocusRequester?) -> Unit), scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, @@ -690,7 +694,7 @@ fun ChatLayout( openGroupLink: (GroupInfo) -> Unit, markItemsRead: (List) -> Unit, markChatRead: () -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, @@ -731,9 +735,10 @@ fun ChatLayout( val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chatInfo != null) { - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f @@ -746,6 +751,20 @@ fun ChatLayout( setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, ) } + if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { + Column( + Modifier + .align(Alignment.BottomStart) + .padding(bottom = composeViewHeight.value) + ) { + GroupMentions( + rhId = remoteHostId, + composeState = composeState, + composeViewFocusRequester = composeViewFocusRequester, + chatInfo = chatInfo, + ) + } + } } } if (contentTag == MsgContentTag.Report) { @@ -792,7 +811,7 @@ fun ChatLayout( .navigationBarsPadding() .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) ) { - composeView() + composeView(composeViewFocusRequester) } } } @@ -850,7 +869,7 @@ fun BoxScope.ChatInfoToolbar( endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, openGroupLink: (GroupInfo) -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, showSearch: MutableState ) { @@ -969,18 +988,20 @@ fun BoxScope.ChatInfoToolbar( } } - if ((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) { - val ntfsEnabled = remember { mutableStateOf(chatInfo.ntfsEnabled) } + val enableNtfs = chatInfo.chatSettings?.enableNtfs + if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { + val ntfMode = remember { mutableStateOf(enableNtfs) } + val nextNtfMode by remember { derivedStateOf { ntfMode.value.nextMode(chatInfo.hasMentions) } } menuItems.add { ItemAction( - if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + stringResource(nextNtfMode.text(chatInfo.hasMentions)), + painterResource(nextNtfMode.icon), onClick = { showMenu.value = false // Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu scope.launch { delay(200) - changeNtfsState(!ntfsEnabled.value, ntfsEnabled) + changeNtfsState(nextNtfMode, ntfMode) } } ) @@ -2680,7 +2701,7 @@ fun PreviewChatLayout() { chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -2755,7 +2776,7 @@ fun PreviewGroupChatLayout() { chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), 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 c413e06599..b48b32030f 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 @@ -1,4 +1,4 @@ -@file:UseSerializers(UriSerializer::class) +@file:UseSerializers(UriSerializer::class, ComposeMessageSerializer::class) package chat.simplex.common.views.chat import androidx.compose.foundation.background @@ -11,17 +11,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.model.ChatModel.withChats @@ -33,10 +34,15 @@ import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import java.io.File import java.net.URI import java.nio.file.Files +const val MAX_NUMBER_OF_MENTIONS = 3 + @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @@ -63,23 +69,57 @@ data class LiveMessage( val sent: Boolean ) +typealias MentionedMembers = Map + +@Serializable +data class ComposeMessage( + val text: String = "", + val selection: TextRange = TextRange.Zero +) { + constructor(text: String): this(text, TextRange(text.length)) +} + +@Serializer(forClass = TextRange::class) +object ComposeMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextRange", PrimitiveKind.LONG) + override fun serialize(encoder: Encoder, value: TextRange) = + encoder.encodeLong(packInts(value.start, value.end)) + override fun deserialize(decoder: Decoder): TextRange = + decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) } +} + @Serializable data class ComposeState( - val message: String = "", + val message: ComposeMessage = ComposeMessage(), + val parsedMessage: List = emptyList(), val liveMessage: LiveMessage? = null, val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, val inProgress: Boolean = false, - val useLinkPreviews: Boolean + val useLinkPreviews: Boolean, + val mentions: MentionedMembers = emptyMap() ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( - editingItem.content.text, + ComposeMessage(editingItem.content.text), + editingItem.formattedText ?: FormattedText.plain(editingItem.content.text), liveMessage, chatItemPreview(editingItem), ComposeContextItem.EditingItem(editingItem), - useLinkPreviews = useLinkPreviews + useLinkPreviews = useLinkPreviews, + mentions = editingItem.mentions ?: emptyMap() ) + val memberMentions: Map + get() = this.mentions.mapNotNull { + val memberRef = it.value.memberRef + + if (memberRef != null) { + it.key to memberRef.groupMemberId + } else { + null + } + }.toMap() + val editing: Boolean get() = when (contextItem) { @@ -100,7 +140,7 @@ data class ComposeState( get() = when (contextItem) { is ComposeContextItem.ReportedItem -> { when (contextItem.reason) { - is ReportReason.Other -> message.isNotEmpty() + is ReportReason.Other -> message.text.isNotEmpty() else -> true } } @@ -112,12 +152,12 @@ data class ComposeState( is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport + else -> message.text.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } val endLiveDisabled: Boolean - get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = liveMessage != null && message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem val linkPreviewAllowed: Boolean get() = @@ -160,7 +200,7 @@ data class ComposeState( } val empty: Boolean - get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem companion object { fun saver(): Saver, *> = Saver( @@ -170,6 +210,18 @@ data class ComposeState( } ) } + + fun mentionMemberName(name: String): String { + var n = 0 + var tryName = name + + while (mentions.containsKey(tryName)) { + n++ + tryName = "${name}_$n" + } + + return tryName + } } private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) @@ -223,7 +275,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) if (fileName != null) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.FilePreview(fileName, uri)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.FilePreview(fileName, uri)) } } else if (fileSize != null) { AlertManager.shared.showAlertMsg( @@ -276,7 +328,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: } } if (imagesPreview.isNotEmpty()) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) } } @@ -286,15 +338,15 @@ fun ComposeView( chat: Chat, composeState: MutableState, attachmentOption: MutableState, - showChooseAttachment: () -> Unit + showChooseAttachment: () -> Unit, + focusRequester: FocusRequester?, ) { val cancelledLinks = rememberSaveable { mutableSetOf() } fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) - fun parseMessage(msg: String): Pair { - if (msg.isBlank()) return null to false - val parsedMsg = parseToMarkdown(msg) ?: return null to false + fun getSimplexLink(parsedMsg: List?): Pair { + if (parsedMsg == null) return null to false val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink } return link?.text to simplexLink @@ -302,7 +354,7 @@ fun ComposeView( val linkUrl = rememberSaveable { mutableStateOf(null) } // default value parsed because of draft - val hasSimplexLink = rememberSaveable { mutableStateOf(parseMessage(composeState.value.message).second) } + val hasSimplexLink = rememberSaveable { mutableStateOf(getSimplexLink(parseToMarkdown(composeState.value.message.text)).second) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() @@ -310,7 +362,6 @@ fun ComposeView( val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } - AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } fun loadLinkPreview(url: String, wait: Long? = null) { @@ -330,11 +381,11 @@ fun ComposeView( } } - fun showLinkPreview(s: String) { + fun showLinkPreview(parsedMessage: List?) { prevLinkUrl.value = linkUrl.value - val parsed = parseMessage(s) - linkUrl.value = parsed.first - hasSimplexLink.value = parsed.second + val linkParsed = getSimplexLink(parsedMessage) + linkUrl.value = linkParsed.first + hasSimplexLink.value = linkParsed.second val url = linkUrl.value if (url != null) { if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { @@ -403,13 +454,13 @@ fun ComposeView( } } - suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map): ChatItem? { val cInfo = chat.chatInfo val chatItems = if (chat.chatInfo.chatType == ChatType.Local) chatModel.controller.apiCreateChatItems( rh = chat.remoteHostId, noteFolderId = chat.chatInfo.apiId, - composedMessages = listOf(ComposedMessage(file, null, mc)) + composedMessages = listOf(ComposedMessage(file, null, mc, mentions)) ) else chatModel.controller.apiSendMessages( @@ -418,7 +469,7 @@ fun ComposeView( id = cInfo.apiId, live = live, ttl = ttl, - composedMessages = listOf(ComposedMessage(file, quoted, mc)) + composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) ) if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> @@ -437,7 +488,7 @@ fun ComposeView( val cs = composeState.value var sent: List? var lastMessageFailedToSend: ComposeState? = null - val msgText = text ?: cs.message + val msgText = text ?: cs.message.text fun sending() { composeState.value = composeState.value.copy(inProgress = true) @@ -473,7 +524,8 @@ fun ComposeView( fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { - val url = parseMessage(msgText).first + val parsedMsg = parseToMarkdown(msgText) + val url = getSimplexLink(parsedMsg).first val lp = composePreview.linkPreview if (lp != null && url == lp.uri) { MsgContent.MCLink(msgText, preview = lp) @@ -544,7 +596,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent), + updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) if (updatedItem != null) withChats { @@ -572,10 +624,10 @@ fun ComposeView( if (sent == null) { lastMessageFailedToSend = constructFailedMessage(cs) } - if (cs.message.isNotEmpty()) { + if (cs.message.text.isNotEmpty()) { sent?.mapIndexed { index, message -> if (index == sent!!.lastIndex) { - send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl) + send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions) } else { message } @@ -686,7 +738,8 @@ fun ComposeView( } val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, - ttl = ttl + ttl = ttl, + mentions = cs.memberMentions ) sent = if (sendResult != null) listOf(sendResult) else null if (sent == null && index == msgs.lastIndex && cs.liveMessage == null) { @@ -719,21 +772,22 @@ fun ComposeView( } } - fun onMessageChange(s: String) { - composeState.value = composeState.value.copy(message = s) - if (isShortEmoji(s)) { - textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + fun onMessageChange(s: ComposeMessage) { + val parsedMessage = parseToMarkdown(s.text) + composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text)) + if (isShortEmoji(s.text)) { + textStyle.value = if (s.text.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { textStyle.value = smallFont if (composeState.value.linkPreviewAllowed) { - if (s.isNotEmpty()) { - showLinkPreview(s) + if (s.text.isNotEmpty()) { + showLinkPreview(parsedMessage) } else { resetLinkPreview() hasSimplexLink.value = false } - } else if (s.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { - hasSimplexLink.value = parseMessage(s).second + } else if (s.text.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { + hasSimplexLink.value = getSimplexLink(parsedMessage).second } else { hasSimplexLink.value = false } @@ -801,7 +855,7 @@ fun ComposeView( suspend fun sendLiveMessage() { val cs = composeState.value - val typedMsg = cs.message + val typedMsg = cs.message.text if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { val ci = sendMessageAsync(typedMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { @@ -822,14 +876,14 @@ fun ComposeView( val typedMsg = composeState.value.message val liveMessage = composeState.value.liveMessage if (liveMessage != null) { - val sentMsg = liveMessageToSend(liveMessage, typedMsg) + val sentMsg = liveMessageToSend(liveMessage, typedMsg.text) if (sentMsg != null) { val ci = sendMessageAsync(sentMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg.text, sentMsg = sentMsg, sent = true)) } - } else if (liveMessage.typedMsg != typedMsg) { - composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) + } else if (liveMessage.typedMsg != typedMsg.text) { + composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg.text)) } } } @@ -904,16 +958,16 @@ fun ComposeView( fun contextItemView() { when (val contextItem = composeState.value.contextItem) { ComposeContextItem.NoContextItem -> {} - is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatInfo = chat.chatInfo) { clearState() } - is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { + is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatInfo = chat.chatInfo, contextIconColor = Color.Red) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } } @@ -932,7 +986,7 @@ fun ComposeView( if (chatModel.chatId.value == null) return@LaunchedEffect when (val shared = chatModel.sharedContent.value) { - is SharedContent.Text -> onMessageChange(shared.text) + is SharedContent.Text -> onMessageChange(ComposeMessage(shared.text)) is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) is SharedContent.Forward -> composeState.value = composeState.value.copy( @@ -1056,7 +1110,7 @@ fun ComposeView( KeyChangeEffect(chatModel.chatId.value) { prevChatId -> val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { sendMessage(null) resetLinkPreview() clearPrevDraft(prevChatId) @@ -1128,7 +1182,8 @@ fun ComposeView( editPrevMessage = ::editPrevMessage, onFilesPasted = { composeState.onFilesAttached(it) }, onMessageChange = ::onMessageChange, - textStyle = textStyle + textStyle = textStyle, + focusRequester = focusRequester, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 1657a1f0b7..1501fb7938 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -31,7 +31,7 @@ fun ContextItemView( contextItems: List, contextIcon: Painter, showSender: Boolean = true, - chatType: ChatType, + chatInfo: ChatInfo, contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { @@ -64,6 +64,11 @@ fun ContextItemView( inlineContent = inlineContent, linkMode = SimplexLinkMode.DESCRIPTION, modifier = Modifier.fillMaxWidth(), + mentions = contextItem.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } ) } @@ -126,7 +131,7 @@ fun ContextItemView( ContextMsgPreview(contextItem, lines = 3) } } else if (contextItems.isNotEmpty()) { - Text(String.format(generalGetString(if (chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) + Text(String.format(generalGetString(if (chatInfo.chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) } } IconButton(onClick = cancelContextItem) { @@ -147,7 +152,7 @@ fun PreviewContextItemView() { ContextItemView( contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello")), contextIcon = painterResource(MR.images.ic_edit_filled), - chatType = ChatType.Direct + chatInfo = Chat.sampleData.chatInfo ) {} } } 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 45a2a31c04..457f8f1e40 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 @@ -7,7 +7,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,7 +65,7 @@ fun SelectedItemsBottomToolbar( 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) }, {}) + ComposeView(chatModel = chatModel, 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 e2b44478af..5524eff655 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 @@ -12,10 +12,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.alpha 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.* @@ -56,11 +58,11 @@ fun SendMsgView( cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, onFilesPasted: (List) -> Unit, - onMessageChange: (String) -> Unit, - textStyle: MutableState -) { + onMessageChange: (ComposeMessage) -> Unit, + textStyle: MutableState, + focusRequester: FocusRequester? = null, + ) { val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } - val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp) Box(Modifier.padding(padding)) { val cs = composeState.value @@ -73,7 +75,7 @@ fun SendMsgView( false } } - val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && + val showVoiceButton = !nextSendGrpInv && cs.message.text.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || @@ -92,7 +94,8 @@ fun SendMsgView( showVoiceButton, onMessageChange, editPrevMessage, - onFilesPasted + onFilesPasted, + focusRequester ) { if (!cs.inProgress) { sendMessage(null) @@ -160,7 +163,7 @@ fun SendMsgView( } } } - cs.liveMessage?.sent == false && cs.message.isEmpty() -> { + cs.liveMessage?.sent == false && cs.message.text.isEmpty() -> { CancelLiveMessageButton { cancelLiveMessage?.invoke() } @@ -280,7 +283,7 @@ private fun CustomDisappearingMessageDialog( @Composable private fun BoxScope.DeleteTextButton(composeState: MutableState) { IconButton( - { composeState.value = composeState.value.copy(message = "") }, + { composeState.value = composeState.value.copy(message = ComposeMessage()) }, Modifier.align(Alignment.TopEnd).size(36.dp) ) { Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary) @@ -586,7 +589,7 @@ fun PreviewSendMsgView() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -622,7 +625,7 @@ fun PreviewSendMsgViewEditing() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -658,7 +661,7 @@ fun PreviewSendMsgViewInProgress() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } 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 b2963c4d4a..b3dc333ace 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 @@ -17,7 +17,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString @@ -46,6 +45,8 @@ import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +val MEMBER_ROW_AVATAR_SIZE = 42.dp +val MEMBER_ROW_VERTICAL_PADDING = 8.dp @Composable fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { @@ -258,16 +259,17 @@ fun MuteButton( chat: Chat, groupInfo: GroupInfo ) { - val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + val notificationMode = remember { mutableStateOf(groupInfo.chatSettings.enableNtfs) } + val nextNotificationMode by remember { derivedStateOf { notificationMode.value.nextMode(true) } } InfoViewActionButton( modifier = modifier, - icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), - title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), + icon = painterResource(nextNotificationMode.icon), + title = generalGetString(nextNotificationMode.text(true)), disabled = !groupInfo.ready, disabledLook = !groupInfo.ready, onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled.value, chatModel, ntfsEnabled) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNotificationMode, chatModel, notificationMode) } ) } @@ -451,7 +453,7 @@ fun ModalData.GroupChatInfoLayout( val showMenu = remember { mutableStateOf(false) } SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) - MemberRow(member, onClick = { showMemberInfo(member) }) + MemberRow(member) } } item { @@ -599,7 +601,7 @@ private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialThem } @Composable -private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) { +fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false) { @Composable fun MemberInfo() { if (member.blocked) { @@ -628,11 +630,11 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - verticalAlignment = Alignment.CenterVertically ) { Row( - Modifier.weight(1f).padding(top = 8.dp, end = DEFAULT_PADDING, bottom = 8.dp), + Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - MemberProfileImage(size = 42.dp, member) + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Column { Row(verticalAlignment = Alignment.CenterVertically) { @@ -640,22 +642,37 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - MemberVerifiedShield() } Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } - val statusDescr = - if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() - Text( - statusDescr, - color = MaterialTheme.colors.secondary, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + + if (infoPage) { + val statusDescr = + if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() + Text( + statusDescr, + color = MaterialTheme.colors.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } - MemberInfo() + if (infoPage) { + MemberInfo() + } + if (selected) { + Icon( + painterResource( + MR.images.ic_check + ), + null, + Modifier.size(20.dp), + tint = MaterialTheme.colors.primary, + ) + } } } 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 new file mode 100644 index 0000000000..89ef19dcda --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -0,0 +1,298 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.launch + +private val PICKER_ROW_SIZE = MEMBER_ROW_AVATAR_SIZE + (MEMBER_ROW_VERTICAL_PADDING * 2f) +private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE + MEMBER_ROW_VERTICAL_PADDING - 4.dp) + +@Composable +fun GroupMentions( + rhId: Long?, + composeState: MutableState, + composeViewFocusRequester: FocusRequester?, + chatInfo: ChatInfo.Group +) { + val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } + val isVisible = remember { mutableStateOf(false) } + val offsetY = remember { Animatable(maxHeightInPx) } + + val currentMessage = remember { mutableStateOf(composeState.value.message) } + val mentionName = remember { mutableStateOf("") } + val mentionRange = remember { mutableStateOf(null) } + val mentionMemberId = remember { mutableStateOf(null) } + val filteredMembers = remember { + derivedStateOf { + val members = chatModel.groupMembers.value + .filter { + val status = it.memberStatus + status != GroupMemberStatus.MemLeft && status != GroupMemberStatus.MemRemoved && status != GroupMemberStatus.MemInvited + } + .sortedByDescending { it.memberRole } + + if (mentionName.value.isEmpty()) { + members + } else { + members.filter { it.memberProfile.anyNameContains(mentionName.value) } + } + } + } + val scope = rememberCoroutineScope() + + suspend fun closeMembersPicker() { + isVisible.value = false + if (offsetY.value != 0f) { + return + } + + offsetY.animateTo( + targetValue = maxHeightInPx, + animationSpec = mentionPickerAnimSpec() + ) + mentionName.value = "" + mentionRange.value = null + mentionMemberId.value = null + } + + fun messageChanged(msg: ComposeMessage, parsedMsg: List) { + removeUnusedMentions(composeState, parsedMsg) + val selected = selectedMarkdown(parsedMsg, msg.selection) + + if (selected != null) { + val (ft, r) = selected + + when (ft.format) { + is Format.Mention -> { + isVisible.value = true + mentionName.value = ft.format.memberName + mentionRange.value = r + mentionMemberId.value = composeState.value.mentions[mentionName.value]?.memberId + if (!chatModel.membersLoaded.value) { + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + } + return + } + null -> { + val pos = msg.selection.start + if (msg.selection.length == 0 && getCharacter(msg.text, pos - 1)?.first == "@") { + val prevChar = getCharacter(msg.text, pos - 2)?.first + if (prevChar == null || prevChar == " " || prevChar == "\n") { + isVisible.value = true + mentionName.value = "" + mentionRange.value = TextRange(pos - 1, pos) + mentionMemberId.value = null + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + return + } + } + } + else -> {} + } + } + scope.launch { + closeMembersPicker() + } + } + + fun addMemberMention(member: GroupMember, range: TextRange) { + val mentions = composeState.value.mentions.toMutableMap() + val existingMention = mentions.entries.firstOrNull { + it.value.memberId == member.memberId + } + val newName = existingMention?.key ?: composeState.value.mentionMemberName(member.memberProfile.displayName) + mentions[newName] = CIMention(member) + var msgMention = "@" + if (newName.contains(" ")) "'$newName'" else newName + var newPos = range.start + msgMention.length + val newMsgLength = composeState.value.message.text.length + msgMention.length - range.length + if (newPos == newMsgLength) { + msgMention += " " + newPos += 1 + } + + val msg = composeState.value.message.text.replaceRange( + range.start, + range.end, + msgMention + ) + composeState.value = composeState.value.copy( + message = ComposeMessage(msg, TextRange(newPos)), + parsedMessage = parseToMarkdown(msg) ?: FormattedText.plain(msg), + mentions = mentions + ) + + composeViewFocusRequester?.requestFocus() + + scope.launch { + closeMembersPicker() + } + } + + LaunchedEffect(composeState.value.parsedMessage) { + currentMessage.value = composeState.value.message + messageChanged(currentMessage.value, composeState.value.parsedMessage) + } + +// KeyChangeEffect(composeState.value.message.selection) { +// // This condition is needed to prevent messageChanged called twice, +// // because composeState.formattedText triggers later when message changes. +// // The condition is only true if position changed without text change +// if (currentMessage.value.text == composeState.value.message.text) { +// messageChanged(currentMessage.value, composeState.value.parsedMessage) +// } +// } + + LaunchedEffect(isVisible.value) { + if (isVisible.value) { + offsetY.animateTo( + targetValue = 0f, + animationSpec = mentionPickerAnimSpec() + ) + } + } + Box( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, offsetY.value.toInt()) } + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + scope.launch { closeMembersPicker() } + }, + contentAlignment = Alignment.BottomStart + ) { + val showMaxReachedBox = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && isVisible.value && composeState.value.mentions[mentionName.value] == null + LazyColumnWithScrollBarNoAppBar( + Modifier + .heightIn(max = MAX_PICKER_HEIGHT) + .background(MaterialTheme.colors.surface), + maxHeight = remember { mutableStateOf(MAX_PICKER_HEIGHT) }, + containerAlignment = Alignment.BottomEnd + ) { + if (showMaxReachedBox) { + stickyHeader { + MaxMentionsReached() + } + } + itemsIndexed(filteredMembers.value, key = { _, item -> item.memberId }) { i, member -> + if (i != 0 || !showMaxReachedBox) { + Divider() + } + val mentioned = mentionMemberId.value == member.memberId + val disabled = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && !mentioned + Row( + Modifier + .fillMaxWidth() + .alpha(if (disabled) 0.6f else 1f) + .clickable(enabled = !disabled) { + val range = mentionRange.value ?: return@clickable + val mentionMemberValue = mentionMemberId.value + + if (mentionMemberValue != null) { + if (mentionMemberValue != member.memberId) { + addMemberMention(member, range) + } else { + return@clickable + } + } else { + addMemberMention(member, range) + } + } + .padding(horizontal = DEFAULT_PADDING_HALF), + verticalAlignment = Alignment.CenterVertically + ) { + MemberRow( + member, + infoPage = false, + showlocalAliasAndFullName = true, + selected = mentioned + ) + } + } + } + } +} + +@Composable +private fun MaxMentionsReached() { + Column(Modifier.background(MaterialTheme.colors.surface)) { + Divider() + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + String.format(generalGetString(MR.strings.max_group_mentions_per_message_reached), MAX_NUMBER_OF_MENTIONS), + Modifier.padding(12.dp), + ) + } + Divider() + } +} + +private fun getCharacter(s: String, pos: Int): Pair? { + return if (pos in s.indices) { + val char = s.subSequence(pos, pos + 1) + char to (pos until pos + 1) + } else { + null + } +} + +private fun selectedMarkdown( + parsedMsg: List, + range: TextRange +): Pair? { + if (parsedMsg.isEmpty()) return null + + var i = 0 + var pos = 0 + + while (i < parsedMsg.size && pos + parsedMsg[i].text.length < range.start) { + pos += parsedMsg[i].text.length + i++ + } + + return if (i >= parsedMsg.size || range.end > pos + parsedMsg[i].text.length) { + null + } else { + parsedMsg[i] to TextRange(pos, pos + parsedMsg[i].text.length) + } +} + +private fun removeUnusedMentions(composeState: MutableState, parsedMsg: List) { + val usedMentions = parsedMsg.mapNotNull { ft -> + when (ft.format) { + is Format.Mention -> ft.format.memberName + else -> null + } + }.toSet() + + if (usedMentions.size < composeState.value.mentions.size) { + composeState.value = composeState.value.copy( + mentions = composeState.value.mentions.filterKeys { it in usedMentions } + ) + } +} 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 784563dbb2..1fee43f80e 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 @@ -59,7 +59,7 @@ fun FramedItemView( style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface), linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, - showTimestamp = showTimestamp + showTimestamp = showTimestamp, ) } @@ -76,7 +76,7 @@ fun FramedItemView( ) { Text( sender, - style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary), + style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), maxLines = 1 ) ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp) @@ -176,7 +176,7 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -285,7 +285,7 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVideo -> { @@ -293,26 +293,26 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVoice -> { CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) if (mc.text != "") { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCFile -> ciFileView(ci, mc.text) is MsgContent.MCUnknown -> if (ci.file == null) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } else { ciFileView(ci, mc.text) } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCReport -> { @@ -321,9 +321,9 @@ fun FramedItemView( append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") } } - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) } - else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + else -> CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } @@ -344,6 +344,7 @@ fun FramedItemView( @Composable fun CIMarkdownText( ci: ChatItem, + chatInfo: ChatInfo, chatTTL: Int?, linkMode: SimplexLinkMode, uriHandler: UriHandler?, @@ -357,6 +358,10 @@ fun CIMarkdownText( MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, + mentions = ci.mentions, userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 257ede7d4a..ad11eb4897 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp @@ -22,7 +21,6 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* -import java.awt.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) @@ -60,6 +58,8 @@ fun MarkdownText ( sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, + mentions: Map? = null, + userMemberId: String? = null, toggleSecrets: Boolean, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, @@ -148,6 +148,26 @@ fun MarkdownText ( withAnnotation(tag = "SECRET", annotation = key) { if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } } + } else if (ft.format is Format.Mention) { + val mention = mentions?.get(ft.format.memberName) + + if (mention != null) { + if (mention.memberRef != null) { + val displayName = mention.memberRef.displayName + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { + displayName + } else { + "${mention.memberRef.localAlias} ($displayName)" + } + val mentionStyle = if (mention.memberId == userMemberId) ft.format.style.copy(color = MaterialTheme.colors.primary) else ft.format.style + + withStyle(mentionStyle) { append(mentionText(name)) } + } else { + withStyle( ft.format.style) { append(mentionText(ft.format.memberName)) } + } + } else { + append(ft.text) + } } else { val link = ft.link(linkMode) if (link != null) { @@ -291,3 +311,5 @@ private fun isRtl(s: CharSequence): Boolean { } return false } + +private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" 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 3ba15bc79c..8e01c2b4bb 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 @@ -244,6 +244,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo } chatModel.groupMembersIndexes.value = emptyMap() chatModel.groupMembers.value = newMembers + chatModel.membersLoaded.value = true chatModel.populateGroupMembersIndexes() } @@ -256,7 +257,7 @@ fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMen MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, contact.chatSettings.enableNtfs.nextMode(false), showMenu) TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) } @@ -296,7 +297,7 @@ fun GroupMenuItems( MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, groupInfo.chatSettings.enableNtfs.nextMode(true), showMenu) TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { @@ -379,12 +380,12 @@ fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolea } @Composable -fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState) { +fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, nextMsgFilter: MsgFilter, showMenu: MutableState) { ItemAction( - if (ntfsEnabled) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + generalGetString(nextMsgFilter.text(chat.chatInfo.hasMentions)), + painterResource(nextMsgFilter.icon), onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled, chatModel) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextMsgFilter, chatModel) showMenu.value = false } ) @@ -830,8 +831,8 @@ fun groupInvitationAcceptedAlert(rhId: Long?) { ) } -fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { - val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None) +fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, filter: MsgFilter, chatModel: ChatModel, currentState: MutableState? = null) { + val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = filter) updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel, currentState) } @@ -840,7 +841,7 @@ fun toggleChatFavorite(remoteHostId: Long?, chatInfo: ChatInfo, favorite: Boolea updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel) } -fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { +fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { val newChatInfo = when(chatInfo) { is ChatInfo.Direct -> with (chatInfo) { ChatInfo.Direct(contact.copy(chatSettings = chatSettings)) @@ -868,7 +869,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch withChats { updateChatInfo(remoteHostId, newChatInfo) } - if (chatSettings.enableNtfs != MsgFilter.All) { + if (chatSettings.enableNtfs == MsgFilter.None) { ntfManager.cancelNotificationsForChat(chatInfo.id) } val updatedChat = chatModel.getChat(chatInfo.id) @@ -879,7 +880,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } val current = currentState?.value if (current != null) { - currentState.value = !current + currentState.value = chatSettings.enableNtfs } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3205a084f0..2375e26dd4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1219,7 +1219,7 @@ private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = when (activeFilter) { is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats) is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false - is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + is ActiveFilter.Unread -> chat.unreadTag else -> true } 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 63a0d6fc7f..d379131171 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 @@ -19,7 +19,6 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontStyle @@ -176,7 +175,7 @@ fun ChatPreviewView( if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { val sp20 = with(LocalDensity.current) { 20.sp.toDp() } val (text: CharSequence, inlineTextContent) = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } + chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null else -> markedDeletedText(ci, chat.chatInfo) to null } @@ -203,6 +202,11 @@ fun ChatPreviewView( cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName else -> null }, + mentions = ci.mentions, + userMemberId = when { + cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId + else -> null + }, toggleSecrets = false, linkMode = linkMode, senderBold = true, @@ -426,23 +430,56 @@ fun ChatPreviewView( Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { val n = chat.chatStats.unreadCount - val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) + val ntfsMode = chat.chatInfo.chatSettings?.enableNtfs + val showNtfsIcon = !chat.chatInfo.ntfsEnabled(false) && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) if (n > 0 || chat.chatStats.unreadChat) { - 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()) - ) - } else if (showNtfsIcon) { + val unreadMentions = chat.chatStats.unreadMentions + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { + val mentionColor = when { + disabled -> MaterialTheme.colors.secondary + cInfo is ChatInfo.Group -> { + val enableNtfs = cInfo.groupInfo.chatSettings.enableNtfs + if (enableNtfs == MsgFilter.All || enableNtfs == MsgFilter.Mentions) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary + } + + else -> if (showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant + } + if (unreadMentions > 0 && n > 1) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = mentionColor, + modifier = Modifier.size(12.sp.toDp()).offset(y = 3.sp.toDp()) + ) + } + + if (unreadMentions > 0 && n == 1) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(mentionColor, 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 { + 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()) + ) + } + } + } else if (showNtfsIcon && ntfsMode != null) { Icon( - painterResource(MR.images.ic_notifications_off_filled), + painterResource(ntfsMode.iconFilled), contentDescription = generalGetString(MR.strings.notifications), tint = MaterialTheme.colors.secondary, modifier = Modifier @@ -470,11 +507,6 @@ fun ChatPreviewView( } } } - - val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } - if (deleting) { - DefaultProgressView(description = null) - } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt index 4fdbd97d23..078cdde9da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt @@ -9,3 +9,5 @@ fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing) fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) + +fun mentionPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) 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 d3256e285c..5441f0816a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -679,9 +679,11 @@ Mute + Mute all Unmute Favorite Unfavorite + Unread mentions Create list @@ -2555,4 +2557,7 @@ Download errors Server address Open server settings + + + You can mention up to %1$s members per message! \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg new file mode 100644 index 0000000000..39007022b8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg new file mode 100644 index 0000000000..37c79840d7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg new file mode 100644 index 0000000000..75f247a21e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 e7bcf4802a..d0d4fb5e92 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 @@ -10,8 +10,7 @@ import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* @@ -51,19 +50,21 @@ actual fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { + val cs = composeState.value - val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current + val focusReq = focusRequester ?: remember { FocusRequester() } LaunchedEffect(cs.contextItem) { if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect // In replying state - focusRequester.requestFocus() + focusReq.requestFocus() delay(50) keyboard?.show() } @@ -74,9 +75,9 @@ actual fun PlatformTextField( keyboard?.hide() } } - val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.subSequence(0, min(50, cs.message.length)))) } + val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length)))) } val isRtlByCharacters = remember(cs.message) { - if (cs.message.isNotEmpty()) isRtl(cs.message.subSequence(0, min(50, cs.message.length))) else lastTimeWasRtlByCharacters.value + if (cs.message.text.isNotEmpty()) isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length))) else lastTimeWasRtlByCharacters.value } LaunchedEffect(isRtlByCharacters) { lastTimeWasRtlByCharacters.value = isRtlByCharacters @@ -84,12 +85,12 @@ actual fun PlatformTextField( val isLtrGlobally = LocalLayoutDirection.current == LayoutDirection.Ltr // Different padding here is for a text that is considered RTL with non-RTL locale set globally. // In this case padding from right side should be bigger - val startEndPadding = if (cs.message.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp + val startEndPadding = if (cs.message.text.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding val padding = PaddingValues(startPadding, 12.dp, endPadding, 0.dp) - var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } - val textFieldValue = textFieldValueState.copy(text = cs.message) + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message.text, selection = cs.message.selection)) } + val textFieldValue = textFieldValueState.copy(text = cs.message.text, selection = cs.message.selection) val clipboard = LocalClipboardManager.current BasicTextField( value = textFieldValue, @@ -105,7 +106,7 @@ actual fun PlatformTextField( } } textFieldValueState = it - onMessageChange(it.text) + onMessageChange(ComposeMessage(it.text, it.selection)) } }, textStyle = textStyle.value, @@ -118,7 +119,7 @@ actual fun PlatformTextField( .padding(start = startPadding, end = endPadding) .offset(y = (-5).dp) .fillMaxWidth() - .focusRequester(focusRequester) + .focusRequester(focusReq) .onPreviewKeyEvent { if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyDown) { if (it.isShiftPressed) { @@ -129,12 +130,12 @@ actual fun PlatformTextField( text = newText, selection = TextRange(textFieldValue.selection.min + 1) ) - onMessageChange(newText) + onMessageChange(ComposeMessage(newText, textFieldValueState.selection)) } else if (!sendMsgButtonDisabled) { onDone() } true - } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { + } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.text.isEmpty()) { onUpArrow() true } else if (it.key == Key.V && @@ -166,7 +167,7 @@ actual fun PlatformTextField( chatModel.filesToDelete.add(tempFile) tempFile.writeBytes(bytes) - composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message) + composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message.text) } } catch (e: Exception) { Log.e(TAG, "Pasting image exception: ${e.stackTraceToString()}") @@ -200,7 +201,7 @@ actual fun PlatformTextField( } } ) - showDeleteTextButton.value = cs.message.split("\n").size >= 4 && !cs.inProgress + 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 (userIsObserver) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 3f5703365d..7a2a1dff0a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -111,7 +111,9 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, - content: LazyListScope.() -> Unit + maxHeight: State?, + containerAlignment: Alignment, + content: LazyListScope.() -> Unit, ) { val scope = rememberCoroutineScope() val scrollBarAlpha = remember { Animatable(0f) } @@ -135,9 +137,11 @@ actual fun LazyColumnWithScrollBarNoAppBar( // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state // (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row) val scrollBarDraggingState = remember { mutableStateOf(false) } - Box { + Box(contentAlignment = containerAlignment) { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) + Box(if (maxHeight?.value != null) Modifier.height(maxHeight.value).fillMaxWidth() else Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { + DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout, scrollBarDraggingState) + } } } From 517679e2df90b07628e424f42407cea065680c12 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 3 Feb 2025 20:47:32 +0000 Subject: [PATCH 031/289] ios: group member mentions (#5593) * api types * display for mentions and replys * picking of mentions * notifications (wip) * auto tagging * show selected mention * Divider and list bg * stop keyboard dismiss on scroll from ios 16 * change notification mode in all views * icon for mentions notification mode * make unread states work in memory and chat preview * preview fixes * fix unread status when mark read manually * update library * fixed padding * fix layout * use memberName * remove ChatNtfs, show mentions in context items and in drafts, make mentions a map in ComposeState * rework mentions (WIP) * better * show mention name containing @ in quotes * editing mentions * editing * mentionColor * opacity * refactor mention counter * fix unread layout --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 74 ++++-- apps/ios/Shared/Model/NtfManager.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 14 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 14 +- .../Views/Chat/ChatItem/FramedItemView.swift | 4 +- .../Views/Chat/ChatItem/MsgContentView.swift | 31 ++- .../Shared/Views/Chat/ChatItemInfoView.swift | 9 +- apps/ios/Shared/Views/Chat/ChatView.swift | 51 ++-- .../Chat/ComposeMessage/ComposeView.swift | 88 +++++-- .../Chat/ComposeMessage/ContextItemView.swift | 2 +- .../ComposeMessage/NativeTextEditor.swift | 26 +- .../Chat/ComposeMessage/SendMessageView.swift | 6 + .../Views/Chat/Group/GroupChatInfoView.swift | 34 ++- .../Views/Chat/Group/GroupMentions.swift | 234 ++++++++++++++++++ .../Views/Chat/Group/GroupWelcomeView.swift | 2 +- .../Views/ChatList/ChatListNavLink.swift | 14 +- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 53 ++-- .../Shared/Views/NewChat/AddGroupView.swift | 6 +- apps/ios/Shared/Views/TerminalView.swift | 2 + .../ios/SimpleX NSE/NotificationService.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/APITypes.swift | 57 ++++- apps/ios/SimpleXChat/ChatTypes.swift | 77 +++++- apps/ios/SimpleXChat/ErrorAlert.swift | 2 +- src/Simplex/Chat/Store/Profiles.hs | 1 + 26 files changed, 664 insertions(+), 147 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/Group/GroupMentions.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 95cebcde10..d46b524867 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -499,7 +499,7 @@ final class ChatModel: ObservableObject { [cItem] } if case .rcvNew = cItem.meta.itemStatus { - unreadCollector.changeUnreadCounter(cInfo.id, by: 1) + unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) } popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) } else { @@ -579,7 +579,7 @@ final class ChatModel: ObservableObject { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { if cItem.isRcvNew { - unreadCollector.changeUnreadCounter(cInfo.id, by: -1) + unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) } // update previews if let chat = getChat(cInfo.id) { @@ -662,7 +662,7 @@ final class ChatModel: ObservableObject { func markChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in - self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) self.updateFloatingButtons(unreadCount: 0) ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() @@ -693,20 +693,28 @@ final class ChatModel: ObservableObject { markCurrentChatRead(fromIndex: i) _updateChat(cInfo.id) { chat in var unreadBelow = 0 + var unreadMentionsBelow = 0 var j = i - 1 while j >= 0 { - if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus { + let meta = self.im.reversedChatItems[j].meta + if case .rcvNew = meta.itemStatus { unreadBelow += 1 + if meta.userMention { + unreadMentionsBelow += 1 + } } j -= 1 } // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow - if markedCount > 0 { + let markedMentionsCount = chat.chatStats.unreadMentions - unreadMentionsBelow + if markedCount > 0 || markedMentionsCount > 0 { let wasUnread = chat.unreadTag chat.chatStats.unreadCount -= markedCount + chat.chatStats.unreadMentions -= markedMentionsCount ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) - self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) + let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions ? markedMentionsCount : markedCount + self.decreaseUnreadCounter(user: self.currentUser!, by: by) self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount) } } @@ -727,7 +735,7 @@ final class ChatModel: ObservableObject { func clearChat(_ cInfo: ChatInfo) { // clear preview if let chat = getChat(cInfo.id) { - self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) chat.chatItems = [] ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() @@ -740,7 +748,7 @@ final class ChatModel: ObservableObject { } } - func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) { + func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { if self.chatId == cInfo.id { for itemId in itemIds { if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { @@ -748,7 +756,7 @@ final class ChatModel: ObservableObject { } } } - self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count) + self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) } private let unreadCollector = UnreadCollector() @@ -756,16 +764,16 @@ final class ChatModel: ObservableObject { class UnreadCollector { private let subject = PassthroughSubject() private var bag = Set() - private var unreadCounts: [ChatId: Int] = [:] + private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:] init() { subject .debounce(for: 1, scheduler: DispatchQueue.main) .sink { let m = ChatModel.shared - for (chatId, count) in self.unreadCounts { - if let i = m.getChatIndex(chatId) { - m.changeUnreadCounter(i, by: count) + for (chatId, (unread, mentions)) in self.unreadCounts { + if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) { + m.changeUnreadCounter(i, by: unread, unreadMentions: mentions) } } self.unreadCounts = [:] @@ -773,11 +781,12 @@ final class ChatModel: ObservableObject { .store(in: &bag) } - func changeUnreadCounter(_ chatId: ChatId, by count: Int) { + func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) { if chatId == ChatModel.shared.chatId { ChatView.FloatingButtonModel.shared.totalUnread += count } - self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count + let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0) + self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions) subject.send() } } @@ -855,9 +864,11 @@ final class ChatModel: ObservableObject { } } - func changeUnreadCounter(_ chatIndex: Int, by count: Int) { + func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) { let wasUnread = chats[chatIndex].unreadTag - chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count + let stats = chats[chatIndex].chatStats + chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count + chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) changeUnreadCounter(user: currentUser!, by: count) } @@ -866,6 +877,13 @@ final class ChatModel: ObservableObject { changeUnreadCounter(user: user, by: 1) } + func decreaseUnreadCounter(user: any UserLike, chat: Chat) { + let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions + ? chat.chatStats.unreadMentions + : chat.chatStats.unreadCount + decreaseUnreadCounter(user: user, by: by) + } + func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { changeUnreadCounter(user: user, by: -by) } @@ -878,8 +896,20 @@ final class ChatModel: ObservableObject { } func totalUnreadCountForAllUsers() -> Int { - chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + - users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) + var unread: Int = 0 + for chat in chats { + switch chat.chatInfo.chatSettings?.enableNtfs { + case .all: unread += chat.chatStats.unreadCount + case .mentions: unread += chat.chatStats.unreadMentions + default: () + } + } + for u in users { + if !u.user.activeUser { + unread += u.unreadCount + } + } + return unread } func increaseGroupReportsCounter(_ chatId: ChatId) { @@ -1104,7 +1134,11 @@ final class Chat: ObservableObject, Identifiable, ChatLike { } var unreadTag: Bool { - chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + switch chatInfo.chatSettings?.enableNtfs { + case .all: chatStats.unreadChat || chatStats.unreadCount > 0 + case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0 + default: chatStats.unreadChat + } } var id: ChatId { get { chatInfo.id } } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 6c33031eeb..da55bd90d0 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -248,7 +248,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") - if cInfo.ntfsEnabled { + if cInfo.ntfsEnabled(chatItem: cItem) { addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2380f79d59..65b69a75c8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -485,8 +485,8 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) { ) } -func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { - let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) +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) if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } throw r } @@ -1491,11 +1491,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { } } -func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async { +func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds) DispatchQueue.main.async { - ChatModel.shared.markChatItemsRead(cInfo, itemIds) + ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead) } } catch { logger.error("apiChatItemsRead error: \(responseError(error))") @@ -1576,6 +1576,7 @@ func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { throw r } +// use ChatModel's loadGroupMembers from views func apiListMembers(_ groupId: Int64) async -> [GroupMember] { let r = await chatSendCmd(.apiListMembers(groupId: groupId)) if case let .groupMembers(_, group) = r { return group.members } @@ -2027,7 +2028,7 @@ func processReceivedMsg(_ res: ChatResponse) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } - } else if cItem.isRcvNew && cInfo.ntfsEnabled { + } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } } @@ -2072,7 +2073,8 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .chatItemsDeleted(user, items, _): if !active(user) { for item in items { - if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled { + let d = item.deletedChatItem + if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) { await MainActor.run { m.decreaseUnreadCounter(user: user) } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 7a5003c94d..a9fd0bf3ce 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -158,7 +158,9 @@ struct ChatInfoView: View { searchButton(width: buttonWidth) AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - muteButton(width: buttonWidth) + if let nextNtfMode = chat.chatInfo.nextNtfMode { + muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) + } } } .padding(.trailing) @@ -432,13 +434,13 @@ struct ChatInfoView: View { .disabled(!contact.ready || chat.chatItems.isEmpty) } - private func muteButton(width: CGFloat) -> some View { - InfoViewButton( - image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill", - title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute", + private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { + return InfoViewButton( + image: nextNtfMode.iconFilled, + title: "\(nextNtfMode.text(mentions: false))", width: width ) { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + toggleNotifications(chat, enableNtfs: nextNtfMode) } .disabled(!contact.ready || !contact.active) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 6da893d1d2..22adf12a64 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -254,7 +254,7 @@ struct FramedItemView: View { VStack(alignment: .leading, spacing: 2) { Text(sender) .font(.caption) - .foregroundColor(theme.colors.secondary) + .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary) .lineLimit(1) ciQuotedMsgTextView(qi, lines: 2) } @@ -302,6 +302,8 @@ struct FramedItemView: View { text: text, formattedText: ft, meta: ci.meta, + mentions: ci.mentions, + userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, showSecrets: showSecrets, prefix: txtPrefix diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index e9b6d0ba84..0bb2463d23 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -32,6 +32,8 @@ struct MsgContentView: View { var formattedText: [FormattedText]? = nil var sender: String? = nil var meta: CIMeta? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil var rightToLeft = false var showSecrets: Bool var prefix: Text? = nil @@ -68,7 +70,7 @@ struct MsgContentView: View { } private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) + var v = messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) if let mt = meta { if mt.isLive { v = v + typingIndicator(mt.recent) @@ -90,15 +92,15 @@ struct MsgContentView: View { } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, mentions: [String: CIMention]?, userMemberId: String?, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { let s = text var res: Text if let ft = formattedText, ft.count > 0 && ft.count <= 200 { - res = formatText(ft[0], preview, showSecret: showSecrets) + res = formatText(ft[0], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId) var i = 1 while i < ft.count { - res = res + formatText(ft[i], preview, showSecret: showSecrets) + res = res + formatText(ft[i], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId) i = i + 1 } } else { @@ -121,7 +123,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St } } -private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text { +private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool, mentions: [String: CIMention]?, userMemberId: String?) -> Text { let t = ft.text if let f = ft.format { switch (f) { @@ -144,6 +146,21 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) case .full: return linkText(t, simplexUri, preview, prefix: "") case .browser: return linkText(t, simplexUri, preview, prefix: "") } + case let .mention(memberName): + if let m = mentions?[memberName] { + if let ref = m.memberRef { + let name: String = if let alias = ref.localAlias, alias != "" { + "\(alias) (\(ref.displayName))" + } else { + ref.displayName + } + let tName = mentionText(name) + return m.memberId == userMemberId ? tName.foregroundColor(.accentColor) : tName + } else { + return mentionText(memberName) + } + } + return Text(t) case .email: return linkText(t, t, preview, prefix: "mailto:") case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") } @@ -152,6 +169,10 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) } } +private func mentionText(_ name: String) -> Text { + Text(name.contains(" @") ? "@'\(name)'" : "@\(name)").fontWeight(.semibold) +} + private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text { preview ? Text(s).foregroundColor(color).underline(color: color) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 62ea607d27..b03169974e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -14,6 +14,7 @@ struct ChatItemInfoView: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme var ci: ChatItem + var userMemberId: String? @Binding var chatItemInfo: ChatItemInfo? @State private var selection: CIInfoTab = .history @State private var alert: CIInfoViewAlert? = nil @@ -258,7 +259,7 @@ struct ChatItemInfoView: View { @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender) + TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId) } else { Text("no text") .italic() @@ -271,10 +272,12 @@ struct ChatItemInfoView: View { var text: String var formattedText: [FormattedText]? var sender: String? = nil + var mentions: [String: CIMention]? + var userMemberId: String? @State private var showSecrets = false var body: some View { - toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) + toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) } } @@ -548,6 +551,6 @@ func localTimestamp(_ date: Date) -> String { struct ChatItemInfoView_Previews: PreviewProvider { static var previews: some View { - ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil)) + ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 768244de8e..9e48bd897a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -26,6 +26,7 @@ struct ChatView: View { @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() + @State private var selectedRange = NSRange() @State private var keyboardVisible = false @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @@ -76,6 +77,9 @@ struct ChatView: View { VStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { chatItemsList() + if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { + GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) + } FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) } connectingText() @@ -83,7 +87,8 @@ struct ChatView: View { ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + selectedRange: $selectedRange ) .disabled(!cInfo.sendMsgEnabled) } else { @@ -991,31 +996,37 @@ struct ChatView: View { markedRead = true } if let range { - let itemIds = unreadItemIds(range) + let (itemIds, unreadMentions) = unreadItemIds(range) if !itemIds.isEmpty { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, itemIds) + await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions) } } } else if chatItem.isRcvNew { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id]) + await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) } } } .actionSheet(item: $actionSheet) { $0.actionSheet } } - private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { + private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { let im = ItemsModel.shared - return range.compactMap { i in - if i >= 0 && i < im.reversedChatItems.count { - let ci = im.reversedChatItems[i] - return if ci.isRcvNew { ci.id } else { nil } - } else { - return nil + var unreadItems: [ChatItem.ID] = [] + var unreadMentions: Int = 0 + + for i in range { + let ci = im.reversedChatItems[i] + if ci.isRcvNew { + unreadItems.append(ci.id) + if ci.meta.userMention { + unreadMentions += 1 + } } } + + return (unreadItems, unreadMentions) } private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { @@ -1227,7 +1238,7 @@ struct ChatView: View { .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { chatItemInfo = nil }) { - ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo) + ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo) } } @@ -2044,21 +2055,19 @@ struct ToggleNtfsButton: View { @ObservedObject var chat: Chat var body: some View { - Button { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) - } label: { - if chat.chatInfo.ntfsEnabled { - Label("Mute", systemImage: "speaker.slash") - } else { - Label("Unmute", systemImage: "speaker.wave.2") + if let nextMode = chat.chatInfo.nextNtfMode { + Button { + toggleNotifications(chat, enableNtfs: nextMode) + } label: { + Label(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.icon) } } } } -func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { +func toggleNotifications(_ chat: Chat, enableNtfs: MsgFilter) { var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults - chatSettings.enableNtfs = enableNtfs ? .all : .none + chatSettings.enableNtfs = enableNtfs updateChatSettings(chat, chatSettings: chatSettings) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a68a4987a1..b529919216 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -11,6 +11,8 @@ import SimpleXChat import SwiftyGif import PhotosUI +let MAX_NUMBER_OF_MENTIONS = 3 + enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) @@ -19,7 +21,7 @@ enum ComposePreview { case filePreview(fileName: String, file: URL) } -enum ComposeContextItem { +enum ComposeContextItem: Equatable { case noContextItem case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) @@ -39,31 +41,41 @@ struct LiveMessage { var sentMsg: String? } +typealias MentionedMembers = [String: CIMention] + struct ComposeState { var message: String + var parsedMessage: [FormattedText] var liveMessage: LiveMessage? = nil var preview: ComposePreview var contextItem: ComposeContextItem var voiceMessageRecordingState: VoiceMessageRecordingState var inProgress = false var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + var mentions: MentionedMembers = [:] init( message: String = "", + parsedMessage: [FormattedText] = [], liveMessage: LiveMessage? = nil, preview: ComposePreview = .noPreview, contextItem: ComposeContextItem = .noContextItem, - voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording + voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording, + mentions: MentionedMembers = [:] ) { self.message = message + self.parsedMessage = parsedMessage self.liveMessage = liveMessage self.preview = preview self.contextItem = contextItem self.voiceMessageRecordingState = voiceMessageRecordingState + self.mentions = mentions } init(editingItem: ChatItem) { - self.message = editingItem.content.text + let text = editingItem.content.text + self.message = text + self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text) self.preview = chatItemPreview(chatItem: editingItem) self.contextItem = .editingItem(chatItem: editingItem) if let emc = editingItem.content.msgContent, @@ -72,10 +84,12 @@ struct ComposeState { } else { self.voiceMessageRecordingState = .noRecording } + self.mentions = editingItem.mentions ?? [:] } init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) { self.message = "" + self.parsedMessage = [] self.preview = .noPreview self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo) self.voiceMessageRecordingState = .noRecording @@ -83,20 +97,38 @@ struct ComposeState { func copy( message: String? = nil, + parsedMessage: [FormattedText]? = nil, liveMessage: LiveMessage? = nil, preview: ComposePreview? = nil, contextItem: ComposeContextItem? = nil, - voiceMessageRecordingState: VoiceMessageRecordingState? = nil + voiceMessageRecordingState: VoiceMessageRecordingState? = nil, + mentions: MentionedMembers? = nil ) -> ComposeState { ComposeState( message: message ?? self.message, + parsedMessage: parsedMessage ?? self.parsedMessage, liveMessage: liveMessage ?? self.liveMessage, preview: preview ?? self.preview, contextItem: contextItem ?? self.contextItem, - voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState + voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState, + mentions: mentions ?? self.mentions ) } - + + func mentionMemberName(_ name: String) -> String { + var n = 0 + var tryName = name + while mentions[tryName] != nil { + n += 1 + tryName = "\(name)_\(n)" + } + return tryName + } + + var memberMentions: [String: Int64] { + self.mentions.compactMapValues { $0.memberRef?.groupMemberId } + } + var editing: Bool { switch contextItem { case .editingItem: return true @@ -293,6 +325,7 @@ struct ComposeView: View { @ObservedObject var chat: Chat @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool + @Binding var selectedRange: NSRange @State var linkUrl: URL? = nil @State var hasSimplexLink: Bool = false @@ -376,6 +409,7 @@ struct ComposeView: View { ZStack(alignment: .leading) { SendMessageView( composeState: $composeState, + selectedRange: $selectedRange, sendMessage: { ttl in sendMessage(ttl: ttl) resetLinkPreview() @@ -428,15 +462,17 @@ struct ComposeView: View { .ignoresSafeArea(.all, edges: .bottom) } .onChange(of: composeState.message) { msg in + let parsedMsg = parseSimpleXMarkdown(msg) + composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg)) if composeState.linkPreviewAllowed { if msg.count > 0 { - showLinkPreview(msg) + showLinkPreview(parsedMsg) } else { resetLinkPreview() hasSimplexLink = false } } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) { - (_, hasSimplexLink) = parseMessage(msg) + (_, hasSimplexLink) = getSimplexLink(parsedMsg) } else { hasSimplexLink = false } @@ -793,6 +829,7 @@ struct ComposeView: View { var sent: ChatItem? let msgText = text ?? composeState.message let liveMessage = composeState.liveMessage + let mentions = composeState.memberMentions if !live { if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) } await sending() @@ -803,7 +840,7 @@ struct ComposeView: View { // Composed text is send as a reply to the last forwarded item sent = await forwardItems(chatItems, fromChatInfo, ttl).last if !composeState.message.isEmpty { - _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) + _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions) } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) @@ -819,10 +856,11 @@ struct ComposeView: View { switch (composeState.preview) { case .noPreview: - sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) + sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case .linkPreview: - sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) + sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case let .mediaPreviews(media): + // TODO: CHECK THIS let last = media.count - 1 var msgs: [ComposedMessage] = [] if last >= 0 { @@ -847,10 +885,10 @@ struct ComposeView: View { case let .voicePreview(recordingFileName, duration): stopPlayback.toggle() let file = voiceCryptoFile(recordingFileName) - sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) + sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions) case let .filePreview(_, file): if let savedFile = saveFileFromURL(file) { - sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) + sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions) } } } @@ -905,7 +943,7 @@ struct ComposeView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, itemId: ei.id, - msg: mc, + updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions), live: live ) await MainActor.run { @@ -977,9 +1015,9 @@ struct ComposeView: View { return nil } - func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { + 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)], + [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)], live: live, ttl: ttl ).first @@ -1043,7 +1081,8 @@ struct ComposeView: View { func checkLinkPreview() -> MsgContent { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): - if let url = parseMessage(msgText).url, + if let parsedMsg = parseSimpleXMarkdown(msgText), + let url = getSimplexLink(parsedMsg).url, let linkPreview = linkPreview, url == linkPreview.uri { return .link(text: msgText, preview: linkPreview) @@ -1162,9 +1201,9 @@ struct ComposeView: View { } } - private func showLinkPreview(_ s: String) { + private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl - (linkUrl, hasSimplexLink) = parseMessage(s) + (linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg) if let url = linkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl { pendingLinkUrl = url @@ -1181,8 +1220,8 @@ struct ComposeView: View { } } - private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) { - guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) } + private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) { + guard let parsedMsg else { return (nil, false) } let url: URL? = if let uri = parsedMsg.first(where: { ft in ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }) { @@ -1234,18 +1273,21 @@ struct ComposeView_Previews: PreviewProvider { static var previews: some View { let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) @State var composeState = ComposeState(message: "hello") + @State var selectedRange = NSRange() return Group { ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + selectedRange: $selectedRange ) .environmentObject(ChatModel()) ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + selectedRange: $selectedRange ) .environmentObject(ChatModel()) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 3cb747ec68..d5b5e6ccf4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> Text { - return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) func attachment() -> Text { let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 2fc122f249..0d79566d8b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -17,18 +17,19 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var height: CGFloat @Binding var focused: Bool @Binding var placeholder: String? + @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void private let minHeight: CGFloat = 37 private let defaultHeight: CGFloat = { - let field = CustomUITextField(height: Binding.constant(0)) + let field = CustomUITextField(parent: nil, height: Binding.constant(0)) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) }() func makeUIView(context: Context) -> UITextView { - let field = CustomUITextField(height: _height) + let field = CustomUITextField(parent: self, height: _height) field.backgroundColor = .clear field.text = text field.textAlignment = alignment(text) @@ -69,6 +70,10 @@ struct NativeTextEditor: UIViewRepresentable { if castedField.placeholder != placeholder { castedField.placeholder = placeholder } + + if field.selectedRange != selectedRange { + field.selectedRange = selectedRange + } } private func updateHeight(_ field: UITextView) { @@ -99,6 +104,7 @@ private func alignment(_ text: String) -> NSTextAlignment { } private class CustomUITextField: UITextView, UITextViewDelegate { + var parent: NativeTextEditor? var height: Binding var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } @@ -106,7 +112,8 @@ private class CustomUITextField: UITextView, UITextViewDelegate { private let placeholderLabel: UILabel = UILabel() - init(height: Binding) { + init(parent: NativeTextEditor?, height: Binding) { + self.parent = parent self.height = height super.init(frame: .zero, textContainer: nil) } @@ -232,10 +239,22 @@ private class CustomUITextField: UITextView, UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { onFocusChanged(true) + updateSelectedRange(textView) } func textViewDidEndEditing(_ textView: UITextView) { onFocusChanged(false) + updateSelectedRange(textView) + } + + func textViewDidChangeSelection(_ textView: UITextView) { + updateSelectedRange(textView) + } + + private func updateSelectedRange(_ textView: UITextView) { + if parent?.selectedRange != textView.selectedRange { + parent?.selectedRange = textView.selectedRange + } } } @@ -247,6 +266,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ height: Binding.constant(100), focused: Binding.constant(false), placeholder: Binding.constant("Placeholder"), + selectedRange: Binding.constant(NSRange(location: 0, length: 0)), onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index fb69dfdd17..9554772721 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000 struct SendMessageView: View { @Binding var composeState: ComposeState + @Binding var selectedRange: NSRange @EnvironmentObject var theme: AppTheme var sendMessage: (Int?) -> Void var sendLiveMessage: (() async -> Void)? = nil @@ -62,6 +63,7 @@ struct SendMessageView: View { height: $teHeight, focused: $keyboardVisible, placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), + selectedRange: $selectedRange, onImagesAdded: onMediaAdded ) .allowsTightening(false) @@ -424,8 +426,10 @@ struct SendMessageView: View { struct SendMessageView_Previews: PreviewProvider { static var previews: some View { @State var composeStateNew = ComposeState() + @State var selectedRange = NSRange() let ci = ChatItem.getSample(1, .directSnd, .now, "hello") @State var composeStateEditing = ComposeState(editingItem: ci) + @State var selectedRangeEditing = NSRange() @State var sendEnabled: Bool = true return Group { @@ -434,6 +438,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateNew, + selectedRange: $selectedRange, sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: Binding.constant(true) @@ -444,6 +449,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateEditing, + selectedRange: $selectedRangeEditing, sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: Binding.constant(true) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index b0f896e493..2a827de195 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -138,12 +138,12 @@ struct GroupChatInfoView: View { addMembersButton() } } - if members.count > 8 { - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) - } + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + let filteredMembers = s == "" + ? members + : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) ForEach(filteredMembers) { member in ZStack { @@ -276,7 +276,9 @@ struct GroupChatInfoView: View { if groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) } - muteButton(width: buttonWidth) + if let nextNtfMode = chat.chatInfo.nextNtfMode { + muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) + } } .frame(maxWidth: .infinity, alignment: .center) } @@ -324,13 +326,13 @@ struct GroupChatInfoView: View { } } - private func muteButton(width: CGFloat) -> some View { - InfoViewButton( - image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill", - title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute", + private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { + return InfoViewButton( + image: nextNtfMode.iconFilled, + title: "\(nextNtfMode.text(mentions: true))", width: width ) { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + toggleNotifications(chat, enableNtfs: nextNtfMode) } .disabled(!groupInfo.ready) } @@ -353,11 +355,7 @@ struct GroupChatInfoView: View { .onAppear { searchFocussed = false Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - chatModel.groupMembers = groupMembers.map { GMember.init($0) } - chatModel.populateGroupMembersIndexes() - } + await chatModel.loadGroupMembers(groupInfo) } } } @@ -387,7 +385,7 @@ struct GroupChatInfoView: View { Spacer() memberInfo(member) } - + if user { v } else if groupInfo.membership.memberRole >= .admin { @@ -490,7 +488,7 @@ struct GroupChatInfoView: View { .foregroundColor(theme.colors.secondary) } } - + private func memberInfoView(_ groupMember: GMember) -> some View { GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember) .navigationBarHidden(false) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift new file mode 100644 index 0000000000..a621dd1f67 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -0,0 +1,234 @@ +// +// GroupMentions.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 30/01/2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +let MENTION_START: Character = "@" +let QUOTE: Character = "'" +let MEMBER_ROW_SIZE: CGFloat = 60 +let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8 + +struct GroupMentionsView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + @Binding var composeState: ComposeState + @Binding var selectedRange: NSRange + @Binding var keyboardVisible: Bool + + @State private var isVisible = false + @State private var currentMessage: String = "" + @State private var mentionName: String = "" + @State private var mentionRange: NSRange? + @State private var mentionMemberId: String? + + var body: some View { + ZStack { + if isVisible { + Color.white.opacity(0.01) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + isVisible = false + } + } + VStack { + Spacer() + VStack { + Spacer() + VStack { + Divider() + let list = List { + ForEach(filteredMembers, id: \.wrapped.groupMemberId) { member in + let mentioned = mentionMemberId == member.wrapped.memberId + let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned + memberRowView(member.wrapped, mentioned) + .contentShape(Rectangle()) + .disabled(disabled) + .opacity(disabled ? 0.6 : 1) + .onTapGesture { + memberSelected(member) + } + } + } + .listStyle(PlainListStyle()) + .frame(height: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filteredMembers.count))) + + if #available(iOS 16.0, *) { + list.scrollDismissesKeyboard(.never) + } else { + list + } + } + .background(Color(UIColor.systemBackground)) + } + .frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS) + } + .offset(y: isVisible ? 0 : 300) + .animation(.spring(), value: isVisible) + .onChange(of: composeState.parsedMessage) { parsedMsg in + currentMessage = composeState.message + messageChanged(currentMessage, parsedMsg, selectedRange) + } + .onChange(of: selectedRange) { r in + // This condition is needed to prevent messageChanged called twice, + // because composeState.formattedText triggers later when message changes. + // The condition is only true if position changed without text change + if currentMessage == composeState.message { + messageChanged(currentMessage, composeState.parsedMessage, r) + } + } + .onAppear { + currentMessage = composeState.message + } + } + } + + private var filteredMembers: [GMember] { + let members = m.groupMembers + .filter { m in + let status = m.wrapped.memberStatus + return status != .memLeft && status != .memRemoved && status != .memInvited + } + .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } + let s = mentionName.lowercased() + return s.isEmpty + ? members + : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + } + + private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) { + removeUnusedMentions(parsedMsg) + if let (ft, r) = selectedMarkdown(parsedMsg, range) { + switch ft.format { + case let .mention(name): + isVisible = true + mentionName = name + mentionRange = r + mentionMemberId = composeState.mentions[name]?.memberId + if !m.membersLoaded { + Task { await m.loadGroupMembers(groupInfo) } + } + return + case .none: () // + let pos = range.location + if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" { + let prevChar = getCharacter(msg, pos - 2)?.char + if prevChar == nil || prevChar == " " || prevChar == "\n" { + isVisible = true + mentionName = "" + mentionRange = atRange + mentionMemberId = nil + Task { await m.loadGroupMembers(groupInfo) } + return + } + } + default: () + } + } + closeMemberList() + } + + private func removeUnusedMentions(_ parsedMsg: [FormattedText]) { + let usedMentions: Set = Set(parsedMsg.compactMap { ft in + if case let .mention(name) = ft.format { name } else { nil } + }) + if usedMentions.count < composeState.mentions.count { + composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) })) + } + } + + private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? { + if pos < 0 || pos >= s.count { return nil } + let r = NSRange(location: pos, length: 1) + return if let range = Range(r, in: s) { + (s[range], r) + } else { + nil + } + } + + private func selectedMarkdown(_ parsedMsg: [FormattedText], _ range: NSRange) -> (FormattedText, NSRange)? { + if parsedMsg.isEmpty { return nil } + var i = 0 + var pos: Int = 0 + while i < parsedMsg.count && pos + parsedMsg[i].text.count < range.location { + pos += parsedMsg[i].text.count + i += 1 + } + // the second condition will be true when two markdowns are selected + return i >= parsedMsg.count || range.location + range.length > pos + parsedMsg[i].text.count + ? nil + : (parsedMsg[i], NSRange(location: pos, length: parsedMsg[i].text.count)) + } + + private func memberSelected(_ member: GMember) { + if let range = mentionRange, mentionMemberId == nil || mentionMemberId != member.wrapped.memberId { + addMemberMention(member, range) + } + } + + private func addMemberMention(_ member: GMember, _ r: NSRange) { + guard let range = Range(r, in: composeState.message) else { return } + var mentions = composeState.mentions + var newName: String + if let mm = mentions.first(where: { $0.value.memberId == member.wrapped.memberId }) { + newName = mm.key + } else { + newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName) + } + mentions[newName] = CIMention(groupMember: member.wrapped) + var msgMention = "@" + (newName.contains(" ") ? "'\(newName)'" : newName) + var newPos = r.location + msgMention.count + let newMsgLength = composeState.message.count + msgMention.count - r.length + print(newPos) + print(newMsgLength) + if newPos == newMsgLength { + msgMention += " " + newPos += 1 + } + composeState = composeState.copy( + message: composeState.message.replacingCharacters(in: range, with: msgMention), + mentions: mentions + ) + selectedRange = NSRange(location: newPos, length: 0) + closeMemberList() + keyboardVisible = true + } + + private func closeMemberList() { + isVisible = false + mentionName = "" + mentionRange = nil + mentionMemberId = nil + } + + private func memberRowView(_ member: GroupMember, _ mentioned: Bool) -> some View { + return HStack{ + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + let t = Text(member.localAliasAndFullName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) + (member.verified ? memberVerifiedShield() + t : t) + .lineLimit(1) + } + Spacer() + if mentioned { + Image(systemName: "checkmark") + } + } + + func memberVerifiedShield() -> Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(theme.colors.secondary) + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index 8dfc32f6ea..58bfe182cb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -58,7 +58,7 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary) + messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, mentions: nil, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f1ee4e4c42..39268d4727 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -302,14 +302,14 @@ struct ChatListNavLink: View { } @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { - Button { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) - } label: { - if chat.chatInfo.ntfsEnabled { - SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI) - } else { - SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI) + if let nextMode = chat.chatInfo.nextNtfMode { + Button { + toggleNotifications(chat, enableNtfs: nextMode) + } label: { + SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI) } + } else { + EmptyView() } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 68e0c57c75..863bfb1ae2 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -480,7 +480,7 @@ struct ChatListView: View { switch chatTagsModel.activeFilter { case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true - case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + case .unread: chat.unreadTag case .none: true } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 51454cc764..f8c7061077 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -188,12 +188,14 @@ struct ChatPreviewView: View { private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View { ZStack(alignment: .topTrailing) { + let s = chat.chatStats + let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 } let t = text .lineLimit(userFont <= .xxxLarge ? 2 : 1) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, hasFilePreview ? 0 : 8) - .padding(.trailing, hasFilePreview ? 38 : 36) + .padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36)) .offset(x: hasFilePreview ? -2 : 0) .fixedSize(horizontal: false, vertical: true) if !showChatPreviews && !draft { @@ -208,19 +210,34 @@ struct ChatPreviewView: View { @ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View { let s = chat.chatStats if s.unreadCount > 0 || s.unreadChat { - unreadCountText(s.unreadCount) - .font(userFont <= .xxxLarge ? .caption : .caption2) - .foregroundColor(.white) - .padding(.horizontal, dynamicSize(userFont).unreadPadding) - .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) - .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) - .cornerRadius(dynamicSize(userFont).unreadCorner) - } else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local { - Image(systemName: "speaker.slash.fill") + let mentionColor = mentionColor(chat) + HStack(alignment: .center, spacing: 2) { + if s.unreadMentions > 0 && s.unreadCount > 1 { + Text("\(MENTION_START)") + .font(userFont <= .xxxLarge ? .body : .callout) + .foregroundColor(mentionColor) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .cornerRadius(dynamicSize(userFont).unreadCorner) + .padding(.bottom, 1) + } + let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1 + (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount)) + .font(userFont <= .xxxLarge ? .caption : .caption2) + .foregroundColor(.white) + .padding(.horizontal, dynamicSize(userFont).unreadPadding) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) + .cornerRadius(dynamicSize(userFont).unreadCorner) + } + .frame(height: dynamicChatInfoSize) + } else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all { + let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize + let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary + Image(systemName: ntfMode.iconFilled) .resizable() .scaledToFill() - .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) - .foregroundColor(theme.colors.secondary) + .frame(width: iconSize, height: iconSize) + .foregroundColor(iconColor) } else if chat.chatInfo.chatSettings?.favorite ?? false { Image(systemName: "star.fill") .resizable() @@ -232,12 +249,20 @@ struct ChatPreviewView: View { Color.clear.frame(width: 0) } } + + private func mentionColor(_ chat: Chat) -> Color { + switch chat.chatInfo.chatSettings?.enableNtfs { + case .all: theme.colors.primary + case .mentions: theme.colors.primary + default: theme.colors.secondary + } + } private func messageDraft(_ draft: ComposeState) -> Text { let msg = draft.message return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) + attachment() - + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { Text(Image(systemName: s)).foregroundColor(color) + textSpace @@ -256,7 +281,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> Text { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) + return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 0c7f6136ff..0fe0f2644d 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -191,11 +191,7 @@ struct AddGroupView: View { profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on)) let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - m.groupMembers = groupMembers.map { GMember.init($0) } - m.populateGroupMembersIndexes() - } + await m.loadGroupMembers(gInfo) } let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) m.addChat(c) diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 23e1f783f7..9885811051 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -18,6 +18,7 @@ struct TerminalView: View { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State var composeState: ComposeState = ComposeState() + @State var selectedRange = NSRange() @State private var keyboardVisible = false @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var terminalItem: TerminalItem? @@ -96,6 +97,7 @@ struct TerminalView: View { SendMessageView( composeState: $composeState, + selectedRange: $selectedRange, sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index ce80adf38f..c553132dd2 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -789,7 +789,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? if let file = cItem.autoReceiveFile() { cItem = autoReceiveFile(file) ?? cItem } - let ntf: NSENotificationData = (cInfo.ntfsEnabled && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf + let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf return (chatItem.chatId, ntf) } else { return nil diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 02136a245a..c8c7db46c0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -205,6 +205,7 @@ 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; B70A39732D24090D00E80A5F /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A39722D24090D00E80A5F /* TagListView.swift */; }; + B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70CE9E52D4BE5930080F36D /* GroupMentions.swift */; }; B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; }; B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; @@ -556,6 +557,7 @@ 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; B70A39722D24090D00E80A5F /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = ""; }; + B70CE9E52D4BE5930080F36D /* GroupMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMentions.swift; sourceTree = ""; }; B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; @@ -1096,6 +1098,7 @@ 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */, 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */, 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */, + B70CE9E52D4BE5930080F36D /* GroupMentions.swift */, ); path = Group; sourceTree = ""; @@ -1452,6 +1455,7 @@ B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, + B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */, CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */, 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4ae9bda0f2..3a5f9ec995 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -52,7 +52,7 @@ public enum ChatCommand { 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, msg: MsgContent, live: Bool) + case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) @@ -226,7 +226,7 @@ public enum ChatCommand { 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, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" + 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 .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" @@ -1229,11 +1229,27 @@ public struct ComposedMessage: Encodable { public var fileSource: CryptoFile? var quotedItemId: Int64? public var msgContent: MsgContent + public var mentions: [String: Int64] - public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) { + public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent, mentions: [String: Int64] = [:]) { self.fileSource = fileSource self.quotedItemId = quotedItemId self.msgContent = msgContent + self.mentions = mentions + } +} + +public struct UpdatedMessage: Encodable { + public var msgContent: MsgContent + public var mentions: [String: Int64] + + public init(msgContent: MsgContent, mentions: [String: Int64] = [:]) { + self.msgContent = msgContent + self.mentions = mentions + } + + var cmdString: String { + "json \(encodeJSON(self))" } } @@ -2027,6 +2043,41 @@ public enum MsgFilter: String, Codable, Hashable { case none case all case mentions + + public func nextMode(mentions: Bool) -> MsgFilter { + switch self { + case .all: mentions ? .mentions : .none + case .mentions: .none + case .none: .all + } + } + + public func text(mentions: Bool) -> String { + switch self { + case .all: NSLocalizedString("Unmute", comment: "notification label action") + case .mentions: NSLocalizedString("Mute", comment: "notification label action") + case .none: + mentions + ? NSLocalizedString("Mute all", comment: "notification label action") + : NSLocalizedString("Mute", comment: "notification label action") + } + } + + public var icon: String { + return switch self { + case .all: "speaker.wave.2" + case .mentions: "speaker.badge.exclamationmark" + case .none: "speaker.slash" + } + } + + public var iconFilled: String { + return switch self { + case .all: "speaker.wave.2.fill" + case .mentions: "speaker.badge.exclamationmark.fill" + case .none: "speaker.slash.fill" + } + } } public struct UserMsgReceiptSettings: Codable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 5002de4209..dba26198c1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1447,9 +1447,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { return .other } } - - public var ntfsEnabled: Bool { - self.chatSettings?.enableNtfs == .all + + public func ntfsEnabled(chatItem: ChatItem) -> Bool { + ntfsEnabled(chatItem.meta.userMention) + } + + public func ntfsEnabled(_ userMention: Bool) -> Bool { + switch self.chatSettings?.enableNtfs { + case .all: true + case .mentions: userMention + default: false + } } public var chatSettings: ChatSettings? { @@ -1460,6 +1468,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var nextNtfMode: MsgFilter? { + self.chatSettings?.enableNtfs.nextMode(mentions: hasMentions) + } + + public var hasMentions: Bool { + if case .group = self { true } else { false } + } + public var chatTags: [Int64]? { switch self { case let .direct(contact): return contact.chatTags @@ -1559,14 +1575,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } public struct ChatStats: Decodable, Hashable { - public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { + public init(unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { self.unreadCount = unreadCount + self.unreadMentions = unreadMentions self.reportsCount = reportsCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } public var unreadCount: Int = 0 + public var unreadMentions: Int = 0 // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 public var minUnreadItemId: Int64 = 0 @@ -2085,6 +2103,16 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ? String.localizedStringWithFormat(NSLocalizedString("Past member %@", comment: "past/unknown group member"), name) : name } + + public var localAliasAndFullName: String { + get { + let p = memberProfile + let fullName = p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + let name = p.localAlias == "" ? fullName : "\(p.localAlias) (\(fullName))" + + return pastMember(name) + } + } public var memberActive: Bool { switch memberStatus { @@ -2392,6 +2420,28 @@ public struct AChatItem: Decodable, Hashable { } } +public struct CIMentionMember: Decodable, Hashable { + public var groupMemberId: Int64 + public var displayName: String + public var localAlias: String? + public var memberRole: GroupMemberRole +} + +public struct CIMention: Decodable, Hashable { + public var memberId: String + public var memberRef: CIMentionMember? + + public init(groupMember m: GroupMember) { + self.memberId = m.memberId + self.memberRef = CIMentionMember( + groupMemberId: m.groupMemberId, + displayName: m.memberProfile.displayName, + localAlias: m.memberProfile.localAlias, + memberRole: m.memberRole + ) + } +} + public struct ACIReaction: Decodable, Hashable { public var chatInfo: ChatInfo public var chatReaction: CIReaction @@ -2410,11 +2460,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions self.file = file @@ -2424,6 +2475,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] public var file: CIFile? @@ -2432,7 +2484,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, quotedItem, reactions, file + case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -2743,6 +2795,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ), @@ -2765,6 +2818,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ), @@ -2787,6 +2841,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: true, + userMention: false, deletable: false, editable: false ), @@ -2860,6 +2915,7 @@ public struct CIMeta: Decodable, Hashable { public var itemEdited: Bool public var itemTimed: CITimed? public var itemLive: Bool? + public var userMention: Bool public var deletable: Bool public var editable: Bool @@ -2884,6 +2940,7 @@ public struct CIMeta: Decodable, Hashable { itemDeleted: itemDeleted, itemEdited: itemEdited, itemLive: itemLive, + userMention: false, deletable: deletable, editable: editable ) @@ -2900,6 +2957,7 @@ public struct CIMeta: Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ) @@ -3921,6 +3979,12 @@ public struct FormattedText: Decodable, Hashable { public var text: String public var format: Format? + public static func plain(_ text: String) -> [FormattedText] { + text.isEmpty + ? [] + : [FormattedText(text: text, format: nil)] + } + public var isSecret: Bool { if case .secret = format { true } else { false } } @@ -3935,6 +3999,7 @@ public enum Format: Decodable, Equatable, Hashable { case colored(color: FormatColor) case uri case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) + case mention(memberName: String) case email case phone diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift index 5b9acc4fca..d0bf5eeb6e 100644 --- a/apps/ios/SimpleXChat/ErrorAlert.swift +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -40,7 +40,7 @@ public struct ErrorAlert: Error { self = if let chatResponse = error as? ChatResponse { ErrorAlert(chatResponse) } else { - ErrorAlert(LocalizedStringKey(error.localizedDescription)) + ErrorAlert("\(error.localizedDescription)") } } diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e1041c4263..a8d1c094d4 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -150,6 +150,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) +-- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] getUsersInfo db = getUsers db >>= mapM getUserInfo where From fffeef0e281031f52d436e68643c1fe3cdc462e4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Feb 2025 21:02:27 +0000 Subject: [PATCH 032/289] core: 6.3.0.2 --- 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 5b6175679c..6a6a0b5549 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.0.1 +version: 6.3.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/Remote.hs b/src/Simplex/Chat/Remote.hs index a2eaad11fe..2402795aad 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, 0, 1] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 2] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 1] +minRemoteHostVersion = AppVersion [6, 3, 0, 2] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From 37d9954cf719de719f41925a5e609b0567723d7a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Feb 2025 22:41:06 +0000 Subject: [PATCH 033/289] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c8c7db46c0..e3b286f469 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -520,9 +520,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; 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 = ""; }; @@ -679,9 +679,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -762,8 +762,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */, ); path = Libraries; sourceTree = ""; From f4b93f6e8a43de799af2919b6c6c15fbd6cf7283 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 4 Feb 2025 11:24:39 +0000 Subject: [PATCH 034/289] 6.3-beta.2: ios 261, android 271, desktop 89 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++++++++---------- apps/multiplatform/gradle.properties | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e3b286f469..9b2c6737d5 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1947,7 +1947,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1996,7 +1996,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2037,7 +2037,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2057,7 +2057,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2082,7 +2082,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2119,7 +2119,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2156,7 +2156,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2207,7 +2207,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2258,7 +2258,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2292,7 +2292,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260; + CURRENT_PROJECT_VERSION = 261; 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 6c04bf65d7..9fa745c88f 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-beta.1 -android.version_code=270 +android.version_name=6.3-beta.2 +android.version_code=271 -desktop.version_name=6.3-beta.1 -desktop.version_code=88 +desktop.version_name=6.3-beta.2 +desktop.version_code=89 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 844b24be9d15e8d223a8ff7f2c3c33a770d0339b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 5 Feb 2025 09:40:42 +0000 Subject: [PATCH 035/289] core: forward reports only to moderators and above roles (#5605) * core: do not forward reports * test * core: forward reports only to moderators and above roles (#5606) * core: forward reports only to moderators and above roles * test * name * name --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- src/Simplex/Chat/Protocol.hs | 19 ++++++- src/Simplex/Chat/Store/Groups.hs | 2 +- .../SQLite/Migrations/chat_query_plans.txt | 2 +- tests/ChatTests/Groups.hs | 53 +++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 53af11ada7..b366cc3979 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -390,19 +390,34 @@ forwardedGroupMsg msg@ChatMessage {chatMsgEvent} = case encoding @e of _ -> Nothing -- applied after checking forwardedGroupMsg and building list of group members to forward to, see Chat; +-- -- this filters out members if any of forwarded events in batch is an XGrpMemRestrict event referring to them, -- but practically XGrpMemRestrict is not batched with other events so it wouldn't prevent forwarding of other events --- to these members +-- to these members; +-- +-- same for reports (MCReport) - they are not batched with other events, so we can safely filter out +-- members with role less than moderator when forwarding forwardedToGroupMembers :: forall e. MsgEncodingI e => [GroupMember] -> NonEmpty (ChatMessage e) -> [GroupMember] forwardedToGroupMembers ms forwardedMsgs = - filter (\GroupMember {memberId} -> memberId `notElem` restrictMemberIds) ms + filter forwardToMember ms where + forwardToMember GroupMember {memberId, memberRole} = + (memberId `notElem` restrictMemberIds) + && (not hasReport || memberRole >= GRModerator) restrictMemberIds = mapMaybe restrictMemberId $ L.toList forwardedMsgs restrictMemberId ChatMessage {chatMsgEvent} = case encoding @e of SJson -> case chatMsgEvent of XGrpMemRestrict mId _ -> Just mId _ -> Nothing _ -> Nothing + hasReport = any isReport forwardedMsgs + isReport ChatMessage {chatMsgEvent} = case encoding @e of + SJson -> case chatMsgEvent of + XMsgNew mc -> case mcExtMsgContent mc of + ExtMsgContent {content = MCReport {}} -> True + _ -> False + _ -> False + _ -> False data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 75a6c446b6..204689a7b9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -891,7 +891,7 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = 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 member_role IN (?,?,?)") + (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) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] 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 ed3853a743..a94da2dbe6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4577,7 +4577,7 @@ Query: 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 member_role IN (?,?,?) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 2e4e777959..0c5a6bef36 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -133,6 +133,7 @@ chatGroupTests = do it "re-create member contact after deletion, many groups" testRecreateMemberContactManyGroups describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate it "forward message edit (x.msg.update)" testGroupMsgForwardEdit it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction @@ -3980,6 +3981,58 @@ testGroupMsgForward = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +testGroupMsgForwardReport :: HasCallStack => TestParams -> IO () +testGroupMsgForwardReport = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + alice ##> "/mr team bob moderator" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob from admin to moderator", + bob <## "#team: alice changed your role from admin to moderator", + cath <## "#team: alice changed the role of bob from admin to moderator" + ] + + cath ##> "/report #team content hi there" + cath <# "#team > bob hi there" + cath <## " report content" + concurrentlyN_ + [ do + alice <# "#team cath> > bob hi there" + alice <## " report content", + do + bob <# "#team cath!> > bob hi there [>>]" + bob <## " report content [>>]" + ] + + alice ##> "/mr team bob member" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob from moderator to member", + bob <## "#team: alice changed your role from moderator to member", + cath <## "#team: alice changed the role of bob from moderator to member" + ] + + cath ##> "/report #team content hi there" + cath <# "#team > bob hi there" + cath <## " report content" + concurrentlyN_ + [ do + alice <# "#team cath> > bob hi there" + alice <## " report content", + (bob "#team hey team" + alice <# "#team cath> hey team" + bob <# "#team cath> hey team [>>]" + setupGroupForwarding3 :: String -> TestCC -> TestCC -> TestCC -> IO () setupGroupForwarding3 gName alice bob cath = do createGroup3 gName alice bob cath From a622cb91f9c74a9859691a491bd13ce66c7db062 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 5 Feb 2025 22:22:32 +0700 Subject: [PATCH 036/289] ios: fix members ruins layout of ComposeView (#5607) * ios: fix members ruins layout of ComposeView * change to func * filter --------- Co-authored-by: Evgeny Poberezkin --- .../Views/Chat/Group/GroupMentions.swift | 127 ++++++++++-------- 1 file changed, 69 insertions(+), 58 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index a621dd1f67..0b9db8493b 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -27,79 +27,76 @@ struct GroupMentionsView: View { @State private var mentionName: String = "" @State private var mentionRange: NSRange? @State private var mentionMemberId: String? + @State private var sortedMembers: [GMember] = [] var body: some View { ZStack { if isVisible { - Color.white.opacity(0.01) - .edgesIgnoringSafeArea(.all) - .onTapGesture { - isVisible = false - } - } - VStack { - Spacer() - VStack { - Spacer() + let filtered = filteredMembers() + if filtered.count > 0 { + Color.white.opacity(0.01) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + isVisible = false + } VStack { - Divider() - let list = List { - ForEach(filteredMembers, id: \.wrapped.groupMemberId) { member in - let mentioned = mentionMemberId == member.wrapped.memberId - let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned - memberRowView(member.wrapped, mentioned) - .contentShape(Rectangle()) - .disabled(disabled) - .opacity(disabled ? 0.6 : 1) - .onTapGesture { - memberSelected(member) + Spacer() + VStack { + Spacer() + VStack { + Divider() + let list = List { + ForEach(filtered, id: \.wrapped.groupMemberId) { member in + let mentioned = mentionMemberId == member.wrapped.memberId + let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned + memberRowView(member.wrapped, mentioned) + .contentShape(Rectangle()) + .disabled(disabled) + .opacity(disabled ? 0.6 : 1) + .onTapGesture { + memberSelected(member) + } + } + } + .listStyle(PlainListStyle()) + .frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count))) + + if #available(iOS 16.0, *) { + list.scrollDismissesKeyboard(.never) + } else { + list } } + .background(Color(UIColor.systemBackground)) } - .listStyle(PlainListStyle()) - .frame(height: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filteredMembers.count))) - - if #available(iOS 16.0, *) { - list.scrollDismissesKeyboard(.never) - } else { - list - } + .frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS) } - .background(Color(UIColor.systemBackground)) - } - .frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS) - } - .offset(y: isVisible ? 0 : 300) - .animation(.spring(), value: isVisible) - .onChange(of: composeState.parsedMessage) { parsedMsg in - currentMessage = composeState.message - messageChanged(currentMessage, parsedMsg, selectedRange) - } - .onChange(of: selectedRange) { r in - // This condition is needed to prevent messageChanged called twice, - // because composeState.formattedText triggers later when message changes. - // The condition is only true if position changed without text change - if currentMessage == composeState.message { - messageChanged(currentMessage, composeState.parsedMessage, r) + .animation(.spring(), value: isVisible) } } - .onAppear { - currentMessage = composeState.message + } + .onChange(of: composeState.parsedMessage) { parsedMsg in + currentMessage = composeState.message + messageChanged(currentMessage, parsedMsg, selectedRange) + } + .onChange(of: selectedRange) { r in + // This condition is needed to prevent messageChanged called twice, + // because composeState.formattedText triggers later when message changes. + // The condition is only true if position changed without text change + if currentMessage == composeState.message { + messageChanged(currentMessage, composeState.parsedMessage, r) } } + .onAppear { + currentMessage = composeState.message + } } - private var filteredMembers: [GMember] { - let members = m.groupMembers - .filter { m in - let status = m.wrapped.memberStatus - return status != .memLeft && status != .memRemoved && status != .memInvited - } - .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } + private func filteredMembers() -> [GMember] { let s = mentionName.lowercased() return s.isEmpty - ? members - : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + ? sortedMembers + : sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } } private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) { @@ -112,7 +109,10 @@ struct GroupMentionsView: View { mentionRange = r mentionMemberId = composeState.mentions[name]?.memberId if !m.membersLoaded { - Task { await m.loadGroupMembers(groupInfo) } + Task { + await m.loadGroupMembers(groupInfo) + sortMembers() + } } return case .none: () // @@ -124,7 +124,10 @@ struct GroupMentionsView: View { mentionName = "" mentionRange = atRange mentionMemberId = nil - Task { await m.loadGroupMembers(groupInfo) } + Task { + await m.loadGroupMembers(groupInfo) + sortMembers() + } return } } @@ -134,6 +137,14 @@ struct GroupMentionsView: View { closeMemberList() } + private func sortMembers() { + sortedMembers = m.groupMembers.filter({ m in + let status = m.wrapped.memberStatus + return status != .memLeft && status != .memRemoved && status != .memInvited + }) + .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } + } + private func removeUnusedMentions(_ parsedMsg: [FormattedText]) { let usedMentions: Set = Set(parsedMsg.compactMap { ft in if case let .mention(name) = ft.format { name } else { nil } From 5b947b31306f89598267ed820a2420b88906cba7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 5 Feb 2025 17:57:04 +0000 Subject: [PATCH 037/289] core: 6.3.0.3 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6a6a0b5549..55b3f11e96 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.0.2 +version: 6.3.0.3 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From e6ddbc11720a2f735ed246ed6144fd7a76eb1aaa Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 6 Feb 2025 04:32:22 +0700 Subject: [PATCH 038/289] ios: faster member suggestions (#5609) --- .../Views/Chat/Group/GroupMentions.swift | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index 0b9db8493b..168f0490c3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -30,7 +30,7 @@ struct GroupMentionsView: View { @State private var sortedMembers: [GMember] = [] var body: some View { - ZStack { + ZStack(alignment: .bottom) { if isVisible { let filtered = filteredMembers() if filtered.count > 0 { @@ -39,16 +39,15 @@ struct GroupMentionsView: View { .onTapGesture { isVisible = false } - VStack { + VStack(spacing: 0) { Spacer() - VStack { - Spacer() - VStack { - Divider() - let list = List { - ForEach(filtered, id: \.wrapped.groupMemberId) { member in - let mentioned = mentionMemberId == member.wrapped.memberId - let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned + Divider() + let scroll = ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in + let mentioned = mentionMemberId == member.wrapped.memberId + let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned + ZStack(alignment: .bottom) { memberRowView(member.wrapped, mentioned) .contentShape(Rectangle()) .disabled(disabled) @@ -56,22 +55,25 @@ struct GroupMentionsView: View { .onTapGesture { memberSelected(member) } + .padding(.horizontal) + .frame(height: MEMBER_ROW_SIZE) + + Divider() + .padding(.leading) + .padding(.leading, 48) } } - .listStyle(PlainListStyle()) - .frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count))) - - if #available(iOS 16.0, *) { - list.scrollDismissesKeyboard(.never) - } else { - list - } } - .background(Color(UIColor.systemBackground)) } - .frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS) + .frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count))) + .background(Color(UIColor.systemBackground)) + + if #available(iOS 16.0, *) { + scroll.scrollDismissesKeyboard(.never) + } else { + scroll + } } - .animation(.spring(), value: isVisible) } } } From 6b7c4509fe232c0b42c79d5685d53de728aa8630 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 5 Feb 2025 21:54:51 +0000 Subject: [PATCH 039/289] android, desktop: fix footer of web port setting --- .../usersettings/networkAndServers/AdvancedNetworkSettings.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 79314fa26f..e85d88c5f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -230,7 +230,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U smpWebPort.value = it } } - SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) + SectionTextFooter(String.format(stringResource(MR.strings.network_smp_web_port_footer), if (smpWebPort.value) "443" else "5223")) SectionDividerSpaced(maxTopPadding = true) SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { From dac83892637ea3c2f570ea3b2849eb8396629845 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 5 Feb 2025 22:21:34 +0000 Subject: [PATCH 040/289] 6.3-beta.3: ios 262, android 272, desktop 90 --- 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 9b2c6737d5..7198562eb0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -520,9 +520,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; 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 = ""; }; @@ -679,9 +679,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -762,8 +762,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */, ); path = Libraries; sourceTree = ""; @@ -1947,7 +1947,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1996,7 +1996,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2037,7 +2037,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2057,7 +2057,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2082,7 +2082,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2119,7 +2119,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2156,7 +2156,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2207,7 +2207,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2258,7 +2258,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2292,7 +2292,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 261; + CURRENT_PROJECT_VERSION = 262; 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 9fa745c88f..8877e1fb8e 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-beta.2 -android.version_code=271 +android.version_name=6.3-beta.3 +android.version_code=272 -desktop.version_name=6.3-beta.2 -desktop.version_code=89 +desktop.version_name=6.3-beta.3 +desktop.version_code=90 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From e4d6a8822c92c1415a4f0c6558626726de35503d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 7 Feb 2025 17:41:15 +0400 Subject: [PATCH 041/289] core, ios: check notifications token status, offer to re-register token (#5610) * core: api to check token * ios * update library * refactor * texts * errors * check active token on start * text * Revert "check active token on start" This reverts commit c7b6e51f94b6ba637d62665471858cee237eae6f. * update simplexmq * offer re-register * text * update simplexmq * offer on check * rework * text * unset test result * simplexmq * alerts * invalid reasons * rework alert * update simplexmq * fix * simplexmq --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/ContentView.swift | 17 +++++ apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/Model/SimpleXAPI.swift | 46 ++++++++++++- .../UserSettings/NotificationsView.swift | 67 +++++++++++++++---- .../Views/UserSettings/SettingsView.swift | 6 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++------ apps/ios/SimpleXChat/APITypes.swift | 3 + apps/ios/SimpleXChat/ChatTypes.swift | 52 ++++++++++++++ cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 3 + 12 files changed, 202 insertions(+), 38 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 652258415e..3198a3d361 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -288,8 +288,10 @@ struct ContentView: View { } prefShowLANotice = true connectViaUrl() + showReRegisterTokenAlert() } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } + .onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() } .sheet(item: $noticesSheetItem) { item in switch item { case let .whatsNew(updatedConditions): @@ -468,6 +470,21 @@ struct ContentView: View { } } + func showReRegisterTokenAlert() { + dismissAllSheets() { + let m = ChatModel.shared + if let errorTknStatus = m.reRegisterTknStatus, let token = chatModel.deviceToken { + chatModel.reRegisterTknStatus = nil + AlertManager.shared.showAlert(Alert( + title: Text("Notifications error"), + message: Text(tokenStatusInfo(errorTknStatus, register: true)), + primaryButton: .default(Text("Register")) { reRegisterToken(token: token) }, + secondaryButton: .cancel() + )) + } + } + } + private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d46b524867..8b84999577 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -267,6 +267,7 @@ final class ChatModel: ObservableObject { @Published var deviceToken: DeviceToken? @Published var savedToken: DeviceToken? @Published var tokenRegistered = false + @Published var reRegisterTknStatus: NtfTknStatus? = nil @Published var tokenStatus: NtfTknStatus? @Published var notificationMode = NotificationsMode.off @Published var notificationServer: String? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 65b69a75c8..515b39284c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -542,7 +542,12 @@ func registerToken(token: DeviceToken) { Task { do { let status = try await apiRegisterToken(token: token, notificationMode: mode) - await MainActor.run { m.tokenStatus = status } + await MainActor.run { + m.tokenStatus = status + if !status.workingToken { + m.reRegisterTknStatus = status + } + } } catch let error { logger.error("registerToken apiRegisterToken error: \(responseError(error))") } @@ -550,10 +555,49 @@ func registerToken(token: DeviceToken) { } } +func tokenStatusInfo(_ status: NtfTknStatus, register: Bool) -> String { + String.localizedStringWithFormat(NSLocalizedString("Token status: %@.", comment: "token status"), status.text) + + "\n" + status.info(register: register) +} + +func reRegisterToken(token: DeviceToken) { + let m = ChatModel.shared + let mode = m.notificationMode + logger.debug("reRegisterToken \(mode.rawValue)") + Task { + do { + let status = try await apiRegisterToken(token: token, notificationMode: mode) + await MainActor.run { + m.tokenStatus = status + showAlert( + status.workingToken + ? NSLocalizedString("Notifications status", comment: "alert title") + : NSLocalizedString("Notifications error", comment: "alert title"), + message: tokenStatusInfo(status, register: false) + ) + } + } catch let error { + logger.error("reRegisterToken apiRegisterToken error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error registering for notifications", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws { try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code)) } +func apiCheckToken(token: DeviceToken) async throws -> NtfTknStatus { + let r = await chatSendCmd(.apiCheckToken(token: token)) + if case let .ntfTokenStatus(status) = r { return status } + throw r +} + func apiDeleteToken(token: DeviceToken) async throws { try await sendCommandOkResp(.apiDeleteToken(token: token)) } diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index 4e7f826f4f..c4d0588987 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -13,7 +13,7 @@ struct NotificationsView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode - @State private var showAlert: NotificationAlert? + @State private var ntfAlert: NotificationAlert? @State private var legacyDatabase = dbContainerGroupDefault.get() == .documents @State private var testing = false @State private var testedSuccess: Bool? = nil @@ -25,7 +25,7 @@ struct NotificationsView: View { ProgressView().scaleEffect(2) } } - .alert(item: $showAlert) { alert in + .alert(item: $ntfAlert) { alert in if let token = m.deviceToken { return notificationAlert(alert, token) } else { @@ -41,7 +41,7 @@ struct NotificationsView: View { List { Section { SelectionListView(list: NotificationsMode.values, selection: $notificationMode) { mode in - showAlert = .setMode(mode: mode) + ntfAlert = .setMode(mode: mode) } } footer: { VStack(alignment: .leading) { @@ -95,7 +95,7 @@ struct NotificationsView: View { if let server = m.notificationServer { smpServers("Push server", [server], theme.colors.secondary) - testServerButton(server) + testTokenButton(server) } } header: { Text("Push notifications") @@ -163,7 +163,7 @@ struct NotificationsView: View { await MainActor.run { let err = responseError(error) logger.error("apiDeleteToken error: \(err)") - showAlert = .error(title: "Error deleting token", error: err) + ntfAlert = .error(title: "Error deleting token", error: err) } } default: @@ -181,19 +181,19 @@ struct NotificationsView: View { await MainActor.run { let err = responseError(error) logger.error("apiRegisterToken error: \(err)") - showAlert = .error(title: "Error enabling notifications", error: err) + ntfAlert = .error(title: "Error enabling notifications", error: err) } } } } } - private func testServerButton(_ server: String) -> some View { + private func testTokenButton(_ server: String) -> some View { HStack { - Button("Test server") { + Button("Test notifications") { testing = true Task { - await testServer(server) + await testServerAndToken(server) await MainActor.run { testing = false } } } @@ -215,22 +215,61 @@ struct NotificationsView: View { } } - private func testServer(_ server: String) async { + private func testServerAndToken(_ server: String) async { do { let r = try await testProtoServer(server: server) switch r { case .success: - await MainActor.run { - testedSuccess = true + if let token = m.deviceToken { + do { + let status = try await apiCheckToken(token: token) + await MainActor.run { + m.tokenStatus = status + testedSuccess = status.workingToken + if status.workingToken { + showAlert( + NSLocalizedString("Notifications status", comment: "alert title"), + message: tokenStatusInfo(status, register: false) + ) + } else { + showAlert( + title: NSLocalizedString("Notifications error", comment: "alert title"), + message: tokenStatusInfo(status, register: true), + buttonTitle: "Register", + buttonAction: { + reRegisterToken(token: token) + testedSuccess = nil + }, + cancelButton: true + ) + } + } + } catch let error { + await MainActor.run { + let err = responseError(error) + logger.error("apiCheckToken \(err)") + ntfAlert = .error(title: "Error checking token status", error: err) + } + } + } else { + await MainActor.run { + showAlert( + NSLocalizedString("No token!", comment: "alert title") + ) + } } case let .failure(f): await MainActor.run { - showAlert = .testFailure(testFailure: f) + ntfAlert = .testFailure(testFailure: f) testedSuccess = false } } } catch let error { - logger.error("testServerConnection \(responseError(error))") + await MainActor.run { + let err = responseError(error) + logger.error("testServerConnection \(err)") + ntfAlert = .error(title: "Error testing server connection", error: err) + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 138c3689f5..61dbb5d5d7 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -477,7 +477,11 @@ struct SettingsView: View { case .registered: icon = "bolt.fill" color = theme.colors.secondary - case .invalid: + case .invalid: fallthrough + case .invalidBad: fallthrough + case .invalidTopic: fallthrough + case .invalidExpired: fallthrough + case .invalidUnregistered: icon = "bolt.slash" color = theme.colors.secondary case .confirmed: diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7198562eb0..95086b78a8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -166,17 +166,17 @@ 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; - 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */; }; - 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */; }; - 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 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.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.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 */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; @@ -519,17 +519,17 @@ 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a"; sourceTree = ""; }; - 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a"; sourceTree = ""; }; - 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; 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 = ""; }; 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 = ""; }; 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.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.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 = ""; }; 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; @@ -675,14 +675,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */, + 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, + 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, + 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */, - 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -759,11 +759,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 649B28D82CFE07CF00536B68 /* libffi.a */, - 649B28DC2CFE07CF00536B68 /* libgmp.a */, - 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */, + 64C829992D54AEEE006B9E89 /* libffi.a */, + 64C829982D54AEED006B9E89 /* libgmp.a */, + 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3a5f9ec995..4c26b0cf00 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -62,6 +62,7 @@ public enum ChatCommand { case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) + case apiCheckToken(token: DeviceToken) case apiDeleteToken(token: DeviceToken) case apiGetNtfConns(nonce: String, encNtfInfo: String) case apiGetConnNtfMessages(connIds: [String]) @@ -238,6 +239,7 @@ public enum ChatCommand { 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)" + case let .apiCheckToken(token): return "/_ntf check \(token.cmdString)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" case let .apiGetConnNtfMessages(connIds): return "/_ntf conn messages \(connIds.joined(separator: ","))" @@ -409,6 +411,7 @@ public enum ChatCommand { case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" + case .apiCheckToken: return "apiCheckToken" case .apiDeleteToken: return "apiDeleteToken" case .apiGetNtfConns: return "apiGetNtfConns" case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index dba26198c1..ebba98db4c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4124,9 +4124,61 @@ public enum NtfTknStatus: String, Decodable, Hashable { case new = "NEW" case registered = "REGISTERED" case invalid = "INVALID" + case invalidBad = "INVALID,BAD" + case invalidTopic = "INVALID,TOPIC" + case invalidExpired = "INVALID,EXPIRED" + case invalidUnregistered = "INVALID,UNREGISTERED" case confirmed = "CONFIRMED" case active = "ACTIVE" case expired = "EXPIRED" + + public var workingToken: Bool { + switch self { + case .new: true + case .registered: true + case .invalid: false + case .invalidBad: false + case .invalidTopic: false + case .invalidExpired: false + case .invalidUnregistered: false + case .confirmed: true + case .active: true + case .expired: false + } + } + + public var text: String { + switch self { + case .new: NSLocalizedString("New", comment: "token status text") + case .registered: NSLocalizedString("Registered", comment: "token status text") + case .invalid: NSLocalizedString("Invalid", comment: "token status text") + case .invalidBad: NSLocalizedString("Invalid (bad token)", comment: "token status text") + case .invalidTopic: NSLocalizedString("Invalid (wrong topic)", comment: "token status text") + case .invalidExpired: NSLocalizedString("Invalid (expired)", comment: "token status text") + case .invalidUnregistered: NSLocalizedString("Invalid (unregistered)", comment: "token status text") + case .confirmed: NSLocalizedString("Confirmed", comment: "token status text") + case .active: NSLocalizedString("Active", comment: "token status text") + case .expired: NSLocalizedString("Expired", comment: "token status text") + } + } + + public func info(register: Bool) -> String { + switch self { + case .new: return NSLocalizedString("Please wait for token to be registered.", comment: "token info") + case .registered: fallthrough + case .confirmed: return NSLocalizedString("Please wait for token activation to complete.", comment: "token info") + case .active: return NSLocalizedString("You should receive notifications.", comment: "token info") + case .invalid: fallthrough + case .invalidBad: fallthrough + case .invalidTopic: fallthrough + case .invalidExpired: fallthrough + case .invalidUnregistered: fallthrough + case .expired: + return register + ? NSLocalizedString("Register notification token?", comment: "token info") + : NSLocalizedString("Please try to disable and re-enable notfications.", comment: "token info") + } + } } public struct SndFileTransfer: Decodable, Hashable { diff --git a/cabal.project b/cabal.project index 0c4923940d..c9fa73c2c5 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: 45373e7f1f755ac3dfaa3f8efa03d8dc7b2d69ce + tag: 5dbe6337eab6a210d64a3b7cbdad5f0ee107686d source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d064aa098b..327544ea3d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."45373e7f1f755ac3dfaa3f8efa03d8dc7b2d69ce" = "0rz1n3hl59b6khnhrvwhlwdrmrb9fm291i3h03ddlqkrpg1x36m6"; + "https://github.com/simplex-chat/simplexmq.git"."5dbe6337eab6a210d64a3b7cbdad5f0ee107686d" = "1adml8g8zj3z0a83zlwzl1s9fv3saj1b3q95xm8p95d2w90v3s4r"; "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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a7a73eea0a..2c47a383f2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -350,6 +350,7 @@ data ChatCommand | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString + | APICheckToken DeviceToken | APIDeleteToken DeviceToken | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 22cf9ac68c..55e30c7c4a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1288,6 +1288,8 @@ processChatCommand' vr = \case APIRegisterToken token mode -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) >> ok_ + APICheckToken token -> withUser $ \_ -> + CRNtfTokenStatus <$> withAgent (`checkNtfToken` token) APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ APIGetNtfConns nonce encNtfInfo -> withUser $ \user -> do ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo @@ -3746,6 +3748,7 @@ chatCommandP = "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), + "/_ntf check " *> (APICheckToken <$> strP), "/_ntf delete " *> (APIDeleteToken <$> strP), "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), From 83984e482c877bcb6166c4da14d817c14abbb2eb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 8 Feb 2025 19:05:15 +0000 Subject: [PATCH 042/289] core: update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index c9fa73c2c5..806555e86c 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: 5dbe6337eab6a210d64a3b7cbdad5f0ee107686d + tag: a58d3540add65e43146d026666e9d82550977d24 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 327544ea3d..9c894eb925 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5dbe6337eab6a210d64a3b7cbdad5f0ee107686d" = "1adml8g8zj3z0a83zlwzl1s9fv3saj1b3q95xm8p95d2w90v3s4r"; + "https://github.com/simplex-chat/simplexmq.git"."a58d3540add65e43146d026666e9d82550977d24" = "15jpcqr6hd33fil6ldvy9l60ys8k0rq08w9y2rf73hdal3ilix4l"; "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/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 55e30c7c4a..4264bcf378 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -89,7 +89,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB -import qualified Simplex.Messaging.Agent.Store.Migrations as Migrations +import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -2408,7 +2408,7 @@ processChatCommand' vr = \case ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( let versionInfo = coreVersionInfo "" - chatMigrations <- map upMigration <$> withFastStore' Migrations.getCurrent + chatMigrations <- map upMigration <$> withFastStore' getCurrentMigrations agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do From a91599543ed8694b7064943d7b1c5af08f8aca90 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 8 Feb 2025 22:45:55 +0000 Subject: [PATCH 043/289] website: livestream group link --- website/src/finneyforum.html | 8 -------- website/src/livestream.html | 8 ++++++++ website/src/monerokon.html | 8 -------- website/src/rightscon.html | 8 -------- 4 files changed, 8 insertions(+), 24 deletions(-) delete mode 100644 website/src/finneyforum.html create mode 100644 website/src/livestream.html delete mode 100644 website/src/monerokon.html delete mode 100644 website/src/rightscon.html diff --git a/website/src/finneyforum.html b/website/src/finneyforum.html deleted file mode 100644 index 06229e4b5d..0000000000 --- a/website/src/finneyforum.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat - Finney Forum group" -description: "Join the group of attendees of Finney Forum 2024" -groupLink: "https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FTlom_0qzRaEWo_4cweE_hzj6KBmqXC8R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAZzyx3sm1tpGsYjXAOR2LxXD0ty1hlAR7Hg0fbCxEoig%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22IfdftVGf9odVOQImmz1I9A%3D%3D%22%7D" -groupLinkText: Open Finney Forum group link -templateEngineOverride: njk ---- diff --git a/website/src/livestream.html b/website/src/livestream.html new file mode 100644 index 0000000000..c95660da21 --- /dev/null +++ b/website/src/livestream.html @@ -0,0 +1,8 @@ +--- +layout: layouts/group_link.html +title: "SimpleX Chat: Power to the People" +description: "Join the group for livestream Q&A" +groupLink: "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FM1UImZkstXUcr3UPsO8Xe0swAkleCBD4%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEA4lkP2FVVIBBbSdP4GX14FEPJFXgfmGSDbutQJ0vl3Bk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22groupLinkId%22%3A%22N2tlDw-owTy_Yi05iBaZlw%3D%3D%22%7D" +groupLinkText: Open Livestream Q&A group link +templateEngineOverride: njk +--- \ No newline at end of file diff --git a/website/src/monerokon.html b/website/src/monerokon.html deleted file mode 100644 index 060c6883bb..0000000000 --- a/website/src/monerokon.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat - MoneroKon group" -description: "Join the group of attendees of Monero Konferenco 3 - Praha 2023" -groupLink: "https://simplex.chat/contact/#/?v=1-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FIE3ZKT3daRLKdQg1nSXK4U1cUK4A81XQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAN2vLBKKQiTG58nokhiBIpqvLTyfeyey6UbaFGy4cYH8%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%227LTn4BEWw4bD9Gs8snVEJA%3D%3D%22%7D" -groupLinkText: Open MoneroKon group link -templateEngineOverride: njk ---- \ No newline at end of file diff --git a/website/src/rightscon.html b/website/src/rightscon.html deleted file mode 100644 index 8cf9c9b484..0000000000 --- a/website/src/rightscon.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat - RightsCon group" -description: "Join the group of attendees of RightsCon 2023" -groupLink: "https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FyHKMxr06RLUiKent0IREl1rwUtsc1MKs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAwxplfiUKydkqy7Rbl-YQCWUSnrV_ADSd5fWvH17BvEs%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22B3dC9QiKk4AEpGWaLUuPxw%3D%3D%22%7D" -groupLinkText: Open RightsCon group link -templateEngineOverride: njk ---- From 38c5c19b178114110e8fba0892028b93e11a8a30 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:27:40 +0700 Subject: [PATCH 044/289] android: fix entering characters while sending a message (#5615) --- .../simplex/common/platform/PlatformTextField.android.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 64937b5ccc..54e437afb1 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 @@ -122,8 +122,10 @@ actual fun PlatformTextField( } override fun onSelectionChanged(selStart: Int, selEnd: Int) { - onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(selStart, selEnd), maxOf(selStart, selEnd)))) - super.onSelectionChanged(selStart, selEnd) + val start = minOf(text.length, minOf(selStart, selEnd)) + val end = minOf(text.length, maxOf(selStart, selEnd)) + onMessageChange(ComposeMessage(text.toString(), TextRange(start, end))) + super.onSelectionChanged(start, end) } } editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) From 75685df2e86901fb048e1e00cba46afaf030f4dd Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:01:55 +0700 Subject: [PATCH 045/289] android: restart the app on update (#5616) --- .../android/src/main/AndroidManifest.xml | 11 +++++++++ .../main/java/chat/simplex/app/SimplexApp.kt | 8 +++++++ .../java/chat/simplex/app/SimplexService.kt | 24 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index bb6a6f8f8a..48dfba11cc 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -179,5 +179,16 @@ android:name=".SimplexService$AutoRestartReceiver" android:enabled="true" android:exported="false" /> + + + + + + + + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index ee259a98d0..1c8209334d 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -71,6 +71,7 @@ class SimplexApp: Application(), LifecycleEventObserver { context = this initHaskell(packageName) initMultiplatform() + reconfigureBroadcastReceivers() runMigrations() tmpDir.deleteRecursively() tmpDir.mkdir() @@ -216,6 +217,7 @@ class SimplexApp: Application(), LifecycleEventObserver { appPrefs.backgroundServiceNoticeShown.set(false) } SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) + SimplexService.AppUpdateReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) CoroutineScope(Dispatchers.Default).launch { if (mode == NotificationsMode.SERVICE) { SimplexService.start() @@ -371,4 +373,10 @@ class SimplexApp: Application(), LifecycleEventObserver { override val androidApiLevel: Int get() = Build.VERSION.SDK_INT } } + + // Make sure that receivers enabled state is in actual state (same as in prefs) + private fun reconfigureBroadcastReceivers() { + val mode = appPrefs.notificationsMode.get() + SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) + SimplexService.AppUpdateReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)} } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 6ca8dd43a0..ad86759ba9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -249,6 +249,29 @@ class SimplexService: Service() { } } + // restart on app update + class AppUpdateReceiver: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // If notification service is enabled and battery optimization is disabled, restart the service on app update + if (SimplexApp.context.allowToStartServiceAfterAppExit()) { + Log.d(TAG, "AppUpdateReceiver: onReceive called") + scheduleStart(context) + } + } + + companion object { + fun toggleReceiver(enable: Boolean) { + Log.d(TAG, "AppUpdateReceiver: toggleReceiver enabled: $enable") + val component = ComponentName(BuildConfig.APPLICATION_ID, AppUpdateReceiver::class.java.name) + SimplexApp.context.packageManager.setComponentEnabledSetting( + component, + if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + } + } + class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) { override suspend fun doWork(): Result { val id = this.id @@ -694,6 +717,7 @@ class SimplexService: Service() { } ChatController.appPrefs.notificationsMode.set(NotificationsMode.OFF) StartReceiver.toggleReceiver(false) + AppUpdateReceiver.toggleReceiver(false) androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) MessagesFetcherWorker.cancelAll() safeStopService() From 75388b997e5cdaee3173c073ff9f0df0c3cbcb99 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:06:05 +0400 Subject: [PATCH 046/289] desktop: run with postgres backend (#5604) * desktop: postgres * update * update * params, instruction * script passes (app doesn't build) * fix script --- apps/multiplatform/common/build.gradle.kts | 1 + .../chat/simplex/common/platform/AppCommon.kt | 2 ++ .../chat/simplex/common/platform/Core.kt | 6 +++- apps/multiplatform/gradle.properties | 3 ++ scripts/db/README.md | 28 ++++++++++++--- scripts/desktop/build-lib-mac.sh | 36 +++++++++++++------ 6 files changed, 60 insertions(+), 16 deletions(-) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 5b7f89f2df..345a75b1e7 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -154,6 +154,7 @@ buildConfig { buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}") buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") + buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 60a65eaac6..780f8c25b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -28,6 +28,8 @@ val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) else BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE +val databaseBackend: String = if (appPlatform == AppPlatform.ANDROID) "sqlite" else BuildConfigCommon.DATABASE_BACKEND + class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { if(size > capacity) removeFirst() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 5262714099..276d3e3909 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -63,7 +63,11 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() val confirm = confirmMigrations ?: if (appPreferences.developerTools.get() && appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp - var migrated: Array = chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value) + var migrated: Array = if (databaseBackend == "postgres") { + chatMigrateInit("simplex_v1", "postgresql://simplex@/simplex_v1", MigrationConfirmation.Error.value) + } else { + chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value) + } var res: DBMigrationResult = runCatching { json.decodeFromString(migrated[0] as String) }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 8877e1fb8e..276bb8127f 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -33,3 +33,6 @@ desktop.version_code=90 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 compose.version=1.7.0 + +# Choose sqlite or postgres backend +database.backend=sqlite diff --git a/scripts/db/README.md b/scripts/db/README.md index 067aa35400..68d4f74ac7 100644 --- a/scripts/db/README.md +++ b/scripts/db/README.md @@ -17,13 +17,21 @@ 2. Prepare Postgres database. - Build `simplex-chat` executable with `client_postgres` flag and run it to initialize new Postgres chat database. + - Create Postgres database. In shell: - This should create `simplex_v1` database with `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas, and `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. + ```sh + createdb -O simplex simplex_v1 + ``` + + Or via query. + + - Build `simplex-chat` executable with `client_postgres` flag and run it to initialize new chat database. + + This should create `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas in `simplex_v1` database, with `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. 3. Load data from decrypted SQLite databases to Postgres database via pgloader. - Install pgloader and add it to PATH. + Install pgloader and add it to PATH. Run in shell (substitute paths): ```sh SQLITE_DBPATH='simplex_v1_agent.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_agent_schema' pgloader --on-error-stop sqlite.load @@ -61,7 +69,7 @@ Repeat for `simplex_v1_chat_schema`. -5. Compare number of rows between Postgres and SQLite tables. +5. \* Compare number of rows between Postgres and SQLite tables. To check number of rows for all tables in Postgres database schema run: @@ -83,3 +91,15 @@ ``` Repeat for `simplex_v1_chat_schema`. + +6. Build and run desktop app with Postgres backend. + + Run in shell (paths are from project root): + + ```sh + ./scripts/desktop/build-lib-mac.sh arm64 postgres + + ./gradlew runDistributable -Pdatabase.backend=postgres + # or + ./gradlew packageDmg -Pdatabase.backend=postgres + ``` diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 070257ea5f..66af5cbb0c 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -6,6 +6,7 @@ OS=mac ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" COMPOSE_ARCH=$ARCH GHC_VERSION=9.6.3 +DATABASE_BACKEND="${2:-sqlite}" if [ "$ARCH" == "arm64" ]; then ARCH=aarch64 @@ -24,7 +25,14 @@ for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done rm -rf $BUILD_DIR -cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' + +if [[ "$DATABASE_BACKEND" == "postgres" ]]; then + echo "Building with postgres backend..." + cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' +else + echo "Building with sqlite backend..." + cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' +fi cd $BUILD_DIR/build mkdir deps 2> /dev/null || true @@ -99,8 +107,8 @@ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -LIBCRYPTO_PATH=$(otool -l libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSdrct-*.$LIB_EXT +LIBCRYPTO_PATH=$(otool -l libHSsmplxmq-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsmplxmq-*.$LIB_EXT cp $LIBCRYPTO_PATH libcrypto.3.0.$LIB_EXT chmod 755 libcrypto.3.0.$LIB_EXT install_name_tool -id "libcrypto.3.0.$LIB_EXT" libcrypto.3.0.$LIB_EXT @@ -111,14 +119,18 @@ if [ -n "$LIBCRYPTO_PATH" ]; then install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT $LIB fi -LIBCRYPTO_PATH=$(otool -l libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -if [ -n "$LIBCRYPTO_PATH" ]; then - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsmplxmq*.$LIB_EXT -fi +# We could change libpq and libHSpstgrsql for postgres (?), remove sqlite condition for exit below. +# Unnecessary for now as app with postgres backend is not for distribution. +if [[ "$DATABASE_BACKEND" == "sqlite" ]]; then + LIBCRYPTO_PATH=$(otool -l libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) + if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSdrct-*.$LIB_EXT + fi -LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -if [ -n "$LIBCRYPTO_PATH" ]; then - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsqlcphr-*.$LIB_EXT + LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) + if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.3.0.$LIB_EXT libHSsqlcphr-*.$LIB_EXT + fi fi for lib in $(find . -type f -name "*.$LIB_EXT"); do @@ -132,7 +144,9 @@ LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | if [ -n "$LOCAL_DIRS" ]; then echo These libs still point to local directories: echo $LOCAL_DIRS - exit 1 + if [[ "$DATABASE_BACKEND" == "sqlite" ]]; then + exit 1 + fi fi cd - From 9c28a51fee0f6ba2cc30ccb96ec784f62dc10c15 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 9 Feb 2025 12:39:48 +0000 Subject: [PATCH 047/289] core: fix mentions "disappearing" on reactions (#5617) --- src/Simplex/Chat/Library/Commands.hs | 9 ++++++--- src/Simplex/Chat/Library/Subscriber.hs | 8 ++++---- src/Simplex/Chat/Store/Messages.hs | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4264bcf378..220d50f2d1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -756,9 +756,12 @@ processChatCommand' vr = \case pure $ CRChatItemReaction user add r _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" CTGroup -> - withGroupLock "chatItemReaction" chatId $ - withFastStore (\db -> (,) <$> getGroup db vr user chatId <*> getGroupChatItem db user chatId itemId) >>= \case - (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do + 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 + case ci of + ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} -> do unless (groupFeatureAllowed SGFReactions g) $ throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) unless (ciReactionAllowed ci) $ diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e929c4f325..0ea262ec4c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1688,7 +1688,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mapM_ toView cr_ groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM () - groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} brokerTs = do + groupMsgReaction g m sharedMsgId itemMemberId reaction add RcvMessage {msgId} brokerTs = do when (groupFeatureAllowed SGFReactions g) $ do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False when (reactionAllowed add reaction rs) $ do @@ -1697,7 +1697,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where updateChatItemReaction = do cr_ <- withStore $ \db -> do - CChatItem md ci <- getGroupMemberCIBySharedMsgId db user groupId itemMemberId sharedMsgId + CChatItem md ci <- getGroupMemberCIBySharedMsgId db user g itemMemberId sharedMsgId if ciReactionAllowed ci then liftIO $ do setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs @@ -1816,9 +1816,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> messageError "x.msg.update: group member attempted invalid message update" groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> UTCTime -> CM () - groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} brokerTs = do + groupMessageDelete gInfo@GroupInfo {membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} brokerTs = do let msgMemberId = fromMaybe memberId sndMemberId_ - withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo msgMemberId sharedMsgId) >>= \case Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of CIGroupRcv mem -> case sndMemberId_ of -- regular deletion diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 038186652e..c72935eeac 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2459,8 +2459,8 @@ getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupM (userId, groupId, groupMemberId, sharedMsgId) getGroupCIWithReactions db user g itemId -getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupId -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId = do +getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupInfo -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupMemberCIBySharedMsgId db user@User {userId} g@GroupInfo {groupId} memberId sharedMsgId = do itemId <- ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query @@ -2476,7 +2476,7 @@ getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId LIMIT 1 |] (GCUserMember, userId, groupId, memberId, sharedMsgId) - getGroupChatItem db user groupId itemId + getGroupCIWithReactions db user g itemId getGroupChatItemsByAgentMsgId :: DB.Connection -> User -> GroupId -> Int64 -> AgentMsgId -> IO [CChatItem 'CTGroup] getGroupChatItemsByAgentMsgId db user groupId connId msgId = do From ff35643533ba124604857b6c19e61a058175695e Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 9 Feb 2025 19:16:30 +0000 Subject: [PATCH 048/289] core: api to archive reports (#5618) * core: api to archive reports * fix queries * query plans * fix test --- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Library/Commands.hs | 74 ++++++++++++------- src/Simplex/Chat/Store/Messages.hs | 19 ++++- .../SQLite/Migrations/chat_query_plans.txt | 4 +- tests/ChatTests/Groups.hs | 73 +++++++++++++++++- 5 files changed, 142 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2c47a383f2..31f158e780 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -317,6 +317,8 @@ data ChatCommand | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, updatedMessage :: UpdatedMessage} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) + | APIArchiveReceivedReports GroupId + | APIDeleteReceivedReports GroupId (NonEmpty ChatItemId) CIDeleteMode | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIGetReactionMembers UserId GroupId ChatItemId MsgReaction | APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 220d50f2d1..e54eae4472 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -678,11 +678,11 @@ processChatCommand' vr = \case else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo case mode of CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items @@ -711,30 +711,25 @@ processChatCommand' vr = \case APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do (gInfo, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - 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 - mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items True + delGroupChatItemsForMembers user gInfo ms items + APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do + g <- getGroupInfo db vr user gId + deleteTs <- liftIO getCurrentTime + ciIds <- liftIO $ markReceivedGroupReportsDeleted db user g deleteTs + pure $ CRGroupChatItemsDeleted user g ciIds True (Just $ membership g) + 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" + case mode of + CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime + CIDMBroadcast -> do + ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + delGroupChatItemsForMembers user gInfo ms items where - assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () - assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items = - unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete - where - itemDeletable :: CChatItem 'CTGroup -> Bool - itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = - case chatDir of - CIGroupRcv GroupMember {memberRole} -> membershipMemRole >= memberRole && isJust itemSharedMsgId - CIGroupSnd -> isJust itemSharedMsgId - itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, MemberId)] - itemsMsgMemIds GroupInfo {membership = GroupMember {memberId = membershipMemId}} = mapMaybe itemMsgMemIds - where - itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, MemberId) - itemMsgMemIds (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = - join <$> forM itemSharedMsgId $ \msgId -> Just $ case chatDir of - CIGroupRcv GroupMember {memberId} -> (msgId, memberId) - CIGroupSnd -> (msgId, membershipMemId) + isRcvReport = \case + CChatItem _ ChatItem {content = CIRcvMsgContent (MCReport {})} -> True + _ -> False APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> case cType of CTDirect -> withContactLock "chatItemReaction" chatId $ @@ -2718,12 +2713,39 @@ 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 + 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 + mapM_ (sendGroupMessages user gInfo ms) events + delGroupChatItems user gInfo items True + where + assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () + assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items = + unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete + where + itemDeletable :: CChatItem 'CTGroup -> Bool + itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = + case chatDir of + CIGroupRcv GroupMember {memberRole} -> membershipMemRole >= memberRole && isJust itemSharedMsgId + CIGroupSnd -> isJust itemSharedMsgId + itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, MemberId)] + itemsMsgMemIds GroupInfo {membership = GroupMember {memberId = membershipMemId}} = mapMaybe itemMsgMemIds + where + itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, MemberId) + itemMsgMemIds (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = + join <$> forM itemSharedMsgId $ \msgId -> Just $ case chatDir of + CIGroupRcv GroupMember {memberId} -> (msgId, memberId) + CIGroupSnd -> (msgId, membershipMemId) + delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> CM ChatResponse delGroupChatItems user gInfo@GroupInfo {membership} 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 False (Just membership) + 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 @@ -3718,6 +3740,8 @@ chatCommandP = "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <*> (" json" *> jsonP <|> " text " *> updatedMessagesTextP)), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), + "/_archive reports " *> (APIArchiveReceivedReports <$> A.decimal), + "/_delete reports " *> (APIDeleteReceivedReports <$> A.decimal <*> _strP <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index c72935eeac..f849223440 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -62,6 +62,7 @@ module Simplex.Chat.Store.Messages markGroupChatItemBlocked, markGroupCIBlockedByAdmin, markMessageReportsDeleted, + markReceivedGroupReportsDeleted, deleteLocalChatItem, updateDirectChatItemsRead, getDirectUnreadTimedItems, @@ -2438,10 +2439,24 @@ markMessageReportsDeleted db User {userId} GroupInfo {groupId} ChatItem {meta = [sql| UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? AND item_deleted = ? RETURNING chat_item_id; |] - (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId) + (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId, DBCINotDeleted) + +markReceivedGroupReportsDeleted :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO [ChatItemId] +markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} deletedTs = do + currentTs <- liftIO getCurrentTime + map fromOnly + <$> DB.query + db + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = ? + RETURNING chat_item_id + |] + (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted, False) getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId sharedMsgId = do 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 a94da2dbe6..2df28067ad 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3325,11 +3325,11 @@ SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? AND item_deleted = ? RETURNING chat_item_id; Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=?) Query: UPDATE chat_items diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 0c5a6bef36..254fc6e813 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -5978,7 +5978,7 @@ testGroupMemberReports = alice ##> "\\\\ #jokes cath inappropriate joke" concurrentlyN_ [ do - alice <## "#jokes: 1 messages deleted by member alice" + alice <## "#jokes: 1 messages deleted by user" alice <## "message marked deleted by you", do bob <# "#jokes cath> [marked deleted by alice] inappropriate joke" @@ -5991,6 +5991,77 @@ testGroupMemberReports = 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 [marked deleted by alice]")]) + -- delete all reports locally + alice #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + bob #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + dan #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + cath #> "#jokes ok joke" + concurrentlyN_ + [ alice <# "#jokes cath> ok joke", + bob <# "#jokes cath> ok joke", + dan <# "#jokes cath> ok joke" + ] + dan ##> "/report #jokes content ok joke" + dan <# "#jokes > cath ok joke" + dan <## " report content" + dan ##> "/report #jokes spam ok joke" + dan <# "#jokes > cath ok joke" + dan <## " report spam" + concurrentlyN_ + [ do + alice <# "#jokes dan> > cath ok joke" + alice <## " report content" + alice <# "#jokes dan> > cath ok joke" + alice <## " report spam", + do + bob <# "#jokes dan> > cath ok joke" + bob <## " report content" + bob <# "#jokes dan> > cath ok joke" + bob <## " report spam", + (cath ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) + cath #$> ("/_get chat #1 content=report count=100", chat, []) + dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content"), (1, "report spam")]) + alice ##> "/_archive reports 1" + alice <## "#jokes: 2 messages deleted by user" + (bob ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) + bob ##> "/_archive reports 1" + bob <## "#jokes: 2 messages deleted by user" + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) + -- delete reports for all admins + alice #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + bob #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + dan #$> ("/clear #jokes", id, "#jokes: all messages are removed locally ONLY") + cath #> "#jokes ok joke 2" + concurrentlyN_ + [ alice <# "#jokes cath> ok joke 2", + bob <# "#jokes cath> ok joke 2", + dan <# "#jokes cath> ok joke 2" + ] + dan ##> "/report #jokes content ok joke 2" + dan <# "#jokes > cath ok joke 2" + dan <## " report content" + concurrentlyN_ + [ do + alice <# "#jokes dan> > cath ok joke 2" + alice <## " report content", + do + bob <# "#jokes dan> > cath ok joke 2" + bob <## " report content", + (cath "/last_item_id" + i :: ChatItemId <- read <$> getTermLine alice + alice ##> ("/_delete reports 1 " <> show i <> " broadcast") + alice <## "message marked deleted by you" + bob <# "#jokes 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")]) testMemberMention :: HasCallStack => TestParams -> IO () testMemberMention = From 205ced1c1d7b6983d0e31687384d535c5dbdc600 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 10 Feb 2025 09:06:16 +0000 Subject: [PATCH 049/289] core, ui: report preference (#5620) * core: report preference * fix tests * ios: disable reports toggle until 6.4 * android, desktop: reports preference * ui: section * boolean --- apps/ios/Shared/Views/Chat/ChatView.swift | 6 ++- .../Chat/Group/GroupPreferencesView.swift | 2 + apps/ios/SimpleXChat/ChatTypes.swift | 24 +++++++++ apps/ios/SimpleXChat/ChatUtils.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 28 +++++----- .../chat/simplex/common/model/SimpleXAPI.kt | 20 ++++++- .../views/chat/group/GroupPreferences.kt | 6 +++ .../common/views/chat/item/ChatItemView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 4 ++ src/Simplex/Chat/Library/Commands.hs | 3 +- src/Simplex/Chat/Library/Internal.hs | 10 +++- src/Simplex/Chat/Library/Subscriber.hs | 4 +- src/Simplex/Chat/Protocol.hs | 9 +++- src/Simplex/Chat/Store/Messages.hs | 4 +- .../SQLite/Migrations/chat_query_plans.txt | 9 ++++ src/Simplex/Chat/Types/Preferences.hs | 41 ++++++++++++-- tests/ChatTests/Groups.hs | 53 +++++++++++-------- tests/ChatTests/Utils.hs | 3 +- tests/ProtocolTests.hs | 2 +- 19 files changed, 177 insertions(+), 54 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 9e48bd897a..8c8e98c2f1 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1354,7 +1354,11 @@ struct ChatView: View { if ci.chatDir != .groupSnd { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) - } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { + } else if ci.meta.itemDeleted == nil && chat.groupFeatureEnabled(.reports), + case let .group(gInfo) = chat.chatInfo, + gInfo.membership.memberRole == .member + && !live + && composeState.voiceMessageRecordingState == .noRecording { reportButton(ci) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 9ef53258aa..ed39c401ce 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -37,6 +37,7 @@ struct GroupPreferencesView: View { featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) featureSection(.files, $preferences.files.enable, $preferences.files.role) featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) + featureSection(.reports, $preferences.reports.enable) featureSection(.history, $preferences.history.enable) if groupInfo.isOwner { @@ -89,6 +90,7 @@ struct GroupPreferencesView: View { settingsRow(icon, color: color) { Toggle(feature.text, isOn: enable) } + .disabled(feature == .reports) // remove in 6.4 if timedOn { DropdownCustomTimePicker( selection: $preferences.timedMessages.ttl, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ebba98db4c..e974c2ca86 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -711,6 +711,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case voice case files case simplexLinks + case reports case history public var id: Self { self } @@ -731,6 +732,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: true case .files: true case .simplexLinks: true + case .reports: false case .history: false } } @@ -744,6 +746,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") case .files: return NSLocalizedString("Files and media", comment: "chat feature") case .simplexLinks: return NSLocalizedString("SimpleX links", comment: "chat feature") + case .reports: return NSLocalizedString("Member reports", comment: "chat feature") case .history: return NSLocalizedString("Visible history", comment: "chat feature") } } @@ -757,6 +760,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: return "mic" case .files: return "doc" case .simplexLinks: return "link.circle" + case .reports: return "flag" case .history: return "clock" } } @@ -770,6 +774,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .voice: return "mic.fill" case .files: return "doc.fill" case .simplexLinks: return "link.circle.fill" + case .reports: return "flag.fill" case .history: return "clock.fill" } } @@ -819,6 +824,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .on: return "Allow to send SimpleX links." case .off: return "Prohibit sending SimpleX links." } + case .reports: + switch enabled { + case .on: return "Allow to report messsages to moderators." + case .off: return "Prohibit reporting messages to moderators." + } case .history: switch enabled { case .on: return "Send up to 100 last messages to new members." @@ -862,6 +872,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .on: return "Members can send SimpleX links." case .off: return "SimpleX links are prohibited." } + case .reports: + switch enabled { + case .on: return "Members can report messsages to moderators." + case .off: return "Reporting messages to moderators is prohibited." + } case .history: switch enabled { case .on: return "Up to 100 last messages are sent to new members." @@ -1007,6 +1022,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { public var voice: RoleGroupPreference public var files: RoleGroupPreference public var simplexLinks: RoleGroupPreference + public var reports: GroupPreference public var history: GroupPreference public init( @@ -1017,6 +1033,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { voice: RoleGroupPreference, files: RoleGroupPreference, simplexLinks: RoleGroupPreference, + reports: GroupPreference, history: GroupPreference ) { self.timedMessages = timedMessages @@ -1026,6 +1043,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { self.voice = voice self.files = files self.simplexLinks = simplexLinks + self.reports = reports self.history = history } @@ -1037,6 +1055,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { voice: RoleGroupPreference(enable: .on, role: nil), files: RoleGroupPreference(enable: .on, role: nil), simplexLinks: RoleGroupPreference(enable: .on, role: nil), + reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on) ) } @@ -1049,6 +1068,7 @@ public struct GroupPreferences: Codable, Hashable { public var voice: RoleGroupPreference? public var files: RoleGroupPreference? public var simplexLinks: RoleGroupPreference? + public var reports: GroupPreference? public var history: GroupPreference? public init( @@ -1059,6 +1079,7 @@ public struct GroupPreferences: Codable, Hashable { voice: RoleGroupPreference? = nil, files: RoleGroupPreference? = nil, simplexLinks: RoleGroupPreference? = nil, + reports: GroupPreference? = nil, history: GroupPreference? = nil ) { self.timedMessages = timedMessages @@ -1068,6 +1089,7 @@ public struct GroupPreferences: Codable, Hashable { self.voice = voice self.files = files self.simplexLinks = simplexLinks + self.reports = reports self.history = history } @@ -1079,6 +1101,7 @@ public struct GroupPreferences: Codable, Hashable { voice: RoleGroupPreference(enable: .on, role: nil), files: RoleGroupPreference(enable: .on, role: nil), simplexLinks: RoleGroupPreference(enable: .on, role: nil), + reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on) ) } @@ -1092,6 +1115,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group voice: fullPreferences.voice, files: fullPreferences.files, simplexLinks: fullPreferences.simplexLinks, + reports: fullPreferences.reports, history: fullPreferences.history ) } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 2bf861f437..6cbc76ec98 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -27,6 +27,7 @@ extension ChatLike { case .files: p.files.on(for: groupInfo.membership) case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) case .history: p.history.on + case .reports: p.reports.on } } else { return true 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 a148392572..3d64fda251 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 @@ -1225,18 +1225,7 @@ data class Chat( fun groupFeatureEnabled(feature: GroupFeature): Boolean = if (chatInfo is ChatInfo.Group) { - val groupInfo = chatInfo.groupInfo - val p = groupInfo.fullGroupPreferences - when (feature) { - GroupFeature.TimedMessages -> p.timedMessages.on - GroupFeature.DirectMessages -> p.directMessages.on(groupInfo.membership) - GroupFeature.FullDelete -> p.fullDelete.on - GroupFeature.Reactions -> p.reactions.on - GroupFeature.Voice -> p.voice.on(groupInfo.membership) - GroupFeature.Files -> p.files.on(groupInfo.membership) - GroupFeature.SimplexLinks -> p.simplexLinks.on(groupInfo.membership) - GroupFeature.History -> p.history.on - } + chatInfo.groupInfo.groupFeatureEnabled(feature) } else { true } @@ -1780,6 +1769,21 @@ data class GroupInfo ( val canModerate: Boolean get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive + fun groupFeatureEnabled(feature: GroupFeature): Boolean { + val p = fullGroupPreferences + return when (feature) { + GroupFeature.TimedMessages -> p.timedMessages.on + GroupFeature.DirectMessages -> p.directMessages.on(membership) + GroupFeature.FullDelete -> p.fullDelete.on + GroupFeature.Reactions -> p.reactions.on + GroupFeature.Voice -> p.voice.on(membership) + GroupFeature.Files -> p.files.on(membership) + GroupFeature.SimplexLinks -> p.simplexLinks.on(membership) + GroupFeature.Reports -> p.reports.on + GroupFeature.History -> p.history.on + } + } + companion object { val sampleData = GroupInfo( groupId = 1, 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 6021d48b0a..abb449f0cc 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 @@ -5160,6 +5160,7 @@ enum class GroupFeature: Feature { @SerialName("voice") Voice, @SerialName("files") Files, @SerialName("simplexLinks") SimplexLinks, + @SerialName("reports") Reports, @SerialName("history") History; override val hasParam: Boolean get() = when(this) { @@ -5176,6 +5177,7 @@ enum class GroupFeature: Feature { Voice -> true Files -> true SimplexLinks -> true + Reports -> false History -> false } @@ -5188,6 +5190,7 @@ enum class GroupFeature: Feature { Voice -> generalGetString(MR.strings.voice_messages) Files -> generalGetString(MR.strings.files_and_media) SimplexLinks -> generalGetString(MR.strings.simplex_links) + Reports -> generalGetString(MR.strings.group_reports_member_reports) History -> generalGetString(MR.strings.recent_history) } @@ -5200,6 +5203,7 @@ enum class GroupFeature: Feature { Voice -> painterResource(MR.images.ic_keyboard_voice) Files -> painterResource(MR.images.ic_draft) SimplexLinks -> painterResource(MR.images.ic_link) + Reports -> painterResource(MR.images.ic_flag) History -> painterResource(MR.images.ic_schedule) } @@ -5212,6 +5216,7 @@ enum class GroupFeature: Feature { Voice -> painterResource(MR.images.ic_keyboard_voice_filled) Files -> painterResource(MR.images.ic_draft_filled) SimplexLinks -> painterResource(MR.images.ic_link) + Reports -> painterResource(MR.images.ic_flag_filled) History -> painterResource(MR.images.ic_schedule_filled) } @@ -5246,6 +5251,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_simplex_links) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_simplex_links) } + Reports -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_member_reports) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_member_reports) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_recent_history) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_recent_history) @@ -5281,6 +5290,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_simplex_links) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.simplex_links_are_prohibited_in_group) } + Reports -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_reports) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.member_reports_are_prohibited) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.recent_history_is_sent_to_new_members) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.recent_history_is_not_sent_to_new_members) @@ -5400,6 +5413,7 @@ data class FullGroupPreferences( val voice: RoleGroupPreference, val files: RoleGroupPreference, val simplexLinks: RoleGroupPreference, + val reports: GroupPreference, val history: GroupPreference, ) { fun toGroupPreferences(): GroupPreferences = @@ -5411,7 +5425,8 @@ data class FullGroupPreferences( voice = voice, files = files, simplexLinks = simplexLinks, - history = history + reports = reports, + history = history, ) companion object { @@ -5423,6 +5438,7 @@ data class FullGroupPreferences( voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), ) } @@ -5437,6 +5453,7 @@ data class GroupPreferences( val voice: RoleGroupPreference? = null, val files: RoleGroupPreference? = null, val simplexLinks: RoleGroupPreference? = null, + val reports: GroupPreference? = null, val history: GroupPreference? = null, ) { companion object { @@ -5448,6 +5465,7 @@ data class GroupPreferences( voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), ) } 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 3d9f42f929..64195322c2 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 @@ -132,6 +132,11 @@ private fun GroupPreferencesLayout( applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role))) } + SectionDividerSpaced(true, maxBottomPadding = false) + val enableReports = remember(preferences) { mutableStateOf(preferences.reports.enable) } + FeatureSection(GroupFeature.Reports, enableReports, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(reports = GroupPreference(enable = enable))) + } SectionDividerSpaced(true, maxBottomPadding = false) val enableHistory = remember(preferences) { mutableStateOf(preferences.history.enable) } FeatureSection(GroupFeature.History, enableHistory, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> @@ -169,6 +174,7 @@ private fun FeatureSection( feature.text, icon, iconTint, + disabled = feature == GroupFeature.Reports, // remove in 6.4 checked = enableFeature.value == GroupFeatureEnabled.ON, ) { checked -> onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value) 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 e0650e1d80..10e902f24b 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 @@ -400,7 +400,7 @@ fun ChatItemView( val groupInfo = cItem.memberToModerate(cInfo)?.first if (groupInfo != null) { ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) - } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.groupFeatureEnabled(GroupFeature.Reports) && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { ReportItemAction(cItem, composeState, showMenu) } } 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 5441f0816a..4798c9df56 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2064,6 +2064,8 @@ Prohibit sending SimpleX links Send up to 100 last messages to new members. Do not send history to new members. + Allow to report messsages to moderators. + Prohibit reporting messages to moderators. Members can send disappearing messages. Disappearing messages are prohibited. Members can send direct messages. @@ -2082,6 +2084,8 @@ SimpleX links are prohibited. Up to 100 last messages are sent to new members. History is not sent to new members. + Members can report messsages to moderators. + Reporting messages is prohibited in this group. Delete after %d sec %ds diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index e54eae4472..4aed1b053d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3066,7 +3066,7 @@ processChatCommand' vr = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership mc ft fileSource <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership mc ft fileSource True <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do @@ -3974,6 +3974,7 @@ chatCommandP = "/set disappear #" *> (SetGroupTimedMessages <$> displayNameP <*> (A.space *> timedTTLOnOffP)), "/set disappear @" *> (SetContactTimedMessages <$> displayNameP <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), + "/set reports #" *> (SetGroupFeature (AGFNR SGFReports) <$> displayNameP <*> _strP), "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index faf503eb83..ea838206fe 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -320,12 +320,18 @@ 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 -> Maybe GroupFeature -prohibitedGroupContent gInfo m mc ft file_ +prohibitedGroupContent :: GroupInfo -> GroupMember -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature +prohibitedGroupContent gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}} m 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 | otherwise = Nothing + where + -- admins cannot send reports, non-admins cannot receive reports + badReportUser + | sent = userRole >= GRModerator + | otherwise = userRole < GRModerator prohibitedSimplexLinks :: GroupInfo -> GroupMember -> Maybe MarkdownList -> Bool prohibitedSimplexLinks gInfo m ft = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 0ea262ec4c..902c19ed63 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1720,7 +1720,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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_ of + | 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 @@ -1729,7 +1729,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ Nothing -> createContentItem where - rejected f = void $ newChatItem (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False + 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 diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index b366cc3979..1e5815bab3 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -410,8 +410,8 @@ forwardedToGroupMembers ms forwardedMsgs = XGrpMemRestrict mId _ -> Just mId _ -> Nothing _ -> Nothing - hasReport = any isReport forwardedMsgs - isReport ChatMessage {chatMsgEvent} = case encoding @e of + hasReport = any isReportEvent forwardedMsgs + isReportEvent ChatMessage {chatMsgEvent} = case encoding @e of SJson -> case chatMsgEvent of XMsgNew mc -> case mcExtMsgContent mc of ExtMsgContent {content = MCReport {}} -> True @@ -600,6 +600,11 @@ isVoice = \case MCVoice {} -> True _ -> False +isReport :: MsgContent -> Bool +isReport = \case + MCReport {} -> True + _ -> False + msgContentTag :: MsgContent -> MsgContentTag msgContentTag = \case MCText _ -> MCText_ diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f849223440..f37201c487 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2453,10 +2453,10 @@ markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} [sql| UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 RETURNING chat_item_id |] - (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted, False) + (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted) getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId sharedMsgId = do 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 2df28067ad..2ee87ace8f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3322,6 +3322,15 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = ? + RETURNING chat_item_id + +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=?) + Query: UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 001dd27172..2e704c5cf5 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -149,6 +149,7 @@ data GroupFeature | GFVoice | GFFiles | GFSimplexLinks + | GFReports | GFHistory deriving (Show) @@ -160,6 +161,7 @@ data SGroupFeature (f :: GroupFeature) where SGFVoice :: SGroupFeature 'GFVoice SGFFiles :: SGroupFeature 'GFFiles SGFSimplexLinks :: SGroupFeature 'GFSimplexLinks + SGFReports :: SGroupFeature 'GFReports SGFHistory :: SGroupFeature 'GFHistory deriving instance Show (SGroupFeature f) @@ -185,6 +187,7 @@ groupFeatureNameText = \case GFVoice -> "Voice messages" GFFiles -> "Files and media" GFSimplexLinks -> "SimpleX links" + GFReports -> "Member reports" GFHistory -> "Recent history" groupFeatureNameText' :: SGroupFeature f -> Text @@ -208,11 +211,12 @@ allGroupFeatures = AGF SGFVoice, AGF SGFFiles, AGF SGFSimplexLinks, + AGF SGFReports, AGF SGFHistory ] groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, history} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -220,6 +224,7 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac SGFVoice -> voice SGFFiles -> files SGFSimplexLinks -> simplexLinks + SGFReports -> reports SGFHistory -> history toGroupFeature :: SGroupFeature f -> GroupFeature @@ -231,6 +236,7 @@ toGroupFeature = \case SGFVoice -> GFVoice SGFFiles -> GFFiles SGFSimplexLinks -> GFSimplexLinks + SGFReports -> GFReports SGFHistory -> GFHistory class GroupPreferenceI p where @@ -243,7 +249,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, history} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -251,6 +257,7 @@ instance GroupPreferenceI FullGroupPreferences where SGFVoice -> voice SGFFiles -> files SGFSimplexLinks -> simplexLinks + SGFReports -> reports SGFHistory -> history {-# INLINE getGroupPreference #-} @@ -263,6 +270,7 @@ data GroupPreferences = GroupPreferences voice :: Maybe VoiceGroupPreference, files :: Maybe FilesGroupPreference, simplexLinks :: Maybe SimplexLinksGroupPreference, + reports :: Maybe ReportsGroupPreference, history :: Maybe HistoryGroupPreference } deriving (Eq, Show) @@ -296,6 +304,7 @@ setGroupPreference_ f pref prefs = SGFVoice -> prefs {voice = pref} SGFFiles -> prefs {files = pref} SGFSimplexLinks -> prefs {simplexLinks = pref} + SGFReports -> prefs {reports = pref} SGFHistory -> prefs {history = pref} setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences @@ -325,6 +334,7 @@ data FullGroupPreferences = FullGroupPreferences voice :: VoiceGroupPreference, files :: FilesGroupPreference, simplexLinks :: SimplexLinksGroupPreference, + reports :: ReportsGroupPreference, history :: HistoryGroupPreference } deriving (Eq, Show) @@ -377,22 +387,23 @@ defaultGroupPrefs = FullGroupPreferences { timedMessages = TimedMessagesGroupPreference {enable = FEOff, ttl = Just 86400}, directMessages = DirectMessagesGroupPreference {enable = FEOff, role = Nothing}, - fullDelete = FullDeleteGroupPreference {enable = FEOn, role = Just GRModerator}, + fullDelete = FullDeleteGroupPreference {enable = FEOff, role = Nothing}, reactions = ReactionsGroupPreference {enable = FEOn}, voice = VoiceGroupPreference {enable = FEOn, role = Nothing}, files = FilesGroupPreference {enable = FEOn, role = Nothing}, simplexLinks = SimplexLinksGroupPreference {enable = FEOn, role = Nothing}, + reports = ReportsGroupPreference {enable = FEOn}, history = HistoryGroupPreference {enable = FEOff} } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences 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} = defaultBusinessGroupPrefs { timedMessages = Just TimedMessagesGroupPreference {enable = maybe FEOff enableFeature timedMessages, ttl = maybe Nothing prefParam timedMessages}, - fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete, role = Just GRModerator}, + fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete, role = Nothing}, reactions = Just ReactionsGroupPreference {enable = maybe FEOn enableFeature reactions}, voice = Just VoiceGroupPreference {enable = maybe FEOff enableFeature voice, role = Nothing} } @@ -412,6 +423,7 @@ defaultBusinessGroupPrefs = voice = Just $ VoiceGroupPreference FEOff Nothing, files = Just $ FilesGroupPreference FEOn Nothing, simplexLinks = Just $ SimplexLinksGroupPreference FEOn Nothing, + reports = Just $ ReportsGroupPreference FEOff, history = Just $ HistoryGroupPreference FEOn } @@ -512,6 +524,10 @@ data SimplexLinksGroupPreference = SimplexLinksGroupPreference {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) +data ReportsGroupPreference = ReportsGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show) + data HistoryGroupPreference = HistoryGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show) @@ -550,6 +566,9 @@ instance HasField "enable" FilesGroupPreference GroupFeatureEnabled where instance HasField "enable" SimplexLinksGroupPreference GroupFeatureEnabled where hasField p@SimplexLinksGroupPreference {enable} = (\e -> p {enable = e}, enable) +instance HasField "enable" ReportsGroupPreference GroupFeatureEnabled where + hasField p@ReportsGroupPreference {enable} = (\e -> p {enable = e}, enable) + instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where hasField p@HistoryGroupPreference {enable} = (\e -> p {enable = e}, enable) @@ -595,6 +614,12 @@ instance GroupFeatureI 'GFSimplexLinks where groupPrefParam _ = Nothing groupPrefRole SimplexLinksGroupPreference {role} = role +instance GroupFeatureI 'GFReports where + type GroupFeaturePreference 'GFReports = ReportsGroupPreference + sGroupFeature = SGFReports + groupPrefParam _ = Nothing + groupPrefRole _ = Nothing + instance GroupFeatureI 'GFHistory where type GroupFeaturePreference 'GFHistory = HistoryGroupPreference sGroupFeature = SGFHistory @@ -607,6 +632,8 @@ instance GroupFeatureNoRoleI 'GFFullDelete instance GroupFeatureNoRoleI 'GFReactions +instance GroupFeatureNoRoleI 'GFReports + instance GroupFeatureNoRoleI 'GFHistory instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where @@ -761,6 +788,7 @@ mergeGroupPreferences groupPreferences = voice = pref SGFVoice, files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, + reports = pref SGFReports, history = pref SGFHistory } where @@ -777,6 +805,7 @@ toGroupPreferences groupPreferences = voice = pref SGFVoice, files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, + reports = pref SGFReports, history = pref SGFHistory } where @@ -885,6 +914,8 @@ $(J.deriveJSON defaultJSON ''FilesGroupPreference) $(J.deriveJSON defaultJSON ''SimplexLinksGroupPreference) +$(J.deriveJSON defaultJSON ''ReportsGroupPreference) + $(J.deriveJSON defaultJSON ''HistoryGroupPreference) $(J.deriveJSON defaultJSON ''GroupPreferences) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 254fc6e813..04976ad5cd 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -559,7 +559,7 @@ testGroup2 = ] dan <##> alice -- show last messages - alice ##> "/t #club 17" + alice ##> "/t #club 18" alice -- these strings are expected in any order because of sorting by time and rounding of time for sent <##? ( map (ConsoleString . ("#club " <> )) groupFeatureStrs @@ -1226,7 +1226,7 @@ testGroupMessageDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 -- alice, bob: msg id 5, cath: msg id 4 (after group invitations & group events) alice #> "#team hello!" @@ -1238,7 +1238,7 @@ testGroupMessageDelete = msgItemId1 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId1 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (1, "Full deletion: off")]) + alice #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "connected")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) @@ -1264,7 +1264,7 @@ testGroupMessageDelete = msgItemId2 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId2 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=2", chat', [((0, "connected"), Nothing), ((1, "Full deletion: off"), Nothing)]) + alice #$> ("/_get chat #1 count=2", chat', [((0, "connected"), Nothing), ((0, "connected"), Nothing)]) bob #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((1, "hi alic"), Just (0, "hello!"))]) cath #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((0, "hi alic"), Just (0, "hello!"))]) @@ -1311,7 +1311,7 @@ testGroupMessageDeleteMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1348,7 +1348,7 @@ testGroupMessageDeleteMultipleManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob ##> "/set receipts all off" bob <## "ok" @@ -1499,9 +1499,9 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" - alice ##> "/set delete #team off" - alice <## "updated group preferences:" - alice <## "Full deletion: off" + -- alice ##> "/set delete #team off" + -- alice <## "updated group preferences:" + -- alice <## "Full deletion: off" addMember "team" alice bob GRAdmin bob ##> "/j team" concurrentlyN_ @@ -1561,6 +1561,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "Voice messages: on" alice <## "Files and media: on" alice <## "SimpleX links: on" + alice <## "Member reports: on" alice <## "Recent history: on" bobAddedDan :: HasCallStack => TestCC -> IO () bobAddedDan cc = do @@ -1572,7 +1573,7 @@ testGroupModerate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ [ alice <## "#team: you changed the role of cath from admin to member", @@ -1604,7 +1605,7 @@ testGroupModerateOwn = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob - disableFullDeletion2 "team" alice bob + -- disableFullDeletion2 "team" alice bob threadDelay 1000000 alice #> "#team hello" bob <# "#team alice> hello" @@ -1619,7 +1620,7 @@ testGroupModerateMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1655,7 +1656,7 @@ testGroupModerateFullDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ [ alice <## "#team: you changed the role of cath from admin to member", @@ -1694,7 +1695,7 @@ testGroupDelayedModeration ps = do withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - disableFullDeletion2 "team" alice bob + -- disableFullDeletion2 "team" alice bob withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember @@ -1742,7 +1743,7 @@ testGroupDelayedModerationFullDelete ps = do withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - disableFullDeletion2 "team" alice bob + -- disableFullDeletion2 "team" alice bob withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember @@ -3998,6 +3999,12 @@ testGroupMsgForwardReport = cath <## "#team: alice changed the role of bob from admin to moderator" ] + alice ##> "/mr team cath member" + concurrentlyN_ + [ alice <## "#team: you changed the role of cath from admin to member", + bob <## "#team: alice changed the role of cath from admin to member", + cath <## "#team: alice changed your role from admin to member" + ] cath ##> "/report #team content hi there" cath <# "#team > bob hi there" cath <## " report content" @@ -4127,7 +4134,7 @@ testGroupMsgForwardDeletion = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" @@ -4845,7 +4852,7 @@ testGroupHistoryDeletedMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup2 "team" alice bob - disableFullDeletion2 "team" alice bob + -- disableFullDeletion2 "team" alice bob alice #> "#team hello" bob <# "#team alice> hello" @@ -5535,7 +5542,7 @@ testBlockForAllMarkedBlocked = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath threadDelay 1000000 @@ -5623,7 +5630,7 @@ testBlockForAllFullDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath alice ##> "/set delete #team on" alice <## "updated group preferences:" @@ -5704,7 +5711,7 @@ testBlockForAllAnotherAdminUnblocks = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -5733,7 +5740,7 @@ testBlockForAllBeforeJoining = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -5802,7 +5809,7 @@ testBlockForAllCantRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - disableFullDeletion3 "team" alice bob cath + -- disableFullDeletion3 "team" alice bob cath alice ##> "/unblock for all #team bob" alice <## "bad chat command: already unblocked" @@ -5919,7 +5926,7 @@ testGroupMemberReports = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "jokes" alice bob cath - disableFullDeletion3 "jokes" alice bob cath + -- disableFullDeletion3 "jokes" alice bob cath alice ##> "/mr jokes bob moderator" concurrentlyN_ [ alice <## "#jokes: you changed the role of bob from admin to moderator", diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index a6eab378d9..7bb25dbe9b 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -300,11 +300,12 @@ groupFeatures'' dir = [ ((dir, e2eeInfoNoPQStr), Nothing, Nothing), ((dir, "Disappearing messages: off"), Nothing, Nothing), ((dir, "Direct messages: on"), Nothing, Nothing), - ((dir, "Full deletion: on for moderators"), Nothing, Nothing), + ((dir, "Full deletion: off"), Nothing, Nothing), ((dir, "Message reactions: on"), Nothing, Nothing), ((dir, "Voice messages: on"), Nothing, Nothing), ((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) ] diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 4f191384ae..cb293895a9 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} +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} From 9533772aa28a2537f43d701e1af9db33981cd7a0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 10 Feb 2025 09:07:20 +0000 Subject: [PATCH 050/289] build: update query plans --- src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2ee87ace8f..041adbec1d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3325,7 +3325,7 @@ SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 RETURNING chat_item_id Plan: From 3267eb2b273b33f4d814d061ce1c15ce0cd1c1e3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 10 Feb 2025 09:07:55 +0000 Subject: [PATCH 051/289] ui: translations (#5613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2007 of 2007 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Croatian) Currently translated at 37.5% (863 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 98.4% (1975 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 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% (2007 of 2007 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% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 96.2% (2211 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 98.3% (2260 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Spanish) Currently translated at 99.8% (2297 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Italian) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Croatian) Currently translated at 39.6% (911 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Bulgarian) Currently translated at 74.7% (1500 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/bg/ * Translated using Weblate (Bulgarian) Currently translated at 97.5% (2244 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2300 of 2300 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Croatian) Currently translated at 46.7% (1076 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2300 of 2300 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Croatian) Currently translated at 49.6% (1142 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Croatian) Currently translated at 2.6% (53 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2300 of 2300 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2303 of 2303 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Italian) Currently translated at 100.0% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Bulgarian) Currently translated at 97.4% (2245 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2303 of 2303 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% (2007 of 2007 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% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Russian) Currently translated at 96.8% (1943 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Korean) Currently translated at 64.1% (1478 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ko/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2303 of 2303 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% (2007 of 2007 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% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Croatian) Currently translated at 52.0% (1199 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2303 of 2303 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% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Croatian) Currently translated at 52.4% (1208 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2007 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Polish) Currently translated at 92.5% (2131 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Polish) Currently translated at 91.4% (1835 of 2007 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2303 of 2303 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * import/export localizations --------- Co-authored-by: mlanp Co-authored-by: Random Co-authored-by: M1K4 Co-authored-by: Nenad Co-authored-by: jonnysemon Co-authored-by: summoner001 Co-authored-by: 大王叫我来巡山 Co-authored-by: No name Co-authored-by: Rafi Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: Bezruchenko Simon Co-authored-by: 109247019824 <109247019824@users.noreply.hosted.weblate.org> Co-authored-by: fran secs Co-authored-by: Near Co-authored-by: suhyuk kim Co-authored-by: Jester Hodl --- .../bg.xcloc/Localized Contents/bg.xliff | 145 +++++- .../cs.xcloc/Localized Contents/cs.xliff | 132 +++++- .../de.xcloc/Localized Contents/de.xliff | 215 ++++++++- .../en.xcloc/Localized Contents/en.xliff | 164 ++++++- .../es.xcloc/Localized Contents/es.xliff | 219 ++++++++- .../fi.xcloc/Localized Contents/fi.xliff | 132 +++++- .../fr.xcloc/Localized Contents/fr.xliff | 134 +++++- .../hr.xcloc/Localized Contents/hr.xliff | 14 +- .../hu.xcloc/Localized Contents/hu.xliff | 257 +++++++++-- .../it.xcloc/Localized Contents/it.xliff | 209 ++++++++- .../ja.xcloc/Localized Contents/ja.xliff | 132 +++++- .../nl.xcloc/Localized Contents/nl.xliff | 209 ++++++++- .../pl.xcloc/Localized Contents/pl.xliff | 141 +++++- .../ru.xcloc/Localized Contents/ru.xliff | 143 +++++- .../th.xcloc/Localized Contents/th.xliff | 132 +++++- .../tr.xcloc/Localized Contents/tr.xliff | 134 +++++- .../uk.xcloc/Localized Contents/uk.xliff | 134 +++++- .../Localized Contents/zh-Hans.xliff | 134 +++++- apps/ios/bg.lproj/Localizable.strings | 19 +- apps/ios/cs.lproj/Localizable.strings | 4 +- apps/ios/de.lproj/Localizable.strings | 236 +++++++++- apps/ios/es.lproj/Localizable.strings | 245 +++++++++- apps/ios/fi.lproj/Localizable.strings | 4 +- apps/ios/fr.lproj/Localizable.strings | 10 +- apps/ios/hu.lproj/Localizable.strings | 278 +++++++++-- apps/ios/it.lproj/Localizable.strings | 230 +++++++++- apps/ios/ja.lproj/Localizable.strings | 4 +- apps/ios/nl.lproj/Localizable.strings | 230 +++++++++- apps/ios/pl.lproj/Localizable.strings | 31 +- apps/ios/ru.lproj/Localizable.strings | 37 +- apps/ios/th.lproj/Localizable.strings | 4 +- apps/ios/tr.lproj/Localizable.strings | 10 +- apps/ios/uk.lproj/Localizable.strings | 10 +- apps/ios/zh-Hans.lproj/Localizable.strings | 10 +- .../commonMain/resources/MR/ar/strings.xml | 10 +- .../commonMain/resources/MR/bg/strings.xml | 5 +- .../commonMain/resources/MR/ca/strings.xml | 3 + .../commonMain/resources/MR/de/strings.xml | 18 +- .../commonMain/resources/MR/es/strings.xml | 34 +- .../commonMain/resources/MR/hr/strings.xml | 430 ++++++++++++++++-- .../commonMain/resources/MR/hu/strings.xml | 66 +-- .../commonMain/resources/MR/in/strings.xml | 21 + .../commonMain/resources/MR/it/strings.xml | 6 + .../commonMain/resources/MR/ko/strings.xml | 12 +- .../commonMain/resources/MR/nl/strings.xml | 24 +- .../commonMain/resources/MR/pl/strings.xml | 2 +- .../commonMain/resources/MR/uk/strings.xml | 3 + .../commonMain/resources/MR/vi/strings.xml | 269 +++++++---- .../resources/MR/zh-rCN/strings.xml | 8 +- 49 files changed, 4487 insertions(+), 566 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 048ea3990b..04dd78337f 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -531,6 +531,14 @@ <p><a href="%@">Свържете се с мен чрез SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Още няколко неща @@ -631,6 +639,10 @@ Грешки при потвърждението No comment provided by engineer. + + Active + token status text + Active connections Активни връзки @@ -1732,6 +1744,10 @@ Потвърди качването No comment provided by engineer. + + Confirmed + token status text + Connect Свързване @@ -1993,31 +2009,32 @@ This is your own one-time link! Create - Създай + Създаване No comment provided by engineer. Create 1-time link + Създаване на еднократна препратка No comment provided by engineer. Create SimpleX address - Създай SimpleX адрес + Създаване на адрес в SimpleX No comment provided by engineer. Create a group using a random profile. - Създай група с автоматично генериран профилл. + Създаване група с автоматично създаден профил. No comment provided by engineer. Create file - Създай файл + Създаване на файл server test step Create group - Създай група + Създаване на група No comment provided by engineer. @@ -3032,6 +3049,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -3179,6 +3200,10 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Грешка при отстраняване на член @@ -3279,6 +3304,10 @@ This is your own one-time link! Грешка при синхронизиране на връзката No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Грешка при актуализиране на груповия линк @@ -3356,6 +3385,10 @@ This is your own one-time link! Разшири chat item action + + Expired + token status text + Export database Експортирай база данни @@ -4077,6 +4110,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Невалиден QR код @@ -4734,7 +4787,11 @@ This is your link for group %@! Mute Без звук - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4783,6 +4840,10 @@ This is your link for group %@! Състояние на мрежата No comment provided by engineer. + + New + token status text + New Passcode Нов kод за достъп @@ -4974,6 +5035,10 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -5010,10 +5075,18 @@ This is your link for group %@! Известията са деактивирани! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5427,6 +5500,18 @@ Error: %@ Моля, съхранявайте паролата на сигурно място, НЯМА да можете да я промените, ако я загубите. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Полски интерфейс @@ -5794,6 +5879,18 @@ Enable in *Network & servers* settings. Намалена консумация на батерията No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Отхвърляне @@ -6972,6 +7069,10 @@ Enable in *Network & servers* settings. Времето на изчакване за установяване на TCP връзка No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7044,6 +7145,10 @@ Enable in *Network & servers* settings. Тестът е неуспешен на стъпка %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Тествай сървър @@ -7394,6 +7499,10 @@ You will be prompted to complete authentication before this feature is enabled.< Избор на инкогнито при свързване. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -7554,7 +7663,7 @@ To connect, please ask your contact to create another connection link and check Unmute Уведомявай - swipe action + notification label action Unread @@ -7640,6 +7749,10 @@ To connect, please ask your contact to create another connection link and check Използвай сървърите на SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Използвай чата @@ -7709,6 +7822,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -8239,6 +8356,10 @@ Repeat connection request? Изпратихте покана за групата No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Ще бъдете свързани с групата, когато устройството на домакина на групата е онлайн, моля, изчакайте или проверете по-късно! @@ -8310,7 +8431,7 @@ Repeat connection request? Your SimpleX address - Вашият SimpleX адрес + Вашият адрес в SimpleX No comment provided by engineer. @@ -9017,10 +9138,6 @@ Repeat connection request? месеци time unit - - mute - No comment provided by engineer. - never никога @@ -9239,10 +9356,6 @@ last received msg: %2$@ неизвестен статус No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 410463098f..9b3a40e012 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -519,6 +519,14 @@ <p><a href="%@"> Připojte se ke mne přes SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Ještě pár věcí @@ -613,6 +621,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -1644,6 +1656,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect Připojit @@ -2900,6 +2916,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -3044,6 +3064,10 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Chyba při odebrání člena @@ -3142,6 +3166,10 @@ This is your own one-time link! Chyba synchronizace připojení No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Chyba aktualizace odkazu skupiny @@ -3216,6 +3244,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database Export databáze @@ -3915,6 +3947,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -4541,7 +4593,11 @@ This is your link for group %@! Mute Ztlumit - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4588,6 +4644,10 @@ This is your link for group %@! Stav sítě No comment provided by engineer. + + New + token status text + New Passcode Nové heslo @@ -4777,6 +4837,10 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -4812,10 +4876,18 @@ This is your link for group %@! Oznámení jsou zakázána! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5212,6 +5284,18 @@ Error: %@ Heslo uložte bezpečně, v případě jeho ztráty jej NEBUDE možné změnit. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Polské rozhraní @@ -5571,6 +5655,18 @@ Enable in *Network & servers* settings. Snížení spotřeby baterie No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Odmítnout @@ -6724,6 +6820,10 @@ Enable in *Network & servers* settings. Časový limit připojení TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6793,6 +6893,10 @@ Enable in *Network & servers* settings. Test selhal v kroku %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Testovací server @@ -7134,6 +7238,10 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Změnit inkognito režim při připojení. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -7286,7 +7394,7 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Unmute Zrušit ztlumení - swipe action + notification label action Unread @@ -7369,6 +7477,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Používat servery SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Použijte chat @@ -7435,6 +7547,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7933,6 +8049,10 @@ Repeat connection request? Odeslali jste pozvánku do skupiny No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Ke skupině budete připojeni, až bude zařízení hostitele skupiny online, vyčkejte prosím nebo se podívejte později! @@ -8696,10 +8816,6 @@ Repeat connection request? měsíců time unit - - mute - No comment provided by engineer. - never nikdy @@ -8907,10 +9023,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index d978641d4a..f0f1bf84ef 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -14,6 +14,7 @@ + No comment provided by engineer. @@ -208,6 +209,7 @@ %d seconds(s) + %d Sekunde(n) delete after time @@ -473,7 +475,7 @@ 1 day - täglich + Älter als ein Tag delete after time time interval @@ -489,18 +491,19 @@ 1 month - monatlich + Älter als einen Monat delete after time time interval 1 week - wöchentlich + Älter als eine Woche delete after time time interval 1 year + Älter als ein Jahr delete after time @@ -540,6 +543,14 @@ <p><a href="%@">Verbinden Sie sich per SimpleX Chat mit mir</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Ein paar weitere Dinge @@ -640,6 +651,10 @@ Fehler bei der Bestätigung No comment provided by engineer. + + Active + token status text + Active connections Aktive Verbindungen @@ -657,6 +672,7 @@ Add list + Liste hinzufügen No comment provided by engineer. @@ -686,6 +702,7 @@ Add to list + Zur Liste hinzufügen No comment provided by engineer. @@ -765,6 +782,7 @@ All + Alle No comment provided by engineer. @@ -779,6 +797,7 @@ All chats will be removed from the list (text), and the list deleted. + Alle Chats werden von der Liste (text) entfernt und danach wird die Liste gelöscht. alert message @@ -978,6 +997,7 @@ Another reason + Anderer Grund report reason @@ -1007,6 +1027,7 @@ App group: + App-Gruppe: No comment provided by engineer. @@ -1056,6 +1077,7 @@ Archive + Archiv No comment provided by engineer. @@ -1070,10 +1092,12 @@ Archive report + Meldung archivieren No comment provided by engineer. Archive report? + Meldung archivieren? No comment provided by engineer. @@ -1088,6 +1112,7 @@ Ask + Fragen No comment provided by engineer. @@ -1312,6 +1337,7 @@ Businesses + Unternehmen No comment provided by engineer. @@ -1407,6 +1433,7 @@ Change automatic message deletion? + Automatisches Löschen von Nachrichten ändern? alert title @@ -1632,6 +1659,7 @@ Clear or delete group? + Gruppe entfernen oder löschen? No comment provided by engineer. @@ -1656,6 +1684,7 @@ Community guidelines violation + Verstoß gegen die Gemeinschaftsrichtlinien report reason @@ -1773,6 +1802,10 @@ Hochladen bestätigen No comment provided by engineer. + + Confirmed + token status text + Connect Verbinden @@ -1894,6 +1927,7 @@ Das ist Ihr eigener Einmal-Link! Connection blocked + Verbindung blockiert No comment provided by engineer. @@ -1909,10 +1943,13 @@ Das ist Ihr eigener Einmal-Link! Connection is blocked by server operator: %@ + Die Verbindung wurde vom Server-Betreiber blockiert: +%@ No comment provided by engineer. Connection not ready. + Verbindung noch nicht bereit. No comment provided by engineer. @@ -1927,6 +1964,7 @@ Das ist Ihr eigener Einmal-Link! Connection requires encryption renegotiation. + Die Verbindung erfordert eine Neuverhandlung der Verschlüsselung. No comment provided by engineer. @@ -2011,6 +2049,7 @@ Das ist Ihr eigener Einmal-Link! Content violates conditions of use + Inhalt verletzt Nutzungsbedingungen blocking reason @@ -2090,6 +2129,7 @@ Das ist Ihr eigener Einmal-Link! Create list + Liste erstellen No comment provided by engineer. @@ -2353,6 +2393,7 @@ Das ist Ihr eigener Einmal-Link! Delete chat messages from your device. + Chat-Nachrichten von Ihrem Gerät löschen. No comment provided by engineer. @@ -2447,6 +2488,7 @@ Das ist Ihr eigener Einmal-Link! Delete list? + Liste löschen? alert title @@ -2501,6 +2543,7 @@ Das ist Ihr eigener Einmal-Link! Delete report + Meldung löschen No comment provided by engineer. @@ -2665,10 +2708,12 @@ Das ist Ihr eigener Einmal-Link! Disable automatic message deletion? + Automatisches Löschen von Nachrichten deaktivieren? alert title Disable delete messages + Löschen von Nachrichten deaktivieren alert button @@ -2763,6 +2808,7 @@ Das ist Ihr eigener Einmal-Link! Documents: + Dokumente: No comment provided by engineer. @@ -2782,6 +2828,7 @@ Das ist Ihr eigener Einmal-Link! Done + Fertig No comment provided by engineer. @@ -3017,6 +3064,7 @@ Das ist Ihr eigener Einmal-Link! Encryption renegotiation in progress. + Die Neuverhandlung der Verschlüsselung läuft. No comment provided by engineer. @@ -3129,6 +3177,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Wechseln zum Inkognito-Profil! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut. @@ -3151,6 +3203,7 @@ Das ist Ihr eigener Einmal-Link! Error creating list + Fehler beim Erstellen der Liste alert title @@ -3170,6 +3223,7 @@ Das ist Ihr eigener Einmal-Link! Error creating report + Fehler beim Erstellen der Meldung No comment provided by engineer. @@ -3282,6 +3336,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Wiederherstellen der Verbindungen zu den Servern No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Fehler beim Entfernen des Mitglieds @@ -3289,6 +3347,7 @@ Das ist Ihr eigener Einmal-Link! Error reordering lists + Fehler beim Umsortieren der Listen alert title @@ -3303,6 +3362,7 @@ Das ist Ihr eigener Einmal-Link! Error saving chat list + Fehler beim Speichern der Chat-Liste alert title @@ -3385,6 +3445,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Synchronisieren der Verbindung No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Fehler beim Aktualisieren des Gruppen-Links @@ -3465,6 +3529,10 @@ Das ist Ihr eigener Einmal-Link! Erweitern chat item action + + Expired + token status text + Export database Datenbank exportieren @@ -3517,6 +3585,7 @@ Das ist Ihr eigener Einmal-Link! Favorites + Favoriten No comment provided by engineer. @@ -3534,6 +3603,8 @@ Das ist Ihr eigener Einmal-Link! File is blocked by server operator: (info.reason.text). + Datei wurde vom Server-Betreiber blockiert: +(info.reason.text). file error text @@ -3907,6 +3978,7 @@ Fehler: %2$@ Groups + Gruppen No comment provided by engineer. @@ -3976,6 +4048,7 @@ Fehler: %2$@ How it works + Wie es funktioniert alert button @@ -4122,10 +4195,12 @@ Weitere Verbesserungen sind bald verfügbar! Inappropriate content + Unangemessener Inhalt report reason Inappropriate profile + Unangemessenes Profil report reason @@ -4220,6 +4295,26 @@ Weitere Verbesserungen sind bald verfügbar! Interface-Farben No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Ungültiger QR-Code @@ -4495,14 +4590,17 @@ Das ist Ihr Link für die Gruppe %@! List + Liste swipe action List name and emoji should be different for all lists. + Der Listenname und das Emoji sollen für alle Listen unterschiedlich sein. No comment provided by engineer. List name... + Listenname... No comment provided by engineer. @@ -4767,6 +4865,7 @@ Das ist Ihr Link für die Gruppe %@! Messages in this chat will never be deleted. + Nachrichten in diesem Chat werden nie gelöscht. alert message @@ -4871,6 +4970,7 @@ Das ist Ihr Link für die Gruppe %@! More + Mehr swipe action @@ -4901,7 +5001,11 @@ Das ist Ihr Link für die Gruppe %@! Mute Stummschalten - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4953,6 +5057,10 @@ Das ist Ihr Link für die Gruppe %@! Netzwerkstatus No comment provided by engineer. + + New + token status text + New Passcode Neuer Zugangscode @@ -5045,14 +5153,17 @@ Das ist Ihr Link für die Gruppe %@! No chats + Keine Chats No comment provided by engineer. No chats found + Keine Chats gefunden No comment provided by engineer. No chats in list %@ + Keine Chats in der Liste %@ No comment provided by engineer. @@ -5160,8 +5271,13 @@ Das ist Ihr Link für die Gruppe %@! Keine Server für das Versenden von Dateien. servers error + + No token! + alert title + No unread chats + Keine ungelesenen Chats No comment provided by engineer. @@ -5176,6 +5292,7 @@ Das ist Ihr Link für die Gruppe %@! Notes + Anmerkungen No comment provided by engineer. @@ -5198,11 +5315,19 @@ Das ist Ihr Link für die Gruppe %@! Benachrichtigungen sind deaktiviert! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Datenschutz für Benachrichtigungen No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5288,10 +5413,12 @@ Dies erfordert die Aktivierung eines VPNs. Only sender and moderators see it + Nur Absender und Moderatoren sehen es No comment provided by engineer. Only you and moderators see it + Nur Sie und Moderatoren sehen es No comment provided by engineer. @@ -5381,10 +5508,12 @@ Dies erfordert die Aktivierung eines VPNs. Open link + Web-Link öffnen No comment provided by engineer. Open links from chat list + Web-Links aus der Chat-Liste öffnen No comment provided by engineer. @@ -5394,6 +5523,7 @@ Dies erfordert die Aktivierung eines VPNs. Open web link? + Web-Link öffnen? No comment provided by engineer. @@ -5632,6 +5762,18 @@ Fehler: %@ Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Polnische Bedienoberfläche @@ -6024,6 +6166,18 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Reduzierter Batterieverbrauch No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Ablehnen @@ -6127,34 +6281,42 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Report + Melden chat item action Report content: only group moderators will see it. + Inhalt melden: Nur Gruppenmoderatoren werden es sehen. report reason Report member profile: only group moderators will see it. + Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen. report reason Report other: only group moderators will see it. + Anderes melden: Nur Gruppenmoderatoren werden es sehen. report reason Report reason? + Grund der Meldung? No comment provided by engineer. Report spam: only group moderators will see it. + Spam melden: Nur Gruppenmoderatoren werden es sehen. report reason Report violation: only group moderators will see it. + Verstoß melden: Nur Gruppenmoderatoren werden es sehen. report reason Reports + Meldungen No comment provided by engineer. @@ -6335,6 +6497,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Save list + Liste speichern No comment provided by engineer. @@ -6794,6 +6957,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Set chat name… + Chat-Name festlegen… No comment provided by engineer. @@ -7126,6 +7290,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Spam + Spam blocking reason report reason @@ -7216,6 +7381,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Storage + Ablage No comment provided by engineer. @@ -7278,6 +7444,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Timeout der TCP-Verbindung No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7353,6 +7523,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Der Test ist beim Schritt %@ fehlgeschlagen. server test failure + + Test notifications + No comment provided by engineer. + Test server Teste Server @@ -7482,6 +7656,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro The report will be archived for you. + Die Meldung wird für Sie archiviert. No comment provided by engineer. @@ -7561,6 +7736,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden! alert message @@ -7725,6 +7901,10 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Inkognito beim Verbinden einschalten. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Deckkraft der Symbolleiste @@ -7890,7 +8070,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Unmute Stummschaltung aufheben - swipe action + notification label action Unread @@ -7982,6 +8162,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verwenden Sie SimpleX-Chat-Server? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Verwenden Sie Chat @@ -8057,6 +8241,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Die App mit einer Hand bedienen. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Benutzer-Auswahl @@ -8339,6 +8527,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Yes + Ja No comment provided by engineer. @@ -8610,6 +8799,10 @@ Verbindungsanfrage wiederholen? Sie haben eine Gruppeneinladung gesendet No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Sie werden mit der Gruppe verbunden, sobald das Endgerät des Gruppen-Hosts online ist. Bitte warten oder schauen Sie später nochmal nach! @@ -8877,6 +9070,7 @@ Verbindungsanfrage wiederholen? archived report + Archivierte Meldung No comment provided by engineer. @@ -9397,6 +9591,7 @@ Verbindungsanfrage wiederholen? moderator + Moderator member role @@ -9404,11 +9599,6 @@ Verbindungsanfrage wiederholen? Monate time unit - - mute - Stummschalten - No comment provided by engineer. - never nie @@ -9635,11 +9825,6 @@ Zuletzt empfangene Nachricht: %2$@ unbekannter Gruppenmitglieds-Status No comment provided by engineer. - - unmute - Stummschaltung aufheben - No comment provided by engineer. - unprotected Ungeschützt 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 79eb1573a4..22d61004ca 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -543,6 +543,16 @@ <p><a href="%@">Connect to me via SimpleX Chat</a></p> email text + + @%@ + @%@ + No comment provided by engineer. + + + @'%@' + @'%@' + No comment provided by engineer. + A few more things A few more things @@ -643,6 +653,11 @@ Acknowledgement errors No comment provided by engineer. + + Active + Active + token status text + Active connections Active connections @@ -1790,6 +1805,11 @@ Confirm upload No comment provided by engineer. + + Confirmed + Confirmed + token status text + Connect Connect @@ -3161,6 +3181,11 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Error connecting to forwarding server %@. Please try later. @@ -3316,6 +3341,11 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + Error registering for notifications + alert title + Error removing member Error removing member @@ -3421,6 +3451,11 @@ This is your own one-time link! Error synchronizing connection No comment provided by engineer. + + Error testing server connection + Error testing server connection + No comment provided by engineer. + Error updating group link Error updating group link @@ -3501,6 +3536,11 @@ This is your own one-time link! Expand chat item action + + Expired + Expired + token status text + Export database Export database @@ -4263,6 +4303,31 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + Invalid + token status text + + + Invalid (bad token) + Invalid (bad token) + token status text + + + Invalid (expired) + Invalid (expired) + token status text + + + Invalid (unregistered) + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + Invalid (wrong topic) + token status text + Invalid QR code Invalid QR code @@ -4949,7 +5014,12 @@ This is your link for group %@! Mute Mute - swipe action + notification label action + + + Mute all + Mute all + notification label action Muted when inactive! @@ -5001,6 +5071,11 @@ This is your link for group %@! Network status No comment provided by engineer. + + New + New + token status text + New Passcode New Passcode @@ -5211,6 +5286,11 @@ This is your link for group %@! No servers to send files. servers error + + No token! + No token! + alert title + No unread chats No unread chats @@ -5251,11 +5331,21 @@ This is your link for group %@! Notifications are disabled! No comment provided by engineer. + + Notifications error + Notifications error + alert title + Notifications privacy Notifications privacy No comment provided by engineer. + + Notifications status + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5690,6 +5780,21 @@ Error: %@ Please store passphrase securely, you will NOT be able to change it if you lose it. No comment provided by engineer. + + Please try to disable and re-enable notfications. + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + Please wait for token to be registered. + token info + Polish interface Polish interface @@ -6082,6 +6187,21 @@ Enable in *Network & servers* settings. Reduced battery usage No comment provided by engineer. + + Register + Register + No comment provided by engineer. + + + Register notification token? + Register notification token? + token info + + + Registered + Registered + token status text + Reject Reject @@ -7348,6 +7468,11 @@ Enable in *Network & servers* settings. TCP connection timeout No comment provided by engineer. + + TCP port for messaging + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7423,6 +7548,11 @@ Enable in *Network & servers* settings. Test failed at step %@. server test failure + + Test notifications + Test notifications + No comment provided by engineer. + Test server Test server @@ -7797,6 +7927,11 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + Token status: %@. + token status + Toolbar opacity Toolbar opacity @@ -7962,7 +8097,7 @@ To connect, please ask your contact to create another connection link and check Unmute Unmute - swipe action + notification label action Unread @@ -8054,6 +8189,11 @@ To connect, please ask your contact to create another connection link and check Use SimpleX Chat servers? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Use chat @@ -8129,6 +8269,11 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + Use web port + No comment provided by engineer. + User selection User selection @@ -8683,6 +8828,11 @@ Repeat connection request? You sent group invitation No comment provided by engineer. + + You should receive notifications. + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! You will be connected to group when the group host's device is online, please wait or check later! @@ -9479,11 +9629,6 @@ Repeat connection request? months time unit - - mute - mute - No comment provided by engineer. - never never @@ -9710,11 +9855,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - unmute - No comment provided by engineer. - unprotected unprotected 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 dd8790768a..86043e9fcf 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -14,6 +14,7 @@ + No comment provided by engineer. @@ -208,6 +209,7 @@ %d seconds(s) + %d segundos delete after time @@ -501,6 +503,7 @@ 1 year + Un año delete after time @@ -540,6 +543,14 @@ <p><a href="%@"> Conecta conmigo a través de SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Algunas cosas más @@ -640,6 +651,10 @@ Errores de confirmación No comment provided by engineer. + + Active + token status text + Active connections Conexiones activas @@ -657,6 +672,7 @@ Add list + Añadir lista No comment provided by engineer. @@ -686,6 +702,7 @@ Add to list + Añadir a la lista No comment provided by engineer. @@ -765,6 +782,7 @@ All + Todo No comment provided by engineer. @@ -779,6 +797,7 @@ All chats will be removed from the list (text), and the list deleted. + Todos los chats serán quitados de la lista (text) y esta será eliminada. alert message @@ -978,6 +997,7 @@ Another reason + Otro motivo report reason @@ -1007,6 +1027,7 @@ App group: + Grupo app: No comment provided by engineer. @@ -1056,6 +1077,7 @@ Archive + Archivar No comment provided by engineer. @@ -1070,10 +1092,12 @@ Archive report + Archivar informe No comment provided by engineer. Archive report? + ¿Archivar informe? No comment provided by engineer. @@ -1088,6 +1112,7 @@ Ask + Preguntar No comment provided by engineer. @@ -1312,6 +1337,7 @@ Businesses + Empresas No comment provided by engineer. @@ -1407,6 +1433,7 @@ Change automatic message deletion? + ¿Modificar la eliminación automática de mensajes? alert title @@ -1632,6 +1659,7 @@ Clear or delete group? + ¿Vaciar o eliminar grupo? No comment provided by engineer. @@ -1656,6 +1684,7 @@ Community guidelines violation + Violación de las normas de la comunidad report reason @@ -1773,6 +1802,10 @@ Confirmar subida No comment provided by engineer. + + Confirmed + token status text + Connect Conectar @@ -1894,6 +1927,7 @@ This is your own one-time link! Connection blocked + Conexión bloqueada No comment provided by engineer. @@ -1909,10 +1943,13 @@ This is your own one-time link! Connection is blocked by server operator: %@ + Conexión bloqueada por el operador del servidor: +%@ No comment provided by engineer. Connection not ready. + Conexión no establecida. No comment provided by engineer. @@ -1927,6 +1964,7 @@ This is your own one-time link! Connection requires encryption renegotiation. + La conexión requiere renegociar el cifrado. No comment provided by engineer. @@ -2011,6 +2049,7 @@ This is your own one-time link! Content violates conditions of use + El contenido viola las condiciones de uso blocking reason @@ -2090,6 +2129,7 @@ This is your own one-time link! Create list + Crear lista No comment provided by engineer. @@ -2353,6 +2393,7 @@ This is your own one-time link! Delete chat messages from your device. + Elimina los mensajes del dispositivo. No comment provided by engineer. @@ -2447,6 +2488,7 @@ This is your own one-time link! Delete list? + ¿Eliminar lista? alert title @@ -2461,7 +2503,7 @@ This is your own one-time link! Delete messages - Eliminar mensaje + Eliminar mensajes alert button @@ -2501,6 +2543,7 @@ This is your own one-time link! Delete report + Borrar informe No comment provided by engineer. @@ -2665,10 +2708,12 @@ This is your own one-time link! Disable automatic message deletion? + ¿Desactivar la eliminación automática de mensajes? alert title Disable delete messages + Desactivar alert button @@ -2763,6 +2808,7 @@ This is your own one-time link! Documents: + Documentos: No comment provided by engineer. @@ -2782,6 +2828,7 @@ This is your own one-time link! Done + Hecho No comment provided by engineer. @@ -3017,6 +3064,7 @@ This is your own one-time link! Encryption renegotiation in progress. + Renegociación de cifrado en curso. No comment provided by engineer. @@ -3129,6 +3177,10 @@ This is your own one-time link! ¡Error al cambiar a incógnito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde. @@ -3151,6 +3203,7 @@ This is your own one-time link! Error creating list + Error al crear lista alert title @@ -3170,6 +3223,7 @@ This is your own one-time link! Error creating report + Error al crear informe No comment provided by engineer. @@ -3282,6 +3336,10 @@ This is your own one-time link! Error al reconectar con los servidores No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Error al eliminar miembro @@ -3289,6 +3347,7 @@ This is your own one-time link! Error reordering lists + Error al reorganizar listas alert title @@ -3303,6 +3362,7 @@ This is your own one-time link! Error saving chat list + Error al guardar listas alert title @@ -3385,6 +3445,10 @@ This is your own one-time link! Error al sincronizar conexión No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Error al actualizar enlace de grupo @@ -3465,6 +3529,10 @@ This is your own one-time link! Expandir chat item action + + Expired + token status text + Export database Exportar base de datos @@ -3517,6 +3585,7 @@ This is your own one-time link! Favorites + Favoritos No comment provided by engineer. @@ -3534,6 +3603,8 @@ This is your own one-time link! File is blocked by server operator: (info.reason.text). + Archivo bloqueado por el operador del servidor +(info.reason.text). file error text @@ -3907,6 +3978,7 @@ Error: %2$@ Groups + Grupos No comment provided by engineer. @@ -3976,6 +4048,7 @@ Error: %2$@ How it works + Cómo funciona alert button @@ -4122,10 +4195,12 @@ More improvements are coming soon! Inappropriate content + Contenido inapropiado report reason Inappropriate profile + Perfil inapropiado report reason @@ -4220,6 +4295,26 @@ More improvements are coming soon! Colores del interfaz No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Código QR no válido @@ -4495,14 +4590,17 @@ This is your link for group %@! List + Lista swipe action List name and emoji should be different for all lists. + El nombre y el emoji deben ser diferentes en todas las listas. No comment provided by engineer. List name... + Nombre de la lista... No comment provided by engineer. @@ -4767,6 +4865,7 @@ This is your link for group %@! Messages in this chat will never be deleted. + Los mensajes de esta conversación nunca se borran. alert message @@ -4871,6 +4970,7 @@ This is your link for group %@! More + Más swipe action @@ -4901,7 +5001,11 @@ This is your link for group %@! Mute Silenciar - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4953,6 +5057,10 @@ This is your link for group %@! Estado de la red No comment provided by engineer. + + New + token status text + New Passcode Código Nuevo @@ -5045,14 +5153,17 @@ This is your link for group %@! No chats + Sin chats No comment provided by engineer. No chats found + Ningún chat encontrado No comment provided by engineer. No chats in list %@ + Sin chats en la lista %@ No comment provided by engineer. @@ -5160,8 +5271,13 @@ This is your link for group %@! Sin servidores para enviar archivos. servers error + + No token! + alert title + No unread chats + Ningún chat sin leer No comment provided by engineer. @@ -5176,6 +5292,7 @@ This is your link for group %@! Notes + Notas No comment provided by engineer. @@ -5198,11 +5315,19 @@ This is your link for group %@! ¡Las notificaciones están desactivadas! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Privacidad en las notificaciones No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5288,10 +5413,12 @@ Requiere activación de la VPN. Only sender and moderators see it + Solo el remitente y el moderador pueden verlo No comment provided by engineer. Only you and moderators see it + Solo tú y los moderadores podéis verlo No comment provided by engineer. @@ -5381,10 +5508,12 @@ Requiere activación de la VPN. Open link + Abrir enlace No comment provided by engineer. Open links from chat list + Abrir enlaces desde listado de chats No comment provided by engineer. @@ -5394,6 +5523,7 @@ Requiere activación de la VPN. Open web link? + ¿Abrir enlace web? No comment provided by engineer. @@ -5632,6 +5762,18 @@ Error: %@ Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Interfaz en polaco @@ -5799,7 +5941,7 @@ Error: %@ Protect app screen - Proteger la pantalla de la aplicación + Proteger la pantalla No comment provided by engineer. @@ -6024,6 +6166,18 @@ Actívalo en ajustes de *Servidores y Redes*. Reducción del uso de batería No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Rechazar @@ -6127,34 +6281,42 @@ Actívalo en ajustes de *Servidores y Redes*. Report + Informe chat item action Report content: only group moderators will see it. + Informar de contenido: sólo los moderadores del grupo lo verán. report reason Report member profile: only group moderators will see it. + Informar del perfil de un miembro: sólo los moderadores del grupo lo verán. report reason Report other: only group moderators will see it. + Informar de otros: sólo los moderadores del grupo lo verán. report reason Report reason? + ¿Motivo del informe? No comment provided by engineer. Report spam: only group moderators will see it. + Informar de spam: sólo los moderadores del grupo lo verán. report reason Report violation: only group moderators will see it. + Informar de violación: sólo los moderadores del grupo lo verán. report reason Reports + Informes No comment provided by engineer. @@ -6335,6 +6497,7 @@ Actívalo en ajustes de *Servidores y Redes*. Save list + Guardar lista No comment provided by engineer. @@ -6794,6 +6957,7 @@ Actívalo en ajustes de *Servidores y Redes*. Set chat name… + Nombre para el chat… No comment provided by engineer. @@ -7084,7 +7248,7 @@ Actívalo en ajustes de *Servidores y Redes*. Small groups (max 20) - Grupos pequeños (máx. 20) + Grupos pequeños (max. 20) No comment provided by engineer. @@ -7126,6 +7290,7 @@ Actívalo en ajustes de *Servidores y Redes*. Spam + Spam blocking reason report reason @@ -7216,6 +7381,7 @@ Actívalo en ajustes de *Servidores y Redes*. Storage + Almacenamiento No comment provided by engineer. @@ -7278,6 +7444,10 @@ Actívalo en ajustes de *Servidores y Redes*. Timeout de la conexión TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7353,6 +7523,10 @@ Actívalo en ajustes de *Servidores y Redes*. Prueba no superada en el paso %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Probar servidor @@ -7482,6 +7656,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The report will be archived for you. + El informe será archivado para ti. No comment provided by engineer. @@ -7516,7 +7691,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The servers for new files of your current chat profile **%@**. - Servidores para archivos nuevos en tu perfil **%@**. + Servidores para enviar archivos en tu perfil **%@**. No comment provided by engineer. @@ -7561,6 +7736,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Todos los mensajes previos a la selección serán eliminados del chat. ¡No podrá deshacerse! alert message @@ -7725,6 +7901,10 @@ Se te pedirá que completes la autenticación antes de activar esta función.Activa incógnito al conectar. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Opacidad barra @@ -7890,7 +8070,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Unmute Activar audio - swipe action + notification label action Unread @@ -7924,7 +8104,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Updating settings will re-connect the client to all servers. - Al actualizar la configuración el cliente se reconectará a todos los servidores. + Para actualizar la configuración el cliente se reconectará a todos los servidores. No comment provided by engineer. @@ -7982,6 +8162,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión ¿Usar servidores SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Usar Chat @@ -8057,6 +8241,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usa la aplicación con una sola mano. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Selección de usuarios @@ -8339,6 +8527,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Yes + Si No comment provided by engineer. @@ -8610,6 +8799,10 @@ Repeat connection request? Has enviado una invitación de grupo No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Te conectarás al grupo cuando el dispositivo del anfitrión esté en línea, por favor espera o revisa más tarde. @@ -8877,6 +9070,7 @@ Repeat connection request? archived report + informes archivados No comment provided by engineer. @@ -9397,6 +9591,7 @@ Repeat connection request? moderator + moderador member role @@ -9404,11 +9599,6 @@ Repeat connection request? meses time unit - - mute - silenciar - No comment provided by engineer. - never nunca @@ -9635,11 +9825,6 @@ last received msg: %2$@ estado desconocido No comment provided by engineer. - - unmute - activar sonido - No comment provided by engineer. - unprotected con IP desprotegida 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 5a27563de3..2a4c3fb9ff 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -506,6 +506,14 @@ <p> <a href="%@"> Ollaan yhteydessä SimpleX Chatin kautta</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Muutama asia lisää @@ -600,6 +608,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -1629,6 +1641,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect Yhdistä @@ -2884,6 +2900,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -3027,6 +3047,10 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Virhe poistettaessa jäsentä @@ -3124,6 +3148,10 @@ This is your own one-time link! Virhe yhteyden synkronoinnissa No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Virhe ryhmälinkin päivittämisessä @@ -3198,6 +3226,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database Vie tietokanta @@ -3897,6 +3929,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -4523,7 +4575,11 @@ This is your link for group %@! Mute Mykistä - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4570,6 +4626,10 @@ This is your link for group %@! Verkon tila No comment provided by engineer. + + New + token status text + New Passcode Uusi pääsykoodi @@ -4758,6 +4818,10 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -4793,10 +4857,18 @@ This is your link for group %@! Ilmoitukset on poistettu käytöstä! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5192,6 +5264,18 @@ Error: %@ Säilytä tunnuslause turvallisesti, ET voi muuttaa sitä, jos kadotat sen. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Puolalainen käyttöliittymä @@ -5551,6 +5635,18 @@ Enable in *Network & servers* settings. Pienempi akun käyttö No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Hylkää @@ -6702,6 +6798,10 @@ Enable in *Network & servers* settings. TCP-yhteyden aikakatkaisu No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6771,6 +6871,10 @@ Enable in *Network & servers* settings. Testi epäonnistui vaiheessa %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Testipalvelin @@ -7111,6 +7215,10 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -7263,7 +7371,7 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Unmute Poista mykistys - swipe action + notification label action Unread @@ -7346,6 +7454,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Käytä SimpleX Chat palvelimia? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Käytä chattia @@ -7412,6 +7524,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7910,6 +8026,10 @@ Repeat connection request? Lähetit ryhmäkutsun No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin! @@ -8673,10 +8793,6 @@ Repeat connection request? kuukautta time unit - - mute - No comment provided by engineer. - never ei koskaan @@ -8883,10 +8999,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index f0baecb04d..c030ccf98b 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -540,6 +540,14 @@ <p><a href="%@">Contactez-moi via SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Encore quelques points @@ -640,6 +648,10 @@ Erreur d'accusé de réception No comment provided by engineer. + + Active + token status text + Active connections Connections actives @@ -1773,6 +1785,10 @@ Confirmer la transmission No comment provided by engineer. + + Confirmed + token status text + Connect Se connecter @@ -3129,6 +3145,10 @@ Il s'agit de votre propre lien unique ! Erreur lors du passage en mode incognito ! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard. @@ -3282,6 +3302,10 @@ Il s'agit de votre propre lien unique ! Erreur de reconnexion des serveurs No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Erreur lors de la suppression d'un membre @@ -3385,6 +3409,10 @@ Il s'agit de votre propre lien unique ! Erreur de synchronisation de connexion No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Erreur lors de la mise à jour du lien de groupe @@ -3465,6 +3493,10 @@ Il s'agit de votre propre lien unique ! Étendre chat item action + + Expired + token status text + Export database Exporter la base de données @@ -4220,6 +4252,26 @@ D'autres améliorations sont à venir ! Couleurs d'interface No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Code QR invalide @@ -4901,7 +4953,11 @@ Voici votre lien pour le groupe %@ ! Mute Muet - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4953,6 +5009,10 @@ Voici votre lien pour le groupe %@ ! État du réseau No comment provided by engineer. + + New + token status text + New Passcode Nouveau code d'accès @@ -5160,6 +5220,10 @@ Voici votre lien pour le groupe %@ ! Pas de serveurs pour envoyer des fichiers. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -5198,11 +5262,19 @@ Voici votre lien pour le groupe %@ ! Les notifications sont désactivées ! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Notifications sécurisées No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5632,6 +5704,18 @@ Erreur : %@ Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Interface en polonais @@ -6024,6 +6108,18 @@ Activez-le dans les paramètres *Réseau et serveurs*. Réduction de la consommation de batterie No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Rejeter @@ -7278,6 +7374,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Délai de connexion TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7353,6 +7453,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Échec du test à l'étape %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Tester le serveur @@ -7725,6 +7829,10 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Basculer en mode incognito lors de la connexion. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Opacité de la barre d'outils @@ -7890,7 +7998,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Unmute Démute - swipe action + notification label action Unread @@ -7982,6 +8090,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser les serveurs SimpleX Chat ? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Utiliser le chat @@ -8057,6 +8169,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser l'application d'une main. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Sélection de l'utilisateur @@ -8610,6 +8726,10 @@ Répéter la demande de connexion ? Vous avez envoyé une invitation de groupe No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Vous serez connecté·e au groupe lorsque l'appareil de l'hôte sera en ligne, veuillez attendre ou vérifier plus tard ! @@ -9404,11 +9524,6 @@ Répéter la demande de connexion ? mois time unit - - mute - muet - No comment provided by engineer. - never jamais @@ -9635,11 +9750,6 @@ dernier message reçu : %2$@ statut inconnu No comment provided by engineer. - - unmute - démuter - No comment provided by engineer. - unprotected non protégé diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index 286533d5fe..bdb3083f5a 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -1693,12 +1693,14 @@ We will be adding server redundancy to prevent lost messages. Live message! No comment provided by engineer. - + Live messages + Žive poruke No comment provided by engineer. - + Local name + Lokalno ime No comment provided by engineer. @@ -3715,6 +3717,14 @@ SimpleX servers cannot see your profile. Immediately Odmah + + Address settings + Podešavanje adrese + + + Admins can block a member for all. + Administratori mogu da blokiraju + 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 49f863e810..5b212638e7 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -14,6 +14,7 @@ + No comment provided by engineer. @@ -208,6 +209,7 @@ %d seconds(s) + %d másodperc delete after time @@ -451,7 +453,7 @@ - voice messages up to 5 minutes. - custom time to disappear. - editing history. - - 5 perc hosszúságú hangüzenetek. + - legfeljebb 5 perc hosszúságú hangüzenetek. - egyedi üzenet-eltűnési időkorlát. - előzmények szerkesztése. No comment provided by engineer. @@ -501,6 +503,7 @@ 1 year + 1 év delete after time @@ -540,6 +543,14 @@ <p><a href="%@">Csatlakozzon hozzám a SimpleX Chaten keresztül</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Néhány további dolog @@ -640,6 +651,10 @@ Visszaigazolási hibák No comment provided by engineer. + + Active + token status text + Active connections Aktív kapcsolatok száma @@ -657,6 +672,7 @@ Add list + Lista hozzáadása No comment provided by engineer. @@ -686,6 +702,7 @@ Add to list + Hozzáadás listához No comment provided by engineer. @@ -765,6 +782,7 @@ All + Összes No comment provided by engineer. @@ -779,6 +797,7 @@ All chats will be removed from the list (text), and the list deleted. + Az összes csevegés el lesz távolítva a(z) (text) nevű listáról, és a lista is törölve lesz. alert message @@ -978,6 +997,7 @@ Another reason + Egyéb indoklás report reason @@ -1007,6 +1027,7 @@ App group: + Alkalmazáscsoport: No comment provided by engineer. @@ -1031,12 +1052,12 @@ App version - Alkalmazás verzió + Az alkalmazás verziója No comment provided by engineer. App version: v%@ - Alkalmazás verzió: v%@ + Az alkalmazás verziója: v%@ No comment provided by engineer. @@ -1056,6 +1077,7 @@ Archive + Archívum No comment provided by engineer. @@ -1070,10 +1092,12 @@ Archive report + Jelentés archiválása No comment provided by engineer. Archive report? + Jelentés archiválása? No comment provided by engineer. @@ -1088,6 +1112,7 @@ Ask + Mindig kérdezzen rá No comment provided by engineer. @@ -1177,7 +1202,7 @@ Bad message hash - Érvénytelen az üzenet hasító-értéke + Érvénytelen az üzenet hasítóértéke No comment provided by engineer. @@ -1312,6 +1337,7 @@ Businesses + Üzleti No comment provided by engineer. @@ -1407,6 +1433,7 @@ Change automatic message deletion? + Automatikus üzenettörlés módosítása? alert title @@ -1632,6 +1659,7 @@ Clear or delete group? + Csoport kiürítése vagy törlése? No comment provided by engineer. @@ -1656,6 +1684,7 @@ Community guidelines violation + Közösségi irányelvek megsértése report reason @@ -1773,6 +1802,10 @@ Feltöltés megerősítése No comment provided by engineer. + + Confirmed + token status text + Connect Kapcsolódás @@ -1894,6 +1927,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Connection blocked + A kapcsolat le van tiltva No comment provided by engineer. @@ -1909,10 +1943,13 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Connection is blocked by server operator: %@ + A kiszolgáló üzemeltetője letiltotta a kapcsolatot: +%@ No comment provided by engineer. Connection not ready. + A kapcsolat nem áll készen. No comment provided by engineer. @@ -1927,6 +1964,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Connection requires encryption renegotiation. + A kapcsolat titkosítása újraegyeztetést igényel. No comment provided by engineer. @@ -1991,7 +2029,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Contact preferences - Ismerős beállításai + Ismerős-beállítások No comment provided by engineer. @@ -2011,6 +2049,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Content violates conditions of use + A tartalom sérti a használati feltételeket blocking reason @@ -2090,6 +2129,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Create list + Lista létrehozása No comment provided by engineer. @@ -2353,6 +2393,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete chat messages from your device. + Csevegési üzenetek törlése az Ön eszközéről. No comment provided by engineer. @@ -2447,6 +2488,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete list? + Lista törlése? alert title @@ -2501,6 +2543,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete report + Jelentés törlése No comment provided by engineer. @@ -2665,10 +2708,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Disable automatic message deletion? + Automatikus üzenettörlés letiltása? alert title Disable delete messages + Üzenetek törlésének letiltása alert button @@ -2763,6 +2808,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Documents: + Dokumentumok: No comment provided by engineer. @@ -2782,6 +2828,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Done + Kész No comment provided by engineer. @@ -3017,6 +3064,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encryption renegotiation in progress. + A titkosítás újraegyeztetése folyamatban van. No comment provided by engineer. @@ -3129,6 +3177,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba az inkognitóprofilra való váltáskor! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később. @@ -3151,6 +3203,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error creating list + Hiba a lista létrehozásakor alert title @@ -3170,6 +3223,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error creating report + Hiba a jelentés létrehozásakor No comment provided by engineer. @@ -3282,6 +3336,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a kiszolgálókhoz való újrakapcsolódáskor No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Hiba a tag eltávolításakor @@ -3289,6 +3347,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error reordering lists + Hiba a listák újrarendezésekor alert title @@ -3303,6 +3362,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error saving chat list + Hiba a csevegési lista mentésekor alert title @@ -3385,6 +3445,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a kapcsolat szinkronizálásakor No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Hiba a csoporthivatkozás frissítésekor @@ -3465,6 +3529,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kibontás chat item action + + Expired + token status text + Export database Adatbázis exportálása @@ -3517,6 +3585,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Favorites + Kedvencek No comment provided by engineer. @@ -3534,6 +3603,8 @@ Ez az Ön egyszer használható meghívó-hivatkozása! File is blocked by server operator: (info.reason.text). + A kiszolgáló üzemeltetője letiltotta a fájlt: +(info.reason.text). file error text @@ -3877,7 +3948,7 @@ Hiba: %2$@ Group preferences - Csoportbeállítások + Csoport-beállítások No comment provided by engineer. @@ -3907,6 +3978,7 @@ Hiba: %2$@ Groups + Csoportok No comment provided by engineer. @@ -3976,6 +4048,7 @@ Hiba: %2$@ How it works + Hogyan működik alert button @@ -4122,10 +4195,12 @@ További fejlesztések hamarosan! Inappropriate content + Kifogásolt tartalom report reason Inappropriate profile + Kifogásolt profil report reason @@ -4220,6 +4295,26 @@ További fejlesztések hamarosan! Kezelőfelület színei No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Érvénytelen QR-kód @@ -4495,14 +4590,17 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! List + Lista swipe action List name and emoji should be different for all lists. + Az összes lista nevének és emodzsiának különbözőnek kell lennie. No comment provided by engineer. List name... + Listanév… No comment provided by engineer. @@ -4767,6 +4865,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages in this chat will never be deleted. + Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. alert message @@ -4871,6 +4970,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! More + Továbbiak swipe action @@ -4901,7 +5001,11 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Mute Némítás - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4953,6 +5057,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Hálózat állapota No comment provided by engineer. + + New + token status text + New Passcode Új jelkód @@ -5045,19 +5153,22 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No chats + Nincsenek csevegések No comment provided by engineer. No chats found + Nem találhatók csevegések No comment provided by engineer. No chats in list %@ + Nincsenek csevegések a(z) %@ nevű listában No comment provided by engineer. No contacts selected - Nincs kijelölve ismerős + Nincs ismerős kijelölve No comment provided by engineer. @@ -5160,8 +5271,13 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincsenek fájlküldő-kiszolgálók. servers error + + No token! + alert title + No unread chats + Nincsenek olvasatlan csevegések No comment provided by engineer. @@ -5176,11 +5292,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Notes + Jegyzetek No comment provided by engineer. Nothing selected - Nincs kijelölve semmi + Nincs semmi kijelölve No comment provided by engineer. @@ -5198,11 +5315,19 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Az értesítések le vannak tiltva! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Értesítési adatvédelem No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5273,25 +5398,27 @@ VPN engedélyezése szükséges. Only group owners can change group preferences. - Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat. + Csak a csoport-tulajdonosok változtathatják meg a csoport-beállításokat. No comment provided by engineer. Only group owners can enable files and media. - Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. + Csak a csoport-tulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. No comment provided by engineer. Only group owners can enable voice messages. - Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. + Csak a csoport-tulajdonosok engedélyezhetik a hangüzenetek küldését. No comment provided by engineer. Only sender and moderators see it + Csak a küldő és a moderátorok látják No comment provided by engineer. Only you and moderators see it + Csak Ön és a moderátorok látják No comment provided by engineer. @@ -5366,7 +5493,7 @@ VPN engedélyezése szükséges. Open chat console - Csevegés konzol megnyitása + Csevegési konzol megnyitása authentication reason @@ -5381,10 +5508,12 @@ VPN engedélyezése szükséges. Open link + Hivatkozás megnyitása No comment provided by engineer. Open links from chat list + Hivatkozás megnyitása a csevegési listából No comment provided by engineer. @@ -5394,6 +5523,7 @@ VPN engedélyezése szükséges. Open web link? + Webhivatkozás megnyitása? No comment provided by engineer. @@ -5632,6 +5762,18 @@ Hiba: %@ Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Lengyel kezelőfelület @@ -5805,7 +5947,7 @@ Hiba: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben. + Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben. Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. No comment provided by engineer. @@ -6024,6 +6166,18 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csökkentett akkumulátor-használat No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Elutasítás @@ -6127,34 +6281,42 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Report + Jelentés chat item action Report content: only group moderators will see it. + Tartalom jelentése: csak a csoport moderátorai látják. report reason Report member profile: only group moderators will see it. + Tag profiljának jelentése: csak a csoport moderátorai látják. report reason Report other: only group moderators will see it. + Egyéb jelentés: csak a csoport moderátorai látják. report reason Report reason? + Jelentés indoklása? No comment provided by engineer. Report spam: only group moderators will see it. + Kéretlen tartalom jelentése: csak a csoport moderátorai látják. report reason Report violation: only group moderators will see it. + Szabálysértés jelentése: csak a csoport moderátorai látják. report reason Reports + Jelentések No comment provided by engineer. @@ -6335,6 +6497,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Save list + Lista mentése No comment provided by engineer. @@ -6594,7 +6757,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send up to 100 last messages to new members. - Az utolsó 100 üzenet elküldése az új tagoknak. + Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára. No comment provided by engineer. @@ -6694,7 +6857,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sent via proxy - Proxyn keresztül küldve + Proxyn keresztül küldött No comment provided by engineer. @@ -6794,6 +6957,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Set chat name… + Csevegés nevének beállítása… No comment provided by engineer. @@ -6808,7 +6972,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Set group preferences - Csoportbeállítások megadása + Csoport-beállítások megadása No comment provided by engineer. @@ -7126,6 +7290,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Spam + Kéretlen tartalom blocking reason report reason @@ -7216,6 +7381,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Storage + Tárhely No comment provided by engineer. @@ -7278,6 +7444,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. TCP kapcsolat időtúllépése No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7353,6 +7523,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. A teszt sikertelen volt a(z) %@ lépésnél. server test failure + + Test notifications + No comment provided by engineer. + Test server Kiszolgáló tesztelése @@ -7442,12 +7616,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The future of messaging - A privát üzenetküldés következő generációja + Az üzenetváltás jövője No comment provided by engineer. The hash of the previous message is different. - Az előző üzenet hasító értéke különbözik. + Az előző üzenet hasítóértéke különbözik. No comment provided by engineer. @@ -7482,6 +7656,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The report will be archived for you. + A jelentés archiválva lesz az Ön számára. No comment provided by engineer. @@ -7546,7 +7721,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. They can be overridden in contact and group settings. - Ezek felülbírálhatók az ismerős- és csoportbeállításokban. + Ezek felülbírálhatók az ismerős- és csoport-beállításokban. No comment provided by engineer. @@ -7561,6 +7736,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. alert message @@ -7725,6 +7901,10 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Inkognitóra váltás kapcsolódáskor. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Eszköztár átlátszatlansága @@ -7890,7 +8070,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Unmute Némítás megszüntetése - swipe action + notification label action Unread @@ -7982,9 +8162,13 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc SimpleX Chat-kiszolgálók használata? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat - Csevegés használata + Simplex Chat használata No comment provided by engineer. @@ -8057,6 +8241,10 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Használja az alkalmazást egy kézzel. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Felhasználó kijelölése @@ -8134,7 +8322,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Videos and files up to 1gb - Videók és fájlok 1Gb méretig + Videók és fájlok legfeljebb 1GB méretig No comment provided by engineer. @@ -8339,6 +8527,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Yes + Igen No comment provided by engineer. @@ -8610,6 +8799,10 @@ Kapcsolatkérés megismétlése? Csoportmeghívó elküldve No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! @@ -8877,6 +9070,7 @@ Kapcsolatkérés megismétlése? archived report + archivált jelentés No comment provided by engineer. @@ -8901,7 +9095,7 @@ Kapcsolatkérés megismétlése? bad message hash - érvénytelen az üzenet hasító-értéke + érvénytelen az üzenet hasítóértéke integrity error chat item @@ -9071,7 +9265,7 @@ Kapcsolatkérés megismétlése? database version is newer than the app, but no down migration for: %@ - az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés a következőhöz: %@ + az adatbázis verziója újabb, mint az alkalmazásé, de a visszafelé történő átköltöztetés viszont nem lehetséges a következőhöz: %@ No comment provided by engineer. @@ -9397,6 +9591,7 @@ Kapcsolatkérés megismétlése? moderator + moderátor member role @@ -9404,11 +9599,6 @@ Kapcsolatkérés megismétlése? hónap time unit - - mute - némítás - No comment provided by engineer. - never soha @@ -9635,11 +9825,6 @@ utoljára fogadott üzenet: %2$@ ismeretlen állapot No comment provided by engineer. - - unmute - némítás megszüntetése - No comment provided by engineer. - unprotected nem védett @@ -9667,7 +9852,7 @@ utoljára fogadott üzenet: %2$@ via contact address link - a kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási címhivatkozáson keresztül chat list item description 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 fce4c9f6ac..2492edc626 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -14,6 +14,7 @@ + No comment provided by engineer. @@ -208,6 +209,7 @@ %d seconds(s) + %d secondo/i delete after time @@ -501,6 +503,7 @@ 1 year + 1 anno delete after time @@ -540,6 +543,14 @@ <p><a href="%@">Connettiti a me via SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Qualche altra cosa @@ -640,6 +651,10 @@ Errori di riconoscimento No comment provided by engineer. + + Active + token status text + Active connections Connessioni attive @@ -657,6 +672,7 @@ Add list + Aggiungi elenco No comment provided by engineer. @@ -686,6 +702,7 @@ Add to list + Aggiungi ad un elenco No comment provided by engineer. @@ -765,6 +782,7 @@ All + Tutte No comment provided by engineer. @@ -779,6 +797,7 @@ All chats will be removed from the list (text), and the list deleted. + Tutte le chat verranno rimosse dall'elenco (testo) e l'elenco eliminato. alert message @@ -978,6 +997,7 @@ Another reason + Altro motivo report reason @@ -1007,6 +1027,7 @@ App group: + Gruppo app: No comment provided by engineer. @@ -1056,6 +1077,7 @@ Archive + Archivia No comment provided by engineer. @@ -1070,10 +1092,12 @@ Archive report + Archivia la segnalazione No comment provided by engineer. Archive report? + Archiviare la segnalazione? No comment provided by engineer. @@ -1088,6 +1112,7 @@ Ask + Chiedi No comment provided by engineer. @@ -1312,6 +1337,7 @@ Businesses + Lavorative No comment provided by engineer. @@ -1407,6 +1433,7 @@ Change automatic message deletion? + Cambiare l'eliminazione automatica dei messaggi? alert title @@ -1632,6 +1659,7 @@ Clear or delete group? + Svuotare o eliminare il gruppo? No comment provided by engineer. @@ -1656,6 +1684,7 @@ Community guidelines violation + Violazione delle linee guida della comunità report reason @@ -1773,6 +1802,10 @@ Conferma caricamento No comment provided by engineer. + + Confirmed + token status text + Connect Connetti @@ -1894,6 +1927,7 @@ Questo è il tuo link una tantum! Connection blocked + Connessione bloccata No comment provided by engineer. @@ -1909,10 +1943,13 @@ Questo è il tuo link una tantum! Connection is blocked by server operator: %@ + La connessione è bloccata dall'operatore del server: +%@ No comment provided by engineer. Connection not ready. + Connessione non pronta. No comment provided by engineer. @@ -1927,6 +1964,7 @@ Questo è il tuo link una tantum! Connection requires encryption renegotiation. + La connessione richiede la rinegoziazione della crittografia. No comment provided by engineer. @@ -2011,6 +2049,7 @@ Questo è il tuo link una tantum! Content violates conditions of use + Il contenuto viola le condizioni di utilizzo blocking reason @@ -2090,6 +2129,7 @@ Questo è il tuo link una tantum! Create list + Crea elenco No comment provided by engineer. @@ -2353,6 +2393,7 @@ Questo è il tuo link una tantum! Delete chat messages from your device. + Elimina i messaggi di chat dal tuo dispositivo. No comment provided by engineer. @@ -2447,6 +2488,7 @@ Questo è il tuo link una tantum! Delete list? + Eliminare l'elenco? alert title @@ -2501,6 +2543,7 @@ Questo è il tuo link una tantum! Delete report + Elimina la segnalazione No comment provided by engineer. @@ -2665,10 +2708,12 @@ Questo è il tuo link una tantum! Disable automatic message deletion? + Disattivare l'eliminazione automatica dei messaggi? alert title Disable delete messages + Disattiva eliminazione messaggi alert button @@ -2763,6 +2808,7 @@ Questo è il tuo link una tantum! Documents: + Documenti: No comment provided by engineer. @@ -2782,6 +2828,7 @@ Questo è il tuo link una tantum! Done + Fatto No comment provided by engineer. @@ -3017,6 +3064,7 @@ Questo è il tuo link una tantum! Encryption renegotiation in progress. + Rinegoziazione della crittografia in corso. No comment provided by engineer. @@ -3129,6 +3177,10 @@ Questo è il tuo link una tantum! Errore nel passaggio a incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Errore di connessione al server di inoltro %@. Riprova più tardi. @@ -3151,6 +3203,7 @@ Questo è il tuo link una tantum! Error creating list + Errore nella creazione dell'elenco alert title @@ -3170,6 +3223,7 @@ Questo è il tuo link una tantum! Error creating report + Errore nella creazione del resoconto No comment provided by engineer. @@ -3282,6 +3336,10 @@ Questo è il tuo link una tantum! Errore di riconnessione ai server No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Errore nella rimozione del membro @@ -3289,6 +3347,7 @@ Questo è il tuo link una tantum! Error reordering lists + Errore riordinando gli elenchi alert title @@ -3303,6 +3362,7 @@ Questo è il tuo link una tantum! Error saving chat list + Errore nel salvataggio dell'elenco di chat alert title @@ -3385,6 +3445,10 @@ Questo è il tuo link una tantum! Errore nella sincronizzazione della connessione No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Errore nell'aggiornamento del link del gruppo @@ -3465,6 +3529,10 @@ Questo è il tuo link una tantum! Espandi chat item action + + Expired + token status text + Export database Esporta database @@ -3517,6 +3585,7 @@ Questo è il tuo link una tantum! Favorites + Preferite No comment provided by engineer. @@ -3534,6 +3603,8 @@ Questo è il tuo link una tantum! File is blocked by server operator: (info.reason.text). + Il file è bloccato dall'operatore del server: +(info.reason.text). file error text @@ -3907,6 +3978,7 @@ Errore: %2$@ Groups + Gruppi No comment provided by engineer. @@ -3976,6 +4048,7 @@ Errore: %2$@ How it works + Come funziona alert button @@ -4122,10 +4195,12 @@ Altri miglioramenti sono in arrivo! Inappropriate content + Contenuto inappropriato report reason Inappropriate profile + Profilo inappropriato report reason @@ -4220,6 +4295,26 @@ Altri miglioramenti sono in arrivo! Colori dell'interfaccia No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Codice QR non valido @@ -4495,14 +4590,17 @@ Questo è il tuo link per il gruppo %@! List + Elenco swipe action List name and emoji should be different for all lists. + Il nome dell'elenco e l'emoji dovrebbero essere diversi per tutte le liste. No comment provided by engineer. List name... + Nome elenco... No comment provided by engineer. @@ -4767,6 +4865,7 @@ Questo è il tuo link per il gruppo %@! Messages in this chat will never be deleted. + I messaggi in questa chat non verranno mai eliminati. alert message @@ -4871,6 +4970,7 @@ Questo è il tuo link per il gruppo %@! More + Altro swipe action @@ -4901,7 +5001,11 @@ Questo è il tuo link per il gruppo %@! Mute Silenzia - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4953,6 +5057,10 @@ Questo è il tuo link per il gruppo %@! Stato della rete No comment provided by engineer. + + New + token status text + New Passcode Nuovo codice di accesso @@ -5045,14 +5153,17 @@ Questo è il tuo link per il gruppo %@! No chats + Nessuna chat No comment provided by engineer. No chats found + Nessuna chat trovata No comment provided by engineer. No chats in list %@ + Nessuna chat nell'elenco %@ No comment provided by engineer. @@ -5160,8 +5271,13 @@ Questo è il tuo link per il gruppo %@! Nessun server per inviare file. servers error + + No token! + alert title + No unread chats + Nessuna chat non letta No comment provided by engineer. @@ -5176,6 +5292,7 @@ Questo è il tuo link per il gruppo %@! Notes + Note No comment provided by engineer. @@ -5198,11 +5315,19 @@ Questo è il tuo link per il gruppo %@! Le notifiche sono disattivate! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Privacy delle notifiche No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5288,10 +5413,12 @@ Richiede l'attivazione della VPN. Only sender and moderators see it + Solo il mittente e i moderatori lo vedono No comment provided by engineer. Only you and moderators see it + Solo tu e i moderatori lo vedete No comment provided by engineer. @@ -5381,10 +5508,12 @@ Richiede l'attivazione della VPN. Open link + Apri link No comment provided by engineer. Open links from chat list + Apri i link dall'elenco delle chat No comment provided by engineer. @@ -5394,6 +5523,7 @@ Richiede l'attivazione della VPN. Open web link? + Aprire il link? No comment provided by engineer. @@ -5632,6 +5762,18 @@ Errore: %@ Conserva la password in modo sicuro, NON potrai cambiarla se la perdi. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Interfaccia polacca @@ -6024,6 +6166,18 @@ Attivalo nelle impostazioni *Rete e server*. Consumo di batteria ridotto No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Rifiuta @@ -6127,34 +6281,42 @@ Attivalo nelle impostazioni *Rete e server*. Report + Segnala chat item action Report content: only group moderators will see it. + Segnala contenuto: solo i moderatori del gruppo lo vedranno. report reason Report member profile: only group moderators will see it. + Segnala profilo: solo i moderatori del gruppo lo vedranno. report reason Report other: only group moderators will see it. + Segnala altro: solo i moderatori del gruppo lo vedranno. report reason Report reason? + Motivo della segnalazione? No comment provided by engineer. Report spam: only group moderators will see it. + Segnala spam: solo i moderatori del gruppo lo vedranno. report reason Report violation: only group moderators will see it. + Segnala violazione: solo i moderatori del gruppo lo vedranno. report reason Reports + Segnalazioni No comment provided by engineer. @@ -6335,6 +6497,7 @@ Attivalo nelle impostazioni *Rete e server*. Save list + Salva elenco No comment provided by engineer. @@ -6794,6 +6957,7 @@ Attivalo nelle impostazioni *Rete e server*. Set chat name… + Imposta il nome della chat… No comment provided by engineer. @@ -7126,6 +7290,7 @@ Attivalo nelle impostazioni *Rete e server*. Spam + Spam blocking reason report reason @@ -7216,6 +7381,7 @@ Attivalo nelle impostazioni *Rete e server*. Storage + Archiviazione No comment provided by engineer. @@ -7278,6 +7444,10 @@ Attivalo nelle impostazioni *Rete e server*. Scadenza connessione TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7353,6 +7523,10 @@ Attivalo nelle impostazioni *Rete e server*. Test fallito al passo %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Prova server @@ -7482,6 +7656,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. The report will be archived for you. + La segnalazione verrà archiviata per te. No comment provided by engineer. @@ -7561,6 +7736,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Questa azione non è reversibile: i messaggi inviati e ricevuti in questa chat prima della selezione verranno eliminati. alert message @@ -7725,6 +7901,10 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Attiva/disattiva l'incognito quando ti colleghi. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Opacità barra degli strumenti @@ -7890,7 +8070,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Unmute Riattiva notifiche - swipe action + notification label action Unread @@ -7982,6 +8162,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usare i server di SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Usa la chat @@ -8057,6 +8241,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa l'app con una mano sola. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Selezione utente @@ -8339,6 +8527,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Yes + No comment provided by engineer. @@ -8610,6 +8799,10 @@ Ripetere la richiesta di connessione? Hai inviato un invito al gruppo No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Verrai connesso/a al gruppo quando il dispositivo dell'host del gruppo sarà in linea, attendi o controlla più tardi! @@ -8877,6 +9070,7 @@ Ripetere la richiesta di connessione? archived report + segnalazione archiviata No comment provided by engineer. @@ -9397,6 +9591,7 @@ Ripetere la richiesta di connessione? moderator + moderatore member role @@ -9404,11 +9599,6 @@ Ripetere la richiesta di connessione? mesi time unit - - mute - silenzia - No comment provided by engineer. - never mai @@ -9635,11 +9825,6 @@ ultimo msg ricevuto: %2$@ stato sconosciuto No comment provided by engineer. - - unmute - riattiva notifiche - No comment provided by engineer. - unprotected non protetto 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 8b754050a2..913e227f17 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -540,6 +540,14 @@ <p><a href="%@">SimpleX Chatでつながろう</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things その他 @@ -634,6 +642,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -1685,6 +1697,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect 接続 @@ -2957,6 +2973,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -3100,6 +3120,10 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member メンバー除名にエラー発生 @@ -3197,6 +3221,10 @@ This is your own one-time link! 接続の同期エラー No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link グループのリンクのアップデートにエラー発生 @@ -3271,6 +3299,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database データベースをエキスポート @@ -3970,6 +4002,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -4598,7 +4650,11 @@ This is your link for group %@! Mute ミュート - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4645,6 +4701,10 @@ This is your link for group %@! ネットワーク状況 No comment provided by engineer. + + New + token status text + New Passcode 新しいパスコード @@ -4834,6 +4894,10 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -4869,10 +4933,18 @@ This is your link for group %@! 通知が無効になっています! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5269,6 +5341,18 @@ Error: %@ パスフレーズを失くさないように保管してください。失くすと変更できなくなります。 No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface ポーランド語UI @@ -5628,6 +5712,18 @@ Enable in *Network & servers* settings. 電池使用量低減 No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject 拒否 @@ -6773,6 +6869,10 @@ Enable in *Network & servers* settings. TCP接続タイムアウト No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6842,6 +6942,10 @@ Enable in *Network & servers* settings. テストはステップ %@ で失敗しました。 server test failure + + Test notifications + No comment provided by engineer. + Test server テストサーバ @@ -7181,6 +7285,10 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -7333,7 +7441,7 @@ To connect, please ask your contact to create another connection link and check Unmute ミュート解除 - swipe action + notification label action Unread @@ -7416,6 +7524,10 @@ To connect, please ask your contact to create another connection link and check SimpleX チャット サーバーを使用しますか? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat チャット @@ -7482,6 +7594,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7981,6 +8097,10 @@ Repeat connection request? グループの招待を送りました No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! グループのホスト端末がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください! @@ -8744,10 +8864,6 @@ Repeat connection request? time unit - - mute - No comment provided by engineer. - never 一度も @@ -8954,10 +9070,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 81d7ba4d98..d8ced23aa4 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -14,6 +14,7 @@ + No comment provided by engineer. @@ -208,6 +209,7 @@ %d seconds(s) + %d seconden delete after time @@ -501,6 +503,7 @@ 1 year + 1 jaar delete after time @@ -540,6 +543,14 @@ <p><a href="%@">Maak verbinding met mij via SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Nog een paar dingen @@ -640,6 +651,10 @@ Bevestigingsfouten No comment provided by engineer. + + Active + token status text + Active connections Actieve verbindingen @@ -657,6 +672,7 @@ Add list + Lijst toevoegen No comment provided by engineer. @@ -686,6 +702,7 @@ Add to list + Toevoegen aan lijst No comment provided by engineer. @@ -765,6 +782,7 @@ All + alle No comment provided by engineer. @@ -779,6 +797,7 @@ All chats will be removed from the list (text), and the list deleted. + Alle chats worden uit de lijst (tekst) verwijderd en de lijst wordt verwijderd. alert message @@ -978,6 +997,7 @@ Another reason + Een andere reden report reason @@ -1007,6 +1027,7 @@ App group: + App-groep: No comment provided by engineer. @@ -1056,6 +1077,7 @@ Archive + Archief No comment provided by engineer. @@ -1070,10 +1092,12 @@ Archive report + Rapport archiveren No comment provided by engineer. Archive report? + Rapport archiveren? No comment provided by engineer. @@ -1088,6 +1112,7 @@ Ask + Vragen No comment provided by engineer. @@ -1312,6 +1337,7 @@ Businesses + bedrijven No comment provided by engineer. @@ -1407,6 +1433,7 @@ Change automatic message deletion? + Automatisch verwijderen van berichten wijzigen? alert title @@ -1632,6 +1659,7 @@ Clear or delete group? + Groep wissen of verwijderen? No comment provided by engineer. @@ -1656,6 +1684,7 @@ Community guidelines violation + Schending van de communityrichtlijnen report reason @@ -1773,6 +1802,10 @@ Bevestig het uploaden No comment provided by engineer. + + Confirmed + token status text + Connect Verbind @@ -1894,6 +1927,7 @@ Dit is uw eigen eenmalige link! Connection blocked + Verbinding geblokkeerd No comment provided by engineer. @@ -1909,10 +1943,13 @@ Dit is uw eigen eenmalige link! Connection is blocked by server operator: %@ + Verbinding is geblokkeerd door serveroperator: +%@ No comment provided by engineer. Connection not ready. + Verbinding nog niet klaar. No comment provided by engineer. @@ -1927,6 +1964,7 @@ Dit is uw eigen eenmalige link! Connection requires encryption renegotiation. + Verbinding vereist heronderhandeling over encryptie. No comment provided by engineer. @@ -2011,6 +2049,7 @@ Dit is uw eigen eenmalige link! Content violates conditions of use + Inhoud schendt de gebruiksvoorwaarden blocking reason @@ -2090,6 +2129,7 @@ Dit is uw eigen eenmalige link! Create list + Maak een lijst No comment provided by engineer. @@ -2353,6 +2393,7 @@ Dit is uw eigen eenmalige link! Delete chat messages from your device. + Verwijder chatberichten van uw apparaat. No comment provided by engineer. @@ -2447,6 +2488,7 @@ Dit is uw eigen eenmalige link! Delete list? + Lijst verwijderen? alert title @@ -2501,6 +2543,7 @@ Dit is uw eigen eenmalige link! Delete report + Rapport verwijderen No comment provided by engineer. @@ -2665,10 +2708,12 @@ Dit is uw eigen eenmalige link! Disable automatic message deletion? + Automatisch verwijderen van berichten uitschakelen? alert title Disable delete messages + Berichten verwijderen uitschakelen alert button @@ -2763,6 +2808,7 @@ Dit is uw eigen eenmalige link! Documents: + Documenten: No comment provided by engineer. @@ -2782,6 +2828,7 @@ Dit is uw eigen eenmalige link! Done + Klaar No comment provided by engineer. @@ -3017,6 +3064,7 @@ Dit is uw eigen eenmalige link! Encryption renegotiation in progress. + Er wordt opnieuw onderhandeld over de encryptie. No comment provided by engineer. @@ -3129,6 +3177,10 @@ Dit is uw eigen eenmalige link! Fout bij het overschakelen naar incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw. @@ -3151,6 +3203,7 @@ Dit is uw eigen eenmalige link! Error creating list + Fout bij het aanmaken van de lijst alert title @@ -3170,6 +3223,7 @@ Dit is uw eigen eenmalige link! Error creating report + Fout bij maken van rapport No comment provided by engineer. @@ -3282,6 +3336,10 @@ Dit is uw eigen eenmalige link! Fout bij opnieuw verbinden van servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Fout bij verwijderen van lid @@ -3289,6 +3347,7 @@ Dit is uw eigen eenmalige link! Error reordering lists + Fout bij het opnieuw ordenen van lijsten alert title @@ -3303,6 +3362,7 @@ Dit is uw eigen eenmalige link! Error saving chat list + Fout bij het opslaan van chatlijst alert title @@ -3385,6 +3445,10 @@ Dit is uw eigen eenmalige link! Fout bij het synchroniseren van de verbinding No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Fout bij bijwerken van groep link @@ -3465,6 +3529,10 @@ Dit is uw eigen eenmalige link! Uitklappen chat item action + + Expired + token status text + Export database Database exporteren @@ -3517,6 +3585,7 @@ Dit is uw eigen eenmalige link! Favorites + Favorieten No comment provided by engineer. @@ -3534,6 +3603,8 @@ Dit is uw eigen eenmalige link! File is blocked by server operator: (info.reason.text). + Bestand is geblokkeerd door serveroperator: +(info.reason.text). file error text @@ -3907,6 +3978,7 @@ Fout: %2$@ Groups + Groepen No comment provided by engineer. @@ -3976,6 +4048,7 @@ Fout: %2$@ How it works + Hoe het werkt alert button @@ -4122,10 +4195,12 @@ Binnenkort meer verbeteringen! Inappropriate content + Ongepaste inhoud report reason Inappropriate profile + Ongepast profiel report reason @@ -4220,6 +4295,26 @@ Binnenkort meer verbeteringen! Interface kleuren No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Ongeldige QR-code @@ -4495,14 +4590,17 @@ Dit is jouw link voor groep %@! List + Lijst swipe action List name and emoji should be different for all lists. + De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn. No comment provided by engineer. List name... + Naam van lijst... No comment provided by engineer. @@ -4767,6 +4865,7 @@ Dit is jouw link voor groep %@! Messages in this chat will never be deleted. + Berichten in deze chat zullen nooit worden verwijderd. alert message @@ -4871,6 +4970,7 @@ Dit is jouw link voor groep %@! More + Meer swipe action @@ -4901,7 +5001,11 @@ Dit is jouw link voor groep %@! Mute Dempen - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4953,6 +5057,10 @@ Dit is jouw link voor groep %@! Netwerk status No comment provided by engineer. + + New + token status text + New Passcode Nieuwe toegangscode @@ -5045,14 +5153,17 @@ Dit is jouw link voor groep %@! No chats + Geen chats No comment provided by engineer. No chats found + Geen chats gevonden No comment provided by engineer. No chats in list %@ + Geen chats in lijst %@ No comment provided by engineer. @@ -5160,8 +5271,13 @@ Dit is jouw link voor groep %@! Geen servers om bestanden te verzenden. servers error + + No token! + alert title + No unread chats + Geen ongelezen chats No comment provided by engineer. @@ -5176,6 +5292,7 @@ Dit is jouw link voor groep %@! Notes + Notities No comment provided by engineer. @@ -5198,11 +5315,19 @@ Dit is jouw link voor groep %@! Meldingen zijn uitgeschakeld! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Privacy van meldingen No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5288,10 +5413,12 @@ Vereist het inschakelen van VPN. Only sender and moderators see it + Alleen de verzender en moderators zien het No comment provided by engineer. Only you and moderators see it + Alleen jij en moderators zien het No comment provided by engineer. @@ -5381,10 +5508,12 @@ Vereist het inschakelen van VPN. Open link + Link openen No comment provided by engineer. Open links from chat list + Open links van chatlijst No comment provided by engineer. @@ -5394,6 +5523,7 @@ Vereist het inschakelen van VPN. Open web link? + Weblink openen? No comment provided by engineer. @@ -5632,6 +5762,18 @@ Fout: %@ Bewaar het wachtwoord veilig, u kunt deze NIET wijzigen als u het kwijtraakt. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Poolse interface @@ -6024,6 +6166,18 @@ Schakel dit in in *Netwerk en servers*-instellingen. Verminderd batterijgebruik No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Afwijzen @@ -6127,34 +6281,42 @@ Schakel dit in in *Netwerk en servers*-instellingen. Report + Rapport chat item action Report content: only group moderators will see it. + Inhoud melden: alleen groepsmoderators kunnen dit zien. report reason Report member profile: only group moderators will see it. + Rapporteer ledenprofiel: alleen groepsmoderators kunnen dit zien. report reason Report other: only group moderators will see it. + Anders melden: alleen groepsmoderators kunnen het zien. report reason Report reason? + Reden melding? No comment provided by engineer. Report spam: only group moderators will see it. + Spam melden: alleen groepsmoderators kunnen het zien. report reason Report violation: only group moderators will see it. + Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. report reason Reports + Rapporten No comment provided by engineer. @@ -6335,6 +6497,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Save list + Lijst opslaan No comment provided by engineer. @@ -6794,6 +6957,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Set chat name… + Stel chatnaam in… No comment provided by engineer. @@ -7126,6 +7290,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Spam + Spam blocking reason report reason @@ -7216,6 +7381,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Storage + Opslag No comment provided by engineer. @@ -7278,6 +7444,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Timeout van TCP-verbinding No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7353,6 +7523,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Test mislukt bij stap %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Server test @@ -7482,6 +7656,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. The report will be archived for you. + Het rapport wordt voor u gearchiveerd. No comment provided by engineer. @@ -7561,6 +7736,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Deze actie kan niet ongedaan worden gemaakt. De berichten die eerder in deze chat zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. alert message @@ -7725,6 +7901,10 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Schakel incognito in tijdens het verbinden. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity De transparantie van de werkbalk @@ -7890,7 +8070,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Unmute Dempen opheffen - swipe action + notification label action Unread @@ -7982,6 +8162,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak SimpleX Chat servers gebruiken? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Gebruik chat @@ -8057,6 +8241,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik de app met één hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Gebruikersselectie @@ -8339,6 +8527,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Yes + Ja No comment provided by engineer. @@ -8610,6 +8799,10 @@ Verbindingsverzoek herhalen? Je hebt een groep uitnodiging verzonden No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Je wordt verbonden met de groep wanneer het apparaat van de groep host online is, even geduld a.u.b. of controleer het later! @@ -8877,6 +9070,7 @@ Verbindingsverzoek herhalen? archived report + gearchiveerd rapport No comment provided by engineer. @@ -9397,6 +9591,7 @@ Verbindingsverzoek herhalen? moderator + moderator member role @@ -9404,11 +9599,6 @@ Verbindingsverzoek herhalen? maanden time unit - - mute - dempen - No comment provided by engineer. - never nooit @@ -9635,11 +9825,6 @@ laatst ontvangen bericht: %2$@ onbekende status No comment provided by engineer. - - unmute - dempen opheffen - No comment provided by engineer. - unprotected onbeschermd 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 c9adcfcd9e..c410ff46e3 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -208,6 +208,7 @@ %d seconds(s) + %d sekundach delete after time @@ -501,6 +502,7 @@ 1 year + 1 roku delete after time @@ -540,6 +542,14 @@ <p><a href="%@">Połącz się ze mną poprzez SimpleX Chat.</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Jeszcze kilka rzeczy @@ -640,6 +650,10 @@ Błędy potwierdzenia No comment provided by engineer. + + Active + token status text + Active connections Aktywne połączenia @@ -657,6 +671,7 @@ Add list + Dodaj listę No comment provided by engineer. @@ -686,6 +701,7 @@ Add to list + Dodaj do listy No comment provided by engineer. @@ -765,6 +781,7 @@ All + Wszystko No comment provided by engineer. @@ -779,6 +796,7 @@ All chats will be removed from the list (text), and the list deleted. + Wszystkie rozmowy zostaną usunięte z listy (text), a lista usunięta. alert message @@ -1311,6 +1329,7 @@ Businesses + Firmy No comment provided by engineer. @@ -1768,6 +1787,10 @@ Potwierdź wgranie No comment provided by engineer. + + Confirmed + token status text + Connect Połącz @@ -3111,6 +3134,10 @@ To jest twój jednorazowy link! Błąd zmiany na incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później. @@ -3263,6 +3290,10 @@ To jest twój jednorazowy link! Błąd ponownego łączenia serwerów No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Błąd usuwania członka @@ -3365,6 +3396,10 @@ To jest twój jednorazowy link! Błąd synchronizacji połączenia No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Błąd aktualizacji linku grupy @@ -3443,6 +3478,10 @@ To jest twój jednorazowy link! Rozszerz chat item action + + Expired + token status text + Export database Eksportuj bazę danych @@ -4189,6 +4228,26 @@ More improvements are coming soon! Kolory interfejsu No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Nieprawidłowy kod QR @@ -4864,7 +4923,11 @@ To jest twój link do grupy %@! Mute Wycisz - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4914,6 +4977,10 @@ To jest twój link do grupy %@! Status sieci No comment provided by engineer. + + New + token status text + New Passcode Nowy Pin @@ -5113,6 +5180,10 @@ To jest twój link do grupy %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -5151,10 +5222,18 @@ To jest twój link do grupy %@! Powiadomienia są wyłączone! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5577,6 +5656,18 @@ Błąd: %@ Przechowuj kod dostępu w bezpieczny sposób, w przypadku jego utraty NIE będzie można go zmienić. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Polski interfejs @@ -5967,6 +6058,18 @@ Włącz w ustawianiach *Sieć i serwery* . Zmniejszone zużycie baterii No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Odrzuć @@ -7204,6 +7307,10 @@ Włącz w ustawianiach *Sieć i serwery* . Limit czasu połączenia TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7278,6 +7385,10 @@ Włącz w ustawianiach *Sieć i serwery* . Test nie powiódł się na etapie %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Przetestuj serwer @@ -7639,6 +7750,10 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Przełącz incognito przy połączeniu. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Nieprzezroczystość paska narzędzi @@ -7803,7 +7918,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Unmute Wyłącz wyciszenie - swipe action + notification label action Unread @@ -7894,6 +8009,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Użyć serwerów SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Użyj czatu @@ -7966,6 +8085,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Korzystaj z aplikacji jedną ręką. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Wybór użytkownika @@ -8512,6 +8635,10 @@ Powtórzyć prośbę połączenia? Wysłałeś zaproszenie do grupy No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później! @@ -9302,11 +9429,6 @@ Powtórzyć prośbę połączenia? miesiące time unit - - mute - wycisz - No comment provided by engineer. - never nigdy @@ -9532,11 +9654,6 @@ ostatnia otrzymana wiadomość: %2$@ nieznany status No comment provided by engineer. - - unmute - wyłącz wyciszenie - No comment provided by engineer. - unprotected niezabezpieczony 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 e37d0e4534..d5dc066a9f 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -208,6 +208,7 @@ %d seconds(s) + %d секунд delete after time @@ -501,6 +502,7 @@ 1 year + 1 год delete after time @@ -540,6 +542,14 @@ <p><a href="%@">Соединитесь со мной в SimpleX Chat.</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Еще несколько изменений @@ -640,6 +650,10 @@ Ошибки подтверждения No comment provided by engineer. + + Active + token status text + Active connections Активные соединения @@ -657,6 +671,7 @@ Add list + Добавить список No comment provided by engineer. @@ -686,6 +701,7 @@ Add to list + Добавить в список No comment provided by engineer. @@ -765,6 +781,7 @@ All + Все No comment provided by engineer. @@ -978,6 +995,7 @@ Another reason + Другая причина report reason @@ -1056,6 +1074,7 @@ Archive + Архивировать No comment provided by engineer. @@ -1070,10 +1089,12 @@ Archive report + Архивировать отчет No comment provided by engineer. Archive report? + Архивировать отчет? No comment provided by engineer. @@ -1773,6 +1794,10 @@ Подтвердить загрузку No comment provided by engineer. + + Confirmed + token status text + Connect Соединиться @@ -3129,6 +3154,10 @@ This is your own one-time link! Ошибка при смене на Инкогнито! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Ошибка подключения к пересылающему серверу %@. Попробуйте позже. @@ -3282,6 +3311,10 @@ This is your own one-time link! Ошибка переподключения к серверам No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Ошибка при удалении члена группы @@ -3385,6 +3418,10 @@ This is your own one-time link! Ошибка синхронизации соединения No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Ошибка обновления ссылки группы @@ -3465,6 +3502,10 @@ This is your own one-time link! Раскрыть chat item action + + Expired + token status text + Export database Экспорт архива чата @@ -4219,6 +4260,26 @@ More improvements are coming soon! Цвета интерфейса No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Неверный QR код @@ -4900,7 +4961,11 @@ This is your link for group %@! Mute Без звука - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4952,6 +5017,10 @@ This is your link for group %@! Состояние сети No comment provided by engineer. + + New + token status text + New Passcode Новый Код @@ -5159,6 +5228,10 @@ This is your link for group %@! Нет серверов для отправки файлов. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -5197,11 +5270,19 @@ This is your link for group %@! Уведомления выключены No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Конфиденциальность уведомлений No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5631,6 +5712,18 @@ Error: %@ Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Польский интерфейс @@ -6023,6 +6116,18 @@ Enable in *Network & servers* settings. Уменьшенное потребление батареи No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Отклонить @@ -7277,6 +7382,10 @@ Enable in *Network & servers* settings. Таймаут TCP соединения No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7352,6 +7461,10 @@ Enable in *Network & servers* settings. Ошибка теста на шаге %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Тестировать сервер @@ -7724,6 +7837,10 @@ You will be prompted to complete authentication before this feature is enabled.< Установите режим Инкогнито при соединении. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Прозрачность тулбара @@ -7889,7 +8006,7 @@ To connect, please ask your contact to create another connection link and check Unmute Уведомлять - swipe action + notification label action Unread @@ -7981,6 +8098,10 @@ To connect, please ask your contact to create another connection link and check Использовать серверы предосталенные SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Использовать чат @@ -8056,6 +8177,10 @@ To connect, please ask your contact to create another connection link and check Используйте приложение одной рукой. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Выбор пользователя @@ -8609,6 +8734,10 @@ Repeat connection request? Вы отправили приглашение в группу No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже! @@ -9403,11 +9532,6 @@ Repeat connection request? месяцев time unit - - mute - без звука - No comment provided by engineer. - never никогда @@ -9634,11 +9758,6 @@ last received msg: %2$@ неизвестный статус No comment provided by engineer. - - unmute - уведомлять - No comment provided by engineer. - unprotected незащищённый 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 65e294465f..ea0ecaa4f0 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -500,6 +500,14 @@ <p><a href="%@">เชื่อมต่อกับฉันผ่าน SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things อีกสองสามอย่าง @@ -592,6 +600,10 @@ Acknowledgement errors No comment provided by engineer. + + Active + token status text + Active connections No comment provided by engineer. @@ -1621,6 +1633,10 @@ Confirm upload No comment provided by engineer. + + Confirmed + token status text + Connect เชื่อมต่อ @@ -2870,6 +2886,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. No comment provided by engineer. @@ -3012,6 +3032,10 @@ This is your own one-time link! Error reconnecting servers No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member เกิดข้อผิดพลาดในการลบสมาชิก @@ -3109,6 +3133,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการซิงโครไนซ์การเชื่อมต่อ No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link เกิดข้อผิดพลาดในการอัปเดตลิงก์กลุ่ม @@ -3183,6 +3211,10 @@ This is your own one-time link! Expand chat item action + + Expired + token status text + Export database ส่งออกฐานข้อมูล @@ -3881,6 +3913,26 @@ More improvements are coming soon! Interface colors No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code No comment provided by engineer. @@ -4505,7 +4557,11 @@ This is your link for group %@! Mute ปิดเสียง - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4552,6 +4608,10 @@ This is your link for group %@! สถานะเครือข่าย No comment provided by engineer. + + New + token status text + New Passcode รหัสผ่านใหม่ @@ -4739,6 +4799,10 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -4774,10 +4838,18 @@ This is your link for group %@! ปิดการแจ้งเตือน! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5171,6 +5243,18 @@ Error: %@ โปรดจัดเก็บรหัสผ่านอย่างปลอดภัย คุณจะไม่สามารถเปลี่ยนรหัสผ่านได้หากคุณทำรหัสผ่านหาย No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface อินเตอร์เฟซภาษาโปแลนด์ @@ -5529,6 +5613,18 @@ Enable in *Network & servers* settings. ลดการใช้แบตเตอรี่ No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject ปฏิเสธ @@ -6675,6 +6771,10 @@ Enable in *Network & servers* settings. หมดเวลาการเชื่อมต่อ TCP No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -6744,6 +6844,10 @@ Enable in *Network & servers* settings. การทดสอบล้มเหลวในขั้นตอน %@ server test failure + + Test notifications + No comment provided by engineer. + Test server เซิร์ฟเวอร์ทดสอบ @@ -7083,6 +7187,10 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity No comment provided by engineer. @@ -7235,7 +7343,7 @@ To connect, please ask your contact to create another connection link and check Unmute เปิดเสียง - swipe action + notification label action Unread @@ -7318,6 +7426,10 @@ To connect, please ask your contact to create another connection link and check ใช้เซิร์ฟเวอร์ SimpleX Chat ไหม? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat ใช้แชท @@ -7382,6 +7494,10 @@ To connect, please ask your contact to create another connection link and check Use the app with one hand. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection No comment provided by engineer. @@ -7879,6 +7995,10 @@ Repeat connection request? คุณส่งคำเชิญเข้าร่วมกลุ่มแล้ว No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! คุณจะเชื่อมต่อกับกลุ่มเมื่ออุปกรณ์โฮสต์ของกลุ่มออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! @@ -8639,10 +8759,6 @@ Repeat connection request? เดือน time unit - - mute - No comment provided by engineer. - never ไม่เคย @@ -8849,10 +8965,6 @@ last received msg: %2$@ unknown status No comment provided by engineer. - - unmute - No comment provided by engineer. - unprotected No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 0625825006..28befea033 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -540,6 +540,14 @@ <p><a href="%@">SimpleX Chat ile bana bağlanın</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Birkaç şey daha @@ -640,6 +648,10 @@ Onay hataları No comment provided by engineer. + + Active + token status text + Active connections Aktif bağlantılar @@ -1773,6 +1785,10 @@ Yüklemeyi onayla No comment provided by engineer. + + Confirmed + token status text + Connect Bağlan @@ -3128,6 +3144,10 @@ Bu senin kendi tek kullanımlık bağlantın! Gizli moduna geçerken hata oluştu! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Yönlendirme sunucusu %@'ya bağlanırken hata oluştu. Lütfen daha sonra deneyin. @@ -3281,6 +3301,10 @@ Bu senin kendi tek kullanımlık bağlantın! Hata sunuculara yeniden bağlanılıyor No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Kişiyi silerken sorun oluştu @@ -3384,6 +3408,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı senkronizasyonunda hata oluştu No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Grup bağlantısı güncellenirken hata oluştu @@ -3464,6 +3492,10 @@ Bu senin kendi tek kullanımlık bağlantın! Genişlet chat item action + + Expired + token status text + Export database Veritabanını dışarı aktar @@ -4219,6 +4251,26 @@ Daha fazla iyileştirme yakında geliyor! Arayüz renkleri No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Geçersiz QR kodu @@ -4898,7 +4950,11 @@ Bu senin grup için bağlantın %@! Mute Sustur - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4948,6 +5004,10 @@ Bu senin grup için bağlantın %@! Ağ durumu No comment provided by engineer. + + New + token status text + New Passcode Yeni şifre @@ -5147,6 +5207,10 @@ Bu senin grup için bağlantın %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -5185,10 +5249,18 @@ Bu senin grup için bağlantın %@! Bildirimler devre dışı! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5611,6 +5683,18 @@ Hata: %@ Lütfen parolayı güvenli bir şekilde saklayın, kaybederseniz parolayı DEĞİŞTİREMEZSİNİZ. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Lehçe arayüz @@ -6001,6 +6085,18 @@ Enable in *Network & servers* settings. Azaltılmış pil kullanımı No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Reddet @@ -7241,6 +7337,10 @@ Enable in *Network & servers* settings. TCP bağlantı zaman aşımı No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7315,6 +7415,10 @@ Enable in *Network & servers* settings. Test %@ adımında başarısız oldu. server test failure + + Test notifications + No comment provided by engineer. + Test server Sunucuyu test et @@ -7676,6 +7780,10 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Bağlanırken gizli moda geçiş yap. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Araç çubuğu opaklığı @@ -7840,7 +7948,7 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Unmute Susturmayı kaldır - swipe action + notification label action Unread @@ -7931,6 +8039,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste SimpleX Chat sunucuları kullanılsın mı? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Sohbeti kullan @@ -8003,6 +8115,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Uygulamayı tek elle kullan. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Kullanıcı seçimi @@ -8549,6 +8665,10 @@ Bağlantı isteği tekrarlansın mı? Grup daveti gönderdiniz No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Grup sahibinin cihazı çevrimiçi olduğunda gruba bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin! @@ -9339,11 +9459,6 @@ Bağlantı isteği tekrarlansın mı? aylar time unit - - mute - Sessiz - No comment provided by engineer. - never asla @@ -9569,11 +9684,6 @@ son alınan msj: %2$@ bilinmeyen durum No comment provided by engineer. - - unmute - susturmayı kaldır - No comment provided by engineer. - unprotected korumasız 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 500fa0c87b..a8c1ec27b7 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -540,6 +540,14 @@ <p><a href="%@"> Зв'яжіться зі мною через SimpleX Chat</a></p> email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things Ще кілька речей @@ -640,6 +648,10 @@ Помилки підтвердження No comment provided by engineer. + + Active + token status text + Active connections Активні з'єднання @@ -1773,6 +1785,10 @@ Підтвердити завантаження No comment provided by engineer. + + Confirmed + token status text + Connect Підключіться @@ -3129,6 +3145,10 @@ This is your own one-time link! Помилка переходу на інкогніто! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. Помилка підключення до сервера переадресації %@. Спробуйте пізніше. @@ -3282,6 +3302,10 @@ This is your own one-time link! Помилка перепідключення серверів No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member Помилка видалення учасника @@ -3385,6 +3409,10 @@ This is your own one-time link! Помилка синхронізації з'єднання No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link Помилка оновлення посилання на групу @@ -3465,6 +3493,10 @@ This is your own one-time link! Розгорнути chat item action + + Expired + token status text + Export database Експорт бази даних @@ -4220,6 +4252,26 @@ More improvements are coming soon! Кольори інтерфейсу No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code Неправильний QR-код @@ -4901,7 +4953,11 @@ This is your link for group %@! Mute Вимкнути звук - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4953,6 +5009,10 @@ This is your link for group %@! Стан мережі No comment provided by engineer. + + New + token status text + New Passcode Новий пароль @@ -5160,6 +5220,10 @@ This is your link for group %@! Немає серверів для надсилання файлів. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -5198,11 +5262,19 @@ This is your link for group %@! Сповіщення вимкнено! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy Сповіщення про приватність No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5632,6 +5704,18 @@ Error: %@ Будь ласка, зберігайте пароль надійно, ви НЕ зможете змінити його, якщо втратите. No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface Польський інтерфейс @@ -6024,6 +6108,18 @@ Enable in *Network & servers* settings. Зменшення використання акумулятора No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject Відхилити @@ -7278,6 +7374,10 @@ Enable in *Network & servers* settings. Тайм-аут TCP-з'єднання No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7353,6 +7453,10 @@ Enable in *Network & servers* settings. Тест завершився невдало на кроці %@. server test failure + + Test notifications + No comment provided by engineer. + Test server Тестовий сервер @@ -7725,6 +7829,10 @@ You will be prompted to complete authentication before this feature is enabled.< Увімкніть інкогніто при підключенні. No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity Непрозорість панелі інструментів @@ -7890,7 +7998,7 @@ To connect, please ask your contact to create another connection link and check Unmute Увімкнути звук - swipe action + notification label action Unread @@ -7982,6 +8090,10 @@ To connect, please ask your contact to create another connection link and check Використовувати сервери SimpleX Chat? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat Використовуйте чат @@ -8057,6 +8169,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте додаток однією рукою. No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection Вибір користувача @@ -8610,6 +8726,10 @@ Repeat connection request? Ви надіслали запрошення до групи No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, будь ласка, зачекайте або перевірте пізніше! @@ -9404,11 +9524,6 @@ Repeat connection request? місяців time unit - - mute - приглушити - No comment provided by engineer. - never ніколи @@ -9635,11 +9750,6 @@ last received msg: %2$@ невідомий статус No comment provided by engineer. - - unmute - увімкнути звук - No comment provided by engineer. - unprotected незахищені 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 8ebdfadeea..5be4378abb 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 @@ -529,6 +529,14 @@ <p><a href="%@">通过 SimpleX Chat </a></p>与我联系 email text + + @%@ + No comment provided by engineer. + + + @'%@' + No comment provided by engineer. + A few more things 一些杂项 @@ -626,6 +634,10 @@ 确认错误 No comment provided by engineer. + + Active + token status text + Active connections 活动连接 @@ -1724,6 +1736,10 @@ 确认上传 No comment provided by engineer. + + Confirmed + token status text + Connect 连接 @@ -3062,6 +3078,10 @@ This is your own one-time link! Error changing to incognito! No comment provided by engineer. + + Error checking token status + No comment provided by engineer. + Error connecting to forwarding server %@. Please try later. 连接到转发服务器 %@ 时出错。请稍后尝试。 @@ -3213,6 +3233,10 @@ This is your own one-time link! 重新连接服务器时出错 No comment provided by engineer. + + Error registering for notifications + alert title + Error removing member 删除成员错误 @@ -3314,6 +3338,10 @@ This is your own one-time link! 同步连接错误 No comment provided by engineer. + + Error testing server connection + No comment provided by engineer. + Error updating group link 更新群组链接错误 @@ -3392,6 +3420,10 @@ This is your own one-time link! 展开 chat item action + + Expired + token status text + Export database 导出数据库 @@ -4131,6 +4163,26 @@ More improvements are coming soon! 界面颜色 No comment provided by engineer. + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + Invalid QR code 无效的二维码 @@ -4804,7 +4856,11 @@ This is your link for group %@! Mute 静音 - swipe action + notification label action + + + Mute all + notification label action Muted when inactive! @@ -4854,6 +4910,10 @@ This is your link for group %@! 网络状态 No comment provided by engineer. + + New + token status text + New Passcode 新密码 @@ -5049,6 +5109,10 @@ This is your link for group %@! No servers to send files. servers error + + No token! + alert title + No unread chats No comment provided by engineer. @@ -5086,10 +5150,18 @@ This is your link for group %@! 通知被禁用! No comment provided by engineer. + + Notifications error + alert title + Notifications privacy No comment provided by engineer. + + Notifications status + alert title + Now admins can: - delete members' messages. @@ -5509,6 +5581,18 @@ Error: %@ 请安全地保存密码,如果您丢失了密码,您将无法更改它。 No comment provided by engineer. + + Please try to disable and re-enable notfications. + token info + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + Polish interface 波兰语界面 @@ -5897,6 +5981,18 @@ Enable in *Network & servers* settings. 减少电池使用量 No comment provided by engineer. + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + Reject 拒绝 @@ -7125,6 +7221,10 @@ Enable in *Network & servers* settings. TCP 连接超时 No comment provided by engineer. + + TCP port for messaging + No comment provided by engineer. + TCP_KEEPCNT TCP_KEEPCNT @@ -7198,6 +7298,10 @@ Enable in *Network & servers* settings. 在步骤 %@ 上测试失败。 server test failure + + Test notifications + No comment provided by engineer. + Test server 测试服务器 @@ -7556,6 +7660,10 @@ You will be prompted to complete authentication before this feature is enabled.< 在连接时切换隐身模式。 No comment provided by engineer. + + Token status: %@. + token status + Toolbar opacity 工具栏不透明度 @@ -7720,7 +7828,7 @@ To connect, please ask your contact to create another connection link and check Unmute 取消静音 - swipe action + notification label action Unread @@ -7810,6 +7918,10 @@ To connect, please ask your contact to create another connection link and check 使用 SimpleX Chat 服务器? No comment provided by engineer. + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + Use chat 使用聊天 @@ -7882,6 +7994,10 @@ To connect, please ask your contact to create another connection link and check 用一只手使用应用程序。 No comment provided by engineer. + + Use web port + No comment provided by engineer. + User selection 用户选择 @@ -8427,6 +8543,10 @@ Repeat connection request? 您发送了群组邀请 No comment provided by engineer. + + You should receive notifications. + token info + You will be connected to group when the group host's device is online, please wait or check later! 您将在组主设备上线时连接到该群组,请稍等或稍后再检查! @@ -9213,11 +9333,6 @@ Repeat connection request? time unit - - mute - 静音 - No comment provided by engineer. - never 从不 @@ -9443,11 +9558,6 @@ last received msg: %2$@ 未知状态 No comment provided by engineer. - - unmute - 取消静音 - No comment provided by engineer. - unprotected 未受保护 diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index bfc4b50f5d..b697b58222 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1135,16 +1135,19 @@ "Correct name to %@?" = "Поправи име на %@?"; /* No comment provided by engineer. */ -"Create" = "Създай"; +"Create" = "Създаване"; /* No comment provided by engineer. */ -"Create a group using a random profile." = "Създай група с автоматично генериран профилл."; +"Create 1-time link" = "Създаване на еднократна препратка"; + +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Създаване група с автоматично създаден профил."; /* server test step */ -"Create file" = "Създай файл"; +"Create file" = "Създаване на файл"; /* No comment provided by engineer. */ -"Create group" = "Създай група"; +"Create group" = "Създаване на група"; /* No comment provided by engineer. */ "Create group link" = "Създай групов линк"; @@ -1165,7 +1168,7 @@ "Create secret group" = "Създай тайна група"; /* No comment provided by engineer. */ -"Create SimpleX address" = "Създай SimpleX адрес"; +"Create SimpleX address" = "Създаване на адрес в SimpleX"; /* No comment provided by engineer. */ "Create your profile" = "Създай своя профил"; @@ -2637,7 +2640,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Множество профили за чат"; -/* swipe action */ +/* notification label action */ "Mute" = "Без звук"; /* No comment provided by engineer. */ @@ -3980,7 +3983,7 @@ /* authentication reason */ "Unlock app" = "Отключи приложението"; -/* swipe action */ +/* notification label action */ "Unmute" = "Уведомявай"; /* swipe action */ @@ -4479,7 +4482,7 @@ "Your settings" = "Вашите настройки"; /* No comment provided by engineer. */ -"Your SimpleX address" = "Вашият SimpleX адрес"; +"Your SimpleX address" = "Вашият адрес в SimpleX"; /* No comment provided by engineer. */ "Your SMP servers" = "Вашите SMP сървъри"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 478311cef1..eef7930c6f 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -2036,7 +2036,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Více chatovacích profilů"; -/* swipe action */ +/* notification label action */ "Mute" = "Ztlumit"; /* No comment provided by engineer. */ @@ -3124,7 +3124,7 @@ /* authentication reason */ "Unlock app" = "Odemknout aplikaci"; -/* swipe action */ +/* notification label action */ "Unmute" = "Zrušit ztlumení"; /* swipe action */ diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 13c07528a7..874d8c9c88 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* No comment provided by engineer. */ "\n" = "\n"; +/* No comment provided by engineer. */ +" " = " "; + /* No comment provided by engineer. */ " (" = " ("; @@ -199,6 +202,9 @@ /* time interval */ "%d sec" = "%d s"; +/* delete after time */ +"%d seconds(s)" = "%d Sekunde(n)"; + /* integrity error chat item */ "%d skipped message(s)" = "%d übersprungene Nachricht(en)"; @@ -288,7 +294,7 @@ /* delete after time time interval */ -"1 day" = "täglich"; +"1 day" = "Älter als ein Tag"; /* time interval */ "1 hour" = "1 Stunde"; @@ -298,11 +304,14 @@ /* delete after time time interval */ -"1 month" = "monatlich"; +"1 month" = "Älter als einen Monat"; /* delete after time time interval */ -"1 week" = "wöchentlich"; +"1 week" = "Älter als eine Woche"; + +/* delete after time */ +"1 year" = "Älter als ein Jahr"; /* No comment provided by engineer. */ "1-time link" = "Einmal-Link"; @@ -397,6 +406,9 @@ /* No comment provided by engineer. */ "Add friends" = "Freunde aufnehmen"; +/* No comment provided by engineer. */ +"Add list" = "Liste hinzufügen"; + /* No comment provided by engineer. */ "Add profile" = "Profil hinzufügen"; @@ -412,6 +424,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Einem anderen Gerät hinzufügen"; +/* No comment provided by engineer. */ +"Add to list" = "Zur Liste hinzufügen"; + /* No comment provided by engineer. */ "Add welcome message" = "Begrüßungsmeldung hinzufügen"; @@ -469,12 +484,18 @@ /* chat item text */ "agreeing encryption…" = "Verschlüsselung zustimmen…"; +/* No comment provided by engineer. */ +"All" = "Alle"; + /* No comment provided by engineer. */ "All app data is deleted." = "Werden die App-Daten komplett gelöscht."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden!"; +/* alert message */ +"All chats will be removed from the list (text), and the list deleted." = "Alle Chats werden von der Liste (text) entfernt und danach wird die Liste gelöscht."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Alle Daten werden gelöscht, sobald dieser eingegeben wird."; @@ -601,6 +622,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "und %lld weitere Ereignisse"; +/* report reason */ +"Another reason" = "Anderer Grund"; + /* No comment provided by engineer. */ "Answer call" = "Anruf annehmen"; @@ -616,6 +640,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt."; +/* No comment provided by engineer. */ +"App group:" = "App-Gruppe:"; + /* No comment provided by engineer. */ "App icon" = "App-Icon"; @@ -643,18 +670,33 @@ /* No comment provided by engineer. */ "Apply to" = "Anwenden auf"; +/* No comment provided by engineer. */ +"Archive" = "Archiv"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivieren und Hochladen"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Kontakte für spätere Chats archivieren."; +/* No comment provided by engineer. */ +"Archive report" = "Meldung archivieren"; + +/* No comment provided by engineer. */ +"Archive report?" = "Meldung archivieren?"; + /* No comment provided by engineer. */ "Archived contacts" = "Archivierte Kontakte"; +/* No comment provided by engineer. */ +"archived report" = "Archivierte Meldung"; + /* No comment provided by engineer. */ "Archiving database" = "Datenbank wird archiviert"; +/* No comment provided by engineer. */ +"Ask" = "Fragen"; + /* No comment provided by engineer. */ "Attach" = "Anhängen"; @@ -814,6 +856,9 @@ /* No comment provided by engineer. */ "Business chats" = "Geschäftliche Chats"; +/* No comment provided by engineer. */ +"Businesses" = "Unternehmen"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -884,6 +929,9 @@ /* No comment provided by engineer. */ "Change" = "Ändern"; +/* alert title */ +"Change automatic message deletion?" = "Automatisches Löschen von Nachrichten ändern?"; + /* authentication reason */ "Change chat profiles" = "Chat-Profile wechseln"; @@ -1032,6 +1080,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Chat-Inhalte entfernen?"; +/* No comment provided by engineer. */ +"Clear or delete group?" = "Gruppe entfernen oder löschen?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Private Notizen entfernen?"; @@ -1047,6 +1098,9 @@ /* No comment provided by engineer. */ "colored" = "farbig"; +/* report reason */ +"Community guidelines violation" = "Verstoß gegen die Gemeinschaftsrichtlinien"; + /* server test step */ "Compare file" = "Datei vergleichen"; @@ -1218,6 +1272,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Verbindungs- und Server-Status."; +/* No comment provided by engineer. */ +"Connection blocked" = "Verbindung blockiert"; + /* No comment provided by engineer. */ "Connection error" = "Verbindungsfehler"; @@ -1227,12 +1284,21 @@ /* chat list item title (it should not be shown */ "connection established" = "Verbindung hergestellt"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Server-Betreiber blockiert:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Verbindung noch nicht bereit."; + /* No comment provided by engineer. */ "Connection notifications" = "Verbindungsbenachrichtigungen"; /* No comment provided by engineer. */ "Connection request sent!" = "Verbindungsanfrage wurde gesendet!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Die Verbindung erfordert eine Neuverhandlung der Verschlüsselung."; + /* No comment provided by engineer. */ "Connection security" = "Verbindungs-Sicherheit"; @@ -1293,6 +1359,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen."; +/* blocking reason */ +"Content violates conditions of use" = "Inhalt verletzt Nutzungsbedingungen"; + /* No comment provided by engineer. */ "Continue" = "Weiter"; @@ -1335,6 +1404,9 @@ /* No comment provided by engineer. */ "Create link" = "Link erzeugen"; +/* No comment provided by engineer. */ +"Create list" = "Liste erstellen"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻"; @@ -1514,6 +1586,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Chat löschen"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Chat-Nachrichten von Ihrem Gerät löschen."; + /* No comment provided by engineer. */ "Delete chat profile" = "Chat-Profil löschen"; @@ -1568,6 +1643,9 @@ /* No comment provided by engineer. */ "Delete link?" = "Link löschen?"; +/* alert title */ +"Delete list?" = "Liste löschen?"; + /* No comment provided by engineer. */ "Delete member message?" = "Nachricht des Mitglieds löschen?"; @@ -1598,6 +1676,9 @@ /* server test step */ "Delete queue" = "Lösche Warteschlange"; +/* No comment provided by engineer. */ +"Delete report" = "Meldung löschen"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Löschen Sie bis zu 20 Nachrichten auf einmal."; @@ -1706,6 +1787,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Deaktivieren (vorgenommene Einstellungen bleiben erhalten)"; +/* alert title */ +"Disable automatic message deletion?" = "Automatisches Löschen von Nachrichten deaktivieren?"; + +/* alert button */ +"Disable delete messages" = "Löschen von Nachrichten deaktivieren"; + /* No comment provided by engineer. */ "Disable for all" = "Für Alle deaktivieren"; @@ -1766,6 +1853,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "SimpleX NICHT für Notrufe nutzen."; +/* No comment provided by engineer. */ +"Documents:" = "Dokumente:"; + /* No comment provided by engineer. */ "Don't create address" = "Keine Adresse erstellt"; @@ -1775,6 +1865,9 @@ /* No comment provided by engineer. */ "Don't show again" = "Nicht nochmals anzeigen"; +/* No comment provided by engineer. */ +"Done" = "Fertig"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Datenbank herabstufen und den Chat öffnen"; @@ -1956,6 +2049,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "Neuaushandlung der Verschlüsselung von %@ notwendig"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Die Neuverhandlung der Verschlüsselung läuft."; + /* No comment provided by engineer. */ "ended" = "beendet"; @@ -2043,6 +2139,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fehler beim Erzeugen des Gruppen-Links"; +/* alert title */ +"Error creating list" = "Fehler beim Erstellen der Liste"; + /* No comment provided by engineer. */ "Error creating member contact" = "Fehler beim Anlegen eines Mitglied-Kontaktes"; @@ -2052,6 +2151,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Fehler beim Erstellen des Profils!"; +/* No comment provided by engineer. */ +"Error creating report" = "Fehler beim Erstellen der Meldung"; + /* No comment provided by engineer. */ "Error decrypting file" = "Fehler beim Entschlüsseln der Datei"; @@ -2121,9 +2223,15 @@ /* No comment provided by engineer. */ "Error removing member" = "Fehler beim Entfernen des Mitglieds"; +/* alert title */ +"Error reordering lists" = "Fehler beim Umsortieren der Listen"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Fehler beim Zurücksetzen der Statistiken"; +/* alert title */ +"Error saving chat list" = "Fehler beim Speichern der Chat-Liste"; + /* No comment provided by engineer. */ "Error saving group profile" = "Fehler beim Speichern des Gruppenprofils"; @@ -2259,12 +2367,18 @@ /* swipe action */ "Favorite" = "Favorit"; +/* No comment provided by engineer. */ +"Favorites" = "Favoriten"; + /* file error alert title */ "File error" = "Datei-Fehler"; /* alert message */ "File errors:\n%@" = "Datei-Fehler:\n%@"; +/* file error text */ +"File is blocked by server operator:\n(info.reason.text)." = "Datei wurde vom Server-Betreiber blockiert:\n(info.reason.text)."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen."; @@ -2496,6 +2610,9 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Die Gruppe wird nur bei Ihnen gelöscht. Dies kann nicht rückgängig gemacht werden!"; +/* No comment provided by engineer. */ +"Groups" = "Gruppen"; + /* No comment provided by engineer. */ "Help" = "Hilfe"; @@ -2535,6 +2652,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Wie es die Privatsphäre schützt"; +/* alert button */ +"How it works" = "Wie es funktioniert"; + /* No comment provided by engineer. */ "How SimpleX works" = "Wie SimpleX funktioniert"; @@ -2622,6 +2742,12 @@ /* No comment provided by engineer. */ "inactive" = "Inaktiv"; +/* report reason */ +"Inappropriate content" = "Unangemessener Inhalt"; + +/* report reason */ +"Inappropriate profile" = "Unangemessenes Profil"; + /* No comment provided by engineer. */ "Incognito" = "Inkognito"; @@ -2889,6 +3015,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Verknüpfte Desktops"; +/* swipe action */ +"List" = "Liste"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Der Listenname und das Emoji sollen für alle Listen unterschiedlich sein."; + +/* No comment provided by engineer. */ +"List name..." = "Listenname..."; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -3066,6 +3201,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Die Nachrichten von %@ werden angezeigt!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Nachrichten in diesem Chat werden nie gelöscht."; + /* No comment provided by engineer. */ "Messages received" = "Empfangene Nachrichten"; @@ -3138,9 +3276,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "Von %@ moderiert"; +/* member role */ +"moderator" = "Moderator"; + /* time unit */ "months" = "Monate"; +/* swipe action */ +"More" = "Mehr"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Weitere Verbesserungen sind bald verfügbar!"; @@ -3156,10 +3300,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Mehrere Chat-Profile"; -/* No comment provided by engineer. */ -"mute" = "Stummschalten"; - -/* swipe action */ +/* notification label action */ "Mute" = "Stummschalten"; /* No comment provided by engineer. */ @@ -3255,6 +3396,15 @@ /* Authentication unavailable */ "No app password" = "Kein App-Passwort"; +/* No comment provided by engineer. */ +"No chats" = "Keine Chats"; + +/* No comment provided by engineer. */ +"No chats found" = "Keine Chats gefunden"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Keine Chats in der Liste %@"; + /* No comment provided by engineer. */ "No contacts selected" = "Keine Kontakte ausgewählt"; @@ -3324,12 +3474,18 @@ /* copied message info in history */ "no text" = "Kein Text"; +/* No comment provided by engineer. */ +"No unread chats" = "Keine ungelesenen Chats"; + /* No comment provided by engineer. */ "No user identifiers." = "Keine Benutzerkennungen."; /* No comment provided by engineer. */ "Not compatible!" = "Nicht kompatibel!"; +/* No comment provided by engineer. */ +"Notes" = "Anmerkungen"; + /* No comment provided by engineer. */ "Nothing selected" = "Nichts ausgewählt"; @@ -3407,6 +3563,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Nur Absender und Moderatoren sehen es"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Nur Sie und Moderatoren sehen es"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Nur Sie können Reaktionen auf Nachrichten geben."; @@ -3455,12 +3617,21 @@ /* No comment provided by engineer. */ "Open group" = "Gruppe öffnen"; +/* No comment provided by engineer. */ +"Open link" = "Web-Link öffnen"; + +/* No comment provided by engineer. */ +"Open links from chat list" = "Web-Links aus der Chat-Liste öffnen"; + /* authentication reason */ "Open migration to another device" = "Migration auf ein anderes Gerät öffnen"; /* No comment provided by engineer. */ "Open Settings" = "Geräte-Einstellungen öffnen"; +/* No comment provided by engineer. */ +"Open web link?" = "Web-Link öffnen?"; + /* No comment provided by engineer. */ "Opening app…" = "App wird geöffnet…"; @@ -3936,6 +4107,30 @@ /* chat item action */ "Reply" = "Antwort"; +/* chat item action */ +"Report" = "Melden"; + +/* report reason */ +"Report content: only group moderators will see it." = "Inhalt melden: Nur Gruppenmoderatoren werden es sehen."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen."; + +/* report reason */ +"Report other: only group moderators will see it." = "Anderes melden: Nur Gruppenmoderatoren werden es sehen."; + +/* No comment provided by engineer. */ +"Report reason?" = "Grund der Meldung?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Spam melden: Nur Gruppenmoderatoren werden es sehen."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Verstoß melden: Nur Gruppenmoderatoren werden es sehen."; + +/* No comment provided by engineer. */ +"Reports" = "Meldungen"; + /* chat list item title */ "requested to connect" = "Zur Verbindung aufgefordert"; @@ -4039,6 +4234,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Gruppenprofil speichern"; +/* No comment provided by engineer. */ +"Save list" = "Liste speichern"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Passwort speichern und Chat öffnen"; @@ -4339,6 +4537,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Einen Tag festlegen"; +/* No comment provided by engineer. */ +"Set chat name…" = "Chat-Name festlegen…"; + /* No comment provided by engineer. */ "Set contact name…" = "Kontaktname festlegen…"; @@ -4547,6 +4748,10 @@ /* notification title */ "Somebody" = "Jemand"; +/* blocking reason + report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Quadratisch, kreisförmig oder irgendetwas dazwischen."; @@ -4604,6 +4809,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Chat wird beendet"; +/* No comment provided by engineer. */ +"Storage" = "Ablage"; + /* No comment provided by engineer. */ "strike" = "durchstreichen"; @@ -4763,6 +4971,9 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Das Profil wird nur mit Ihren Kontakten geteilt."; +/* No comment provided by engineer. */ +"The report will be archived for you." = "Die Meldung wird für Sie archiviert."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**."; @@ -4808,6 +5019,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden!"; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden!"; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden!"; @@ -5015,10 +5229,7 @@ /* authentication reason */ "Unlock app" = "App entsperren"; -/* No comment provided by engineer. */ -"unmute" = "Stummschaltung aufheben"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Stummschaltung aufheben"; /* No comment provided by engineer. */ @@ -5339,6 +5550,9 @@ /* pref value */ "yes" = "Ja"; +/* No comment provided by engineer. */ +"Yes" = "Ja"; + /* No comment provided by engineer. */ "you" = "Profil"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 39496a182f..2004e192c1 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* No comment provided by engineer. */ "\n" = "\n"; +/* No comment provided by engineer. */ +" " = " "; + /* No comment provided by engineer. */ " (" = " ("; @@ -199,6 +202,9 @@ /* time interval */ "%d sec" = "%d segundo(s)"; +/* delete after time */ +"%d seconds(s)" = "%d segundos"; + /* integrity error chat item */ "%d skipped message(s)" = "%d mensaje(s) omitido(s)"; @@ -304,6 +310,9 @@ time interval */ "1 week" = "una semana"; +/* delete after time */ +"1 year" = "Un año"; + /* No comment provided by engineer. */ "1-time link" = "Enlace de un uso"; @@ -397,6 +406,9 @@ /* No comment provided by engineer. */ "Add friends" = "Añadir amigos"; +/* No comment provided by engineer. */ +"Add list" = "Añadir lista"; + /* No comment provided by engineer. */ "Add profile" = "Añadir perfil"; @@ -412,6 +424,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Añadir a otro dispositivo"; +/* No comment provided by engineer. */ +"Add to list" = "Añadir a la lista"; + /* No comment provided by engineer. */ "Add welcome message" = "Añadir mensaje de bienvenida"; @@ -469,12 +484,18 @@ /* chat item text */ "agreeing encryption…" = "acordando cifrado…"; +/* No comment provided by engineer. */ +"All" = "Todo"; + /* No comment provided by engineer. */ "All app data is deleted." = "Todos los datos de la aplicación se eliminarán."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse!"; +/* alert message */ +"All chats will be removed from the list (text), and the list deleted." = "Todos los chats serán quitados de la lista (text) y esta será eliminada."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Al introducirlo todos los datos son eliminados."; @@ -601,6 +622,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "y %lld evento(s) más"; +/* report reason */ +"Another reason" = "Otro motivo"; + /* No comment provided by engineer. */ "Answer call" = "Responder llamada"; @@ -616,6 +640,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Cifrado de los nuevos archivos locales (excepto vídeos)."; +/* No comment provided by engineer. */ +"App group:" = "Grupo app:"; + /* No comment provided by engineer. */ "App icon" = "Icono aplicación"; @@ -643,18 +670,33 @@ /* No comment provided by engineer. */ "Apply to" = "Aplicar a"; +/* No comment provided by engineer. */ +"Archive" = "Archivar"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivar y subir"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiva contactos para charlar más tarde."; +/* No comment provided by engineer. */ +"Archive report" = "Archivar informe"; + +/* No comment provided by engineer. */ +"Archive report?" = "¿Archivar informe?"; + /* No comment provided by engineer. */ "Archived contacts" = "Contactos archivados"; +/* No comment provided by engineer. */ +"archived report" = "informes archivados"; + /* No comment provided by engineer. */ "Archiving database" = "Archivando base de datos"; +/* No comment provided by engineer. */ +"Ask" = "Preguntar"; + /* No comment provided by engineer. */ "Attach" = "Adjuntar"; @@ -775,7 +817,7 @@ /* rcv group event chat item */ "blocked %@" = "ha bloqueado a %@"; -/* marked deleted chat item preview text */ +/* blocked chat item */ "blocked by admin" = "bloqueado por administrador"; /* No comment provided by engineer. */ @@ -814,6 +856,9 @@ /* No comment provided by engineer. */ "Business chats" = "Chats empresariales"; +/* No comment provided by engineer. */ +"Businesses" = "Empresas"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -884,6 +929,9 @@ /* No comment provided by engineer. */ "Change" = "Cambiar"; +/* alert title */ +"Change automatic message deletion?" = "¿Modificar la eliminación automática de mensajes?"; + /* authentication reason */ "Change chat profiles" = "Cambiar perfil de usuario"; @@ -1032,6 +1080,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "¿Vaciar conversación?"; +/* No comment provided by engineer. */ +"Clear or delete group?" = "¿Vaciar o eliminar grupo?"; + /* No comment provided by engineer. */ "Clear private notes?" = "¿Eliminar notas privadas?"; @@ -1047,6 +1098,9 @@ /* No comment provided by engineer. */ "colored" = "coloreado"; +/* report reason */ +"Community guidelines violation" = "Violación de las normas de la comunidad"; + /* server test step */ "Compare file" = "Comparar archivo"; @@ -1218,6 +1272,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Estado de tu conexión y servidores."; +/* No comment provided by engineer. */ +"Connection blocked" = "Conexión bloqueada"; + /* No comment provided by engineer. */ "Connection error" = "Error conexión"; @@ -1227,12 +1284,21 @@ /* chat list item title (it should not be shown */ "connection established" = "conexión establecida"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Conexión bloqueada por el operador del servidor:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Conexión no establecida."; + /* No comment provided by engineer. */ "Connection notifications" = "Notificaciones de conexión"; /* No comment provided by engineer. */ "Connection request sent!" = "¡Solicitud de conexión enviada!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "La conexión requiere renegociar el cifrado."; + /* No comment provided by engineer. */ "Connection security" = "Seguridad de conexión"; @@ -1293,6 +1359,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos."; +/* blocking reason */ +"Content violates conditions of use" = "El contenido viola las condiciones de uso"; + /* No comment provided by engineer. */ "Continue" = "Continuar"; @@ -1335,6 +1404,9 @@ /* No comment provided by engineer. */ "Create link" = "Crear enlace"; +/* No comment provided by engineer. */ +"Create list" = "Crear lista"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻"; @@ -1514,6 +1586,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Eliminar chat"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Elimina los mensajes del dispositivo."; + /* No comment provided by engineer. */ "Delete chat profile" = "Eliminar perfil"; @@ -1568,6 +1643,9 @@ /* No comment provided by engineer. */ "Delete link?" = "¿Eliminar enlace?"; +/* alert title */ +"Delete list?" = "¿Eliminar lista?"; + /* No comment provided by engineer. */ "Delete member message?" = "¿Eliminar el mensaje de miembro?"; @@ -1575,7 +1653,7 @@ "Delete message?" = "¿Eliminar mensaje?"; /* alert button */ -"Delete messages" = "Eliminar mensaje"; +"Delete messages" = "Eliminar mensajes"; /* No comment provided by engineer. */ "Delete messages after" = "Eliminar en"; @@ -1598,6 +1676,9 @@ /* server test step */ "Delete queue" = "Eliminar cola"; +/* No comment provided by engineer. */ +"Delete report" = "Borrar informe"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Elimina hasta 20 mensajes a la vez."; @@ -1706,6 +1787,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Desactivar (conservando anulaciones)"; +/* alert title */ +"Disable automatic message deletion?" = "¿Desactivar la eliminación automática de mensajes?"; + +/* alert button */ +"Disable delete messages" = "Desactivar"; + /* No comment provided by engineer. */ "Disable for all" = "Desactivar para todos"; @@ -1766,6 +1853,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NO uses SimpleX para llamadas de emergencia."; +/* No comment provided by engineer. */ +"Documents:" = "Documentos:"; + /* No comment provided by engineer. */ "Don't create address" = "No crear dirección SimpleX"; @@ -1775,6 +1865,9 @@ /* No comment provided by engineer. */ "Don't show again" = "No volver a mostrar"; +/* No comment provided by engineer. */ +"Done" = "Hecho"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Degradar y abrir Chat"; @@ -1956,6 +2049,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "se requiere renegociar el cifrado para %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Renegociación de cifrado en curso."; + /* No comment provided by engineer. */ "ended" = "finalizado"; @@ -2043,6 +2139,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Error al crear enlace de grupo"; +/* alert title */ +"Error creating list" = "Error al crear lista"; + /* No comment provided by engineer. */ "Error creating member contact" = "Error al establecer contacto con el miembro"; @@ -2052,6 +2151,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "¡Error al crear perfil!"; +/* No comment provided by engineer. */ +"Error creating report" = "Error al crear informe"; + /* No comment provided by engineer. */ "Error decrypting file" = "Error al descifrar el archivo"; @@ -2121,9 +2223,15 @@ /* No comment provided by engineer. */ "Error removing member" = "Error al eliminar miembro"; +/* alert title */ +"Error reordering lists" = "Error al reorganizar listas"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Error al restablecer las estadísticas"; +/* alert title */ +"Error saving chat list" = "Error al guardar listas"; + /* No comment provided by engineer. */ "Error saving group profile" = "Error al guardar perfil de grupo"; @@ -2199,7 +2307,8 @@ /* No comment provided by engineer. */ "Error: " = "Error: "; -/* alert message */ +/* file error text + snd error text */ "Error: %@" = "Error: %@"; /* No comment provided by engineer. */ @@ -2259,12 +2368,18 @@ /* swipe action */ "Favorite" = "Favoritos"; +/* No comment provided by engineer. */ +"Favorites" = "Favoritos"; + /* file error alert title */ "File error" = "Error de archivo"; /* alert message */ "File errors:\n%@" = "Error(es) de archivo\n%@"; +/* file error text */ +"File is blocked by server operator:\n(info.reason.text)." = "Archivo bloqueado por el operador del servidor\n(info.reason.text)."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Archivo no encontrado, probablemente haya sido borrado o cancelado."; @@ -2496,6 +2611,9 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "El grupo será eliminado para tí. ¡No podrá deshacerse!"; +/* No comment provided by engineer. */ +"Groups" = "Grupos"; + /* No comment provided by engineer. */ "Help" = "Ayuda"; @@ -2535,6 +2653,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Cómo ayuda a la privacidad"; +/* alert button */ +"How it works" = "Cómo funciona"; + /* No comment provided by engineer. */ "How SimpleX works" = "Cómo funciona SimpleX"; @@ -2622,6 +2743,12 @@ /* No comment provided by engineer. */ "inactive" = "inactivo"; +/* report reason */ +"Inappropriate content" = "Contenido inapropiado"; + +/* report reason */ +"Inappropriate profile" = "Perfil inapropiado"; + /* No comment provided by engineer. */ "Incognito" = "Incógnito"; @@ -2889,6 +3016,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Ordenadores enlazados"; +/* swipe action */ +"List" = "Lista"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "El nombre y el emoji deben ser diferentes en todas las listas."; + +/* No comment provided by engineer. */ +"List name..." = "Nombre de la lista..."; + /* No comment provided by engineer. */ "LIVE" = "EN VIVO"; @@ -3066,6 +3202,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "¡Los mensajes de %@ serán mostrados!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Los mensajes de esta conversación nunca se borran."; + /* No comment provided by engineer. */ "Messages received" = "Mensajes recibidos"; @@ -3138,9 +3277,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "moderado por %@"; +/* member role */ +"moderator" = "moderador"; + /* time unit */ "months" = "meses"; +/* swipe action */ +"More" = "Más"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "¡Pronto habrá más mejoras!"; @@ -3156,10 +3301,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Múltiples perfiles"; -/* No comment provided by engineer. */ -"mute" = "silenciar"; - -/* swipe action */ +/* notification label action */ "Mute" = "Silenciar"; /* No comment provided by engineer. */ @@ -3255,6 +3397,15 @@ /* Authentication unavailable */ "No app password" = "Sin contraseña de la aplicación"; +/* No comment provided by engineer. */ +"No chats" = "Sin chats"; + +/* No comment provided by engineer. */ +"No chats found" = "Ningún chat encontrado"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Sin chats en la lista %@"; + /* No comment provided by engineer. */ "No contacts selected" = "Ningún contacto seleccionado"; @@ -3324,12 +3475,18 @@ /* copied message info in history */ "no text" = "sin texto"; +/* No comment provided by engineer. */ +"No unread chats" = "Ningún chat sin leer"; + /* No comment provided by engineer. */ "No user identifiers." = "Sin identificadores de usuario."; /* No comment provided by engineer. */ "Not compatible!" = "¡No compatible!"; +/* No comment provided by engineer. */ +"Notes" = "Notas"; + /* No comment provided by engineer. */ "Nothing selected" = "Nada seleccionado"; @@ -3407,6 +3564,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Sólo los propietarios del grupo pueden activar los mensajes de voz."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Solo el remitente y el moderador pueden verlo"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Solo tú y los moderadores podéis verlo"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Sólo tú puedes añadir reacciones a los mensajes."; @@ -3455,12 +3618,21 @@ /* No comment provided by engineer. */ "Open group" = "Grupo abierto"; +/* No comment provided by engineer. */ +"Open link" = "Abrir enlace"; + +/* No comment provided by engineer. */ +"Open links from chat list" = "Abrir enlaces desde listado de chats"; + /* authentication reason */ "Open migration to another device" = "Abrir menú migración a otro dispositivo"; /* No comment provided by engineer. */ "Open Settings" = "Abrir Configuración"; +/* No comment provided by engineer. */ +"Open web link?" = "¿Abrir enlace web?"; + /* No comment provided by engineer. */ "Opening app…" = "Iniciando aplicación…"; @@ -3711,7 +3883,7 @@ "Prohibit sending voice messages." = "No se permiten mensajes de voz."; /* No comment provided by engineer. */ -"Protect app screen" = "Proteger la pantalla de la aplicación"; +"Protect app screen" = "Proteger la pantalla"; /* No comment provided by engineer. */ "Protect IP address" = "Proteger dirección IP"; @@ -3936,6 +4108,30 @@ /* chat item action */ "Reply" = "Responder"; +/* chat item action */ +"Report" = "Informe"; + +/* report reason */ +"Report content: only group moderators will see it." = "Informar de contenido: sólo los moderadores del grupo lo verán."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Informar del perfil de un miembro: sólo los moderadores del grupo lo verán."; + +/* report reason */ +"Report other: only group moderators will see it." = "Informar de otros: sólo los moderadores del grupo lo verán."; + +/* No comment provided by engineer. */ +"Report reason?" = "¿Motivo del informe?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Informar de spam: sólo los moderadores del grupo lo verán."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Informar de violación: sólo los moderadores del grupo lo verán."; + +/* No comment provided by engineer. */ +"Reports" = "Informes"; + /* chat list item title */ "requested to connect" = "solicitado para conectar"; @@ -4039,6 +4235,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Guardar perfil de grupo"; +/* No comment provided by engineer. */ +"Save list" = "Guardar lista"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Guardar contraseña y abrir el chat"; @@ -4339,6 +4538,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Establecer 1 día"; +/* No comment provided by engineer. */ +"Set chat name…" = "Nombre para el chat…"; + /* No comment provided by engineer. */ "Set contact name…" = "Escribe el nombre del contacto…"; @@ -4518,7 +4720,7 @@ "Skipped messages" = "Mensajes omitidos"; /* No comment provided by engineer. */ -"Small groups (max 20)" = "Grupos pequeños (máx. 20)"; +"Small groups (max 20)" = "Grupos pequeños (max. 20)"; /* No comment provided by engineer. */ "SMP server" = "Servidor SMP"; @@ -4547,6 +4749,10 @@ /* notification title */ "Somebody" = "Alguien"; +/* blocking reason + report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Cuadrada, circular o cualquier forma intermedia."; @@ -4604,6 +4810,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Parando chat"; +/* No comment provided by engineer. */ +"Storage" = "Almacenamiento"; + /* No comment provided by engineer. */ "strike" = "tachado"; @@ -4763,6 +4972,9 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; +/* No comment provided by engineer. */ +"The report will be archived for you." = "El informe será archivado para ti."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Las mismas condiciones se aplicarán al operador **%@**."; @@ -4782,7 +4994,7 @@ "The servers for new connections of your current chat profile **%@**." = "Servidores para conexiones nuevas en tu perfil **%@**."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Servidores para archivos nuevos en tu perfil **%@**."; +"The servers for new files of your current chat profile **%@**." = "Servidores para enviar archivos en tu perfil **%@**."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "El texto pegado no es un enlace SimpleX."; @@ -4808,6 +5020,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Esta acción es irreversible. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Podría tardar varios minutos."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Todos los mensajes previos a la selección serán eliminados del chat. ¡No podrá deshacerse!"; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente."; @@ -5015,10 +5230,7 @@ /* authentication reason */ "Unlock app" = "Desbloquear aplicación"; -/* No comment provided by engineer. */ -"unmute" = "activar sonido"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Activar audio"; /* No comment provided by engineer. */ @@ -5049,7 +5261,7 @@ "updated profile" = "perfil actualizado"; /* No comment provided by engineer. */ -"Updating settings will re-connect the client to all servers." = "Al actualizar la configuración el cliente se reconectará a todos los servidores."; +"Updating settings will re-connect the client to all servers." = "Para actualizar la configuración el cliente se reconectará a todos los servidores."; /* No comment provided by engineer. */ "Upgrade and open chat" = "Actualizar y abrir Chat"; @@ -5339,6 +5551,9 @@ /* pref value */ "yes" = "sí"; +/* No comment provided by engineer. */ +"Yes" = "Si"; + /* No comment provided by engineer. */ "you" = "tu"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 05198685cd..e860e56426 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -1988,7 +1988,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Useita keskusteluprofiileja"; -/* swipe action */ +/* notification label action */ "Mute" = "Mykistä"; /* No comment provided by engineer. */ @@ -3058,7 +3058,7 @@ /* authentication reason */ "Unlock app" = "Avaa sovellus"; -/* swipe action */ +/* notification label action */ "Unmute" = "Poista mykistys"; /* swipe action */ diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index c378667a9d..05dcfa9cd3 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -3156,10 +3156,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Différents profils de chat"; -/* No comment provided by engineer. */ -"mute" = "muet"; - -/* swipe action */ +/* notification label action */ "Mute" = "Muet"; /* No comment provided by engineer. */ @@ -5015,10 +5012,7 @@ /* authentication reason */ "Unlock app" = "Déverrouiller l'app"; -/* No comment provided by engineer. */ -"unmute" = "démuter"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Démute"; /* No comment provided by engineer. */ diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 1c330a0c1d..f12a39b3b3 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* No comment provided by engineer. */ "\n" = "\n"; +/* No comment provided by engineer. */ +" " = " "; + /* No comment provided by engineer. */ " (" = " ("; @@ -20,7 +23,7 @@ "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- értesíti az ismerősöket a törlésről (nem kötelező)\n- profil nevek szóközökkel\n- és még sok más!"; /* No comment provided by engineer. */ -"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 perc hosszúságú hangüzenetek.\n- egyedi üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; +"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyedi üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; /* No comment provided by engineer. */ ", " = ", "; @@ -199,6 +202,9 @@ /* time interval */ "%d sec" = "%d mp"; +/* delete after time */ +"%d seconds(s)" = "%d másodperc"; + /* integrity error chat item */ "%d skipped message(s)" = "%d üzenet kihagyva"; @@ -304,6 +310,9 @@ time interval */ "1 week" = "1 hét"; +/* delete after time */ +"1 year" = "1 év"; + /* No comment provided by engineer. */ "1-time link" = "Egyszer használható meghívó-hivatkozás"; @@ -397,6 +406,9 @@ /* No comment provided by engineer. */ "Add friends" = "Barátok hozzáadása"; +/* No comment provided by engineer. */ +"Add list" = "Lista hozzáadása"; + /* No comment provided by engineer. */ "Add profile" = "Profil hozzáadása"; @@ -412,6 +424,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Hozzáadás egy másik eszközhöz"; +/* No comment provided by engineer. */ +"Add to list" = "Hozzáadás listához"; + /* No comment provided by engineer. */ "Add welcome message" = "Üdvözlőüzenet hozzáadása"; @@ -469,12 +484,18 @@ /* chat item text */ "agreeing encryption…" = "titkosítás elfogadása…"; +/* No comment provided by engineer. */ +"All" = "Összes"; + /* No comment provided by engineer. */ "All app data is deleted." = "Az összes alkalmazásadat törölve."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza!"; +/* alert message */ +"All chats will be removed from the list (text), and the list deleted." = "Az összes csevegés el lesz távolítva a(z) (text) nevű listáról, és a lista is törölve lesz."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "A jelkód megadása után az összes adat törölve lesz."; @@ -601,6 +622,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "és további %lld esemény"; +/* report reason */ +"Another reason" = "Egyéb indoklás"; + /* No comment provided by engineer. */ "Answer call" = "Hívás fogadása"; @@ -616,6 +640,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével)."; +/* No comment provided by engineer. */ +"App group:" = "Alkalmazáscsoport:"; + /* No comment provided by engineer. */ "App icon" = "Alkalmazásikon"; @@ -629,10 +656,10 @@ "App session" = "Alkalmazás munkamenete"; /* No comment provided by engineer. */ -"App version" = "Alkalmazás verzió"; +"App version" = "Az alkalmazás verziója"; /* No comment provided by engineer. */ -"App version: v%@" = "Alkalmazás verzió: v%@"; +"App version: v%@" = "Az alkalmazás verziója: v%@"; /* No comment provided by engineer. */ "Appearance" = "Megjelenés"; @@ -643,18 +670,33 @@ /* No comment provided by engineer. */ "Apply to" = "Alkalmazás erre"; +/* No comment provided by engineer. */ +"Archive" = "Archívum"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiválás és feltöltés"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Az ismerősök archiválása a későbbi csevegéshez."; +/* No comment provided by engineer. */ +"Archive report" = "Jelentés archiválása"; + +/* No comment provided by engineer. */ +"Archive report?" = "Jelentés archiválása?"; + /* No comment provided by engineer. */ "Archived contacts" = "Archivált ismerősök"; +/* No comment provided by engineer. */ +"archived report" = "archivált jelentés"; + /* No comment provided by engineer. */ "Archiving database" = "Adatbázis archiválása"; +/* No comment provided by engineer. */ +"Ask" = "Mindig kérdezzen rá"; + /* No comment provided by engineer. */ "Attach" = "Csatolás"; @@ -713,10 +755,10 @@ "Bad desktop address" = "Érvénytelen számítógépcím"; /* integrity error chat item */ -"bad message hash" = "érvénytelen az üzenet hasító-értéke"; +"bad message hash" = "érvénytelen az üzenet hasítóértéke"; /* No comment provided by engineer. */ -"Bad message hash" = "Érvénytelen az üzenet hasító-értéke"; +"Bad message hash" = "Érvénytelen az üzenet hasítóértéke"; /* integrity error chat item */ "bad message ID" = "téves üzenet ID"; @@ -814,6 +856,9 @@ /* No comment provided by engineer. */ "Business chats" = "Üzleti csevegések"; +/* No comment provided by engineer. */ +"Businesses" = "Üzleti"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA)."; @@ -884,6 +929,9 @@ /* No comment provided by engineer. */ "Change" = "Megváltoztatás"; +/* alert title */ +"Change automatic message deletion?" = "Automatikus üzenettörlés módosítása?"; + /* authentication reason */ "Change chat profiles" = "Csevegési profilok megváltoztatása"; @@ -1032,6 +1080,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Üzenetek kiürítése?"; +/* No comment provided by engineer. */ +"Clear or delete group?" = "Csoport kiürítése vagy törlése?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Privát jegyzetek kiürítése?"; @@ -1047,6 +1098,9 @@ /* No comment provided by engineer. */ "colored" = "színes"; +/* report reason */ +"Community guidelines violation" = "Közösségi irányelvek megsértése"; + /* server test step */ "Compare file" = "Fájl összehasonlítás"; @@ -1218,6 +1272,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Kapcsolatok- és kiszolgálók állapotának megjelenítése."; +/* No comment provided by engineer. */ +"Connection blocked" = "A kapcsolat le van tiltva"; + /* No comment provided by engineer. */ "Connection error" = "Kapcsolódási hiba"; @@ -1227,12 +1284,21 @@ /* chat list item title (it should not be shown */ "connection established" = "kapcsolat létrehozva"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "A kiszolgáló üzemeltetője letiltotta a kapcsolatot:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "A kapcsolat nem áll készen."; + /* No comment provided by engineer. */ "Connection notifications" = "Kapcsolódási értesítések"; /* No comment provided by engineer. */ "Connection request sent!" = "Kapcsolatkérés elküldve!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "A kapcsolat titkosítása újraegyeztetést igényel."; + /* No comment provided by engineer. */ "Connection security" = "Kapcsolatbiztonság"; @@ -1282,7 +1348,7 @@ "Contact name" = "Csak név"; /* No comment provided by engineer. */ -"Contact preferences" = "Ismerős beállításai"; +"Contact preferences" = "Ismerős-beállítások"; /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "Az ismerős törölve lesz – ez a művelet nem vonható vissza!"; @@ -1293,6 +1359,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat."; +/* blocking reason */ +"Content violates conditions of use" = "A tartalom sérti a használati feltételeket"; + /* No comment provided by engineer. */ "Continue" = "Folytatás"; @@ -1335,6 +1404,9 @@ /* No comment provided by engineer. */ "Create link" = "Hivatkozás létrehozása"; +/* No comment provided by engineer. */ +"Create list" = "Lista létrehozása"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻"; @@ -1450,7 +1522,7 @@ "Database upgrade" = "Adatbázis fejlesztése"; /* No comment provided by engineer. */ -"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés a következőhöz: %@"; +"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de a visszafelé történő átköltöztetés viszont nem lehetséges a következőhöz: %@"; /* No comment provided by engineer. */ "Database will be encrypted and the passphrase stored in the keychain.\n" = "Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban lesz tárolva.\n"; @@ -1514,6 +1586,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Csevegés törlése"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Csevegési üzenetek törlése az Ön eszközéről."; + /* No comment provided by engineer. */ "Delete chat profile" = "Csevegési profil törlése"; @@ -1568,6 +1643,9 @@ /* No comment provided by engineer. */ "Delete link?" = "Hivatkozás törlése?"; +/* alert title */ +"Delete list?" = "Lista törlése?"; + /* No comment provided by engineer. */ "Delete member message?" = "Csoporttag üzenetének törlése?"; @@ -1598,6 +1676,9 @@ /* server test step */ "Delete queue" = "Sorbaállítás törlése"; +/* No comment provided by engineer. */ +"Delete report" = "Jelentés törlése"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Legfeljebb 20 üzenet egyszerre való törlése."; @@ -1706,6 +1787,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Letiltás (felülírások megtartásával)"; +/* alert title */ +"Disable automatic message deletion?" = "Automatikus üzenettörlés letiltása?"; + +/* alert button */ +"Disable delete messages" = "Üzenetek törlésének letiltása"; + /* No comment provided by engineer. */ "Disable for all" = "Letiltás az összes tag számára"; @@ -1766,6 +1853,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NE használja a SimpleX-et segélyhívásokhoz."; +/* No comment provided by engineer. */ +"Documents:" = "Dokumentumok:"; + /* No comment provided by engineer. */ "Don't create address" = "Ne hozzon létre címet"; @@ -1775,6 +1865,9 @@ /* No comment provided by engineer. */ "Don't show again" = "Ne mutasd újra"; +/* No comment provided by engineer. */ +"Done" = "Kész"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Visszafejlesztés és a csevegés megnyitása"; @@ -1956,6 +2049,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "a titkosítás újraegyeztetése szükséges %@ számára"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "A titkosítás újraegyeztetése folyamatban van."; + /* No comment provided by engineer. */ "ended" = "befejeződött"; @@ -2043,6 +2139,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Hiba a csoporthivatkozás létrehozásakor"; +/* alert title */ +"Error creating list" = "Hiba a lista létrehozásakor"; + /* No comment provided by engineer. */ "Error creating member contact" = "Hiba az ismerőssel történő kapcsolat létrehozásában"; @@ -2052,6 +2151,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Hiba a profil létrehozásakor!"; +/* No comment provided by engineer. */ +"Error creating report" = "Hiba a jelentés létrehozásakor"; + /* No comment provided by engineer. */ "Error decrypting file" = "Hiba a fájl visszafejtésekor"; @@ -2121,9 +2223,15 @@ /* No comment provided by engineer. */ "Error removing member" = "Hiba a tag eltávolításakor"; +/* alert title */ +"Error reordering lists" = "Hiba a listák újrarendezésekor"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Hiba a statisztikák visszaállításakor"; +/* alert title */ +"Error saving chat list" = "Hiba a csevegési lista mentésekor"; + /* No comment provided by engineer. */ "Error saving group profile" = "Hiba a csoportprofil mentésekor"; @@ -2259,12 +2367,18 @@ /* swipe action */ "Favorite" = "Kedvenc"; +/* No comment provided by engineer. */ +"Favorites" = "Kedvencek"; + /* file error alert title */ "File error" = "Fájlhiba"; /* alert message */ "File errors:\n%@" = "Fájlhiba:\n%@"; +/* file error text */ +"File is blocked by server operator:\n(info.reason.text)." = "A kiszolgáló üzemeltetője letiltotta a fájlt:\n(info.reason.text)."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták."; @@ -2476,7 +2590,7 @@ "Group moderation" = "Csoport moderáció"; /* No comment provided by engineer. */ -"Group preferences" = "Csoportbeállítások"; +"Group preferences" = "Csoport-beállítások"; /* No comment provided by engineer. */ "Group profile" = "Csoportprofil"; @@ -2496,6 +2610,9 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; +/* No comment provided by engineer. */ +"Groups" = "Csoportok"; + /* No comment provided by engineer. */ "Help" = "Súgó"; @@ -2535,6 +2652,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Hogyan segíti az adatvédelmet"; +/* alert button */ +"How it works" = "Hogyan működik"; + /* No comment provided by engineer. */ "How SimpleX works" = "Hogyan működik a SimpleX"; @@ -2622,6 +2742,12 @@ /* No comment provided by engineer. */ "inactive" = "inaktív"; +/* report reason */ +"Inappropriate content" = "Kifogásolt tartalom"; + +/* report reason */ +"Inappropriate profile" = "Kifogásolt profil"; + /* No comment provided by engineer. */ "Incognito" = "Inkognitó"; @@ -2889,6 +3015,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Társított számítógépek"; +/* swipe action */ +"List" = "Lista"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Az összes lista nevének és emodzsiának különbözőnek kell lennie."; + +/* No comment provided by engineer. */ +"List name..." = "Listanév…"; + /* No comment provided by engineer. */ "LIVE" = "ÉLŐ"; @@ -3066,6 +3201,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "A(z) %@ által írt üzenetek megjelennek!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve."; + /* No comment provided by engineer. */ "Messages received" = "Fogadott üzenetek"; @@ -3138,9 +3276,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "moderálva lett %@ által"; +/* member role */ +"moderator" = "moderátor"; + /* time unit */ "months" = "hónap"; +/* swipe action */ +"More" = "Továbbiak"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Hamarosan további fejlesztések érkeznek!"; @@ -3156,10 +3300,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Több csevegőprofil"; -/* No comment provided by engineer. */ -"mute" = "némítás"; - -/* swipe action */ +/* notification label action */ "Mute" = "Némítás"; /* No comment provided by engineer. */ @@ -3256,7 +3397,16 @@ "No app password" = "Nincs alkalmazás jelszó"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs kijelölve ismerős"; +"No chats" = "Nincsenek csevegések"; + +/* No comment provided by engineer. */ +"No chats found" = "Nem találhatók csevegések"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Nincsenek csevegések a(z) %@ nevű listában"; + +/* No comment provided by engineer. */ +"No contacts selected" = "Nincs ismerős kijelölve"; /* No comment provided by engineer. */ "No contacts to add" = "Nincs hozzáadandó ismerős"; @@ -3324,6 +3474,9 @@ /* copied message info in history */ "no text" = "nincs szöveg"; +/* No comment provided by engineer. */ +"No unread chats" = "Nincsenek olvasatlan csevegések"; + /* No comment provided by engineer. */ "No user identifiers." = "Nincsenek felhasználó-azonosítók."; @@ -3331,7 +3484,10 @@ "Not compatible!" = "Nem kompatibilis!"; /* No comment provided by engineer. */ -"Nothing selected" = "Nincs kijelölve semmi"; +"Notes" = "Jegyzetek"; + +/* No comment provided by engineer. */ +"Nothing selected" = "Nincs semmi kijelölve"; /* alert title */ "Nothing to forward!" = "Nincs mit továbbítani!"; @@ -3399,13 +3555,19 @@ "Only delete conversation" = "Csak a beszélgetés törlése"; /* No comment provided by engineer. */ -"Only group owners can change group preferences." = "Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat."; +"Only group owners can change group preferences." = "Csak a csoport-tulajdonosok változtathatják meg a csoport-beállításokat."; /* No comment provided by engineer. */ -"Only group owners can enable files and media." = "Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését."; +"Only group owners can enable files and media." = "Csak a csoport-tulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését."; /* No comment provided by engineer. */ -"Only group owners can enable voice messages." = "Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését."; +"Only group owners can enable voice messages." = "Csak a csoport-tulajdonosok engedélyezhetik a hangüzenetek küldését."; + +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Csak a küldő és a moderátorok látják"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Csak Ön és a moderátorok látják"; /* No comment provided by engineer. */ "Only you can add message reactions." = "Csak Ön adhat hozzá üzenetreakciókat."; @@ -3447,7 +3609,7 @@ "Open chat" = "Csevegés megnyitása"; /* authentication reason */ -"Open chat console" = "Csevegés konzol megnyitása"; +"Open chat console" = "Csevegési konzol megnyitása"; /* No comment provided by engineer. */ "Open conditions" = "Feltételek megnyitása"; @@ -3455,12 +3617,21 @@ /* No comment provided by engineer. */ "Open group" = "Csoport megnyitása"; +/* No comment provided by engineer. */ +"Open link" = "Hivatkozás megnyitása"; + +/* No comment provided by engineer. */ +"Open links from chat list" = "Hivatkozás megnyitása a csevegési listából"; + /* authentication reason */ "Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; /* No comment provided by engineer. */ "Open Settings" = "Beállítások megnyitása"; +/* No comment provided by engineer. */ +"Open web link?" = "Webhivatkozás megnyitása?"; + /* No comment provided by engineer. */ "Opening app…" = "Az alkalmazás megnyitása…"; @@ -3720,7 +3891,7 @@ "Protect your chat profiles with a password!" = "Védje meg a csevegési profiljait egy jelszóval!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; /* No comment provided by engineer. */ "Protocol timeout" = "Protokoll időtúllépése"; @@ -3936,6 +4107,30 @@ /* chat item action */ "Reply" = "Válasz"; +/* chat item action */ +"Report" = "Jelentés"; + +/* report reason */ +"Report content: only group moderators will see it." = "Tartalom jelentése: csak a csoport moderátorai látják."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Tag profiljának jelentése: csak a csoport moderátorai látják."; + +/* report reason */ +"Report other: only group moderators will see it." = "Egyéb jelentés: csak a csoport moderátorai látják."; + +/* No comment provided by engineer. */ +"Report reason?" = "Jelentés indoklása?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Kéretlen tartalom jelentése: csak a csoport moderátorai látják."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Szabálysértés jelentése: csak a csoport moderátorai látják."; + +/* No comment provided by engineer. */ +"Reports" = "Jelentések"; + /* chat list item title */ "requested to connect" = "kérelmezve a kapcsolódáshoz"; @@ -4039,6 +4234,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Csoportprofil mentése"; +/* No comment provided by engineer. */ +"Save list" = "Lista mentése"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Jelmondat mentése és a csevegés megnyitása"; @@ -4217,7 +4415,7 @@ "Send them from gallery or custom keyboards." = "Küldje el őket galériából vagy egyedi billentyűzetekről."; /* No comment provided by engineer. */ -"Send up to 100 last messages to new members." = "Az utolsó 100 üzenet elküldése az új tagoknak."; +"Send up to 100 last messages to new members." = "Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára."; /* alert message */ "Sender cancelled file transfer." = "A fájl küldője visszavonta az átvitelt."; @@ -4277,7 +4475,7 @@ "Sent total" = "Összes elküldött üzenet"; /* No comment provided by engineer. */ -"Sent via proxy" = "Proxyn keresztül küldve"; +"Sent via proxy" = "Proxyn keresztül küldött"; /* No comment provided by engineer. */ "Server" = "Kiszolgáló"; @@ -4339,6 +4537,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Beállítva 1 nap"; +/* No comment provided by engineer. */ +"Set chat name…" = "Csevegés nevének beállítása…"; + /* No comment provided by engineer. */ "Set contact name…" = "Ismerős nevének beállítása…"; @@ -4346,7 +4547,7 @@ "Set default theme" = "Alapértelmezett téma beállítása"; /* No comment provided by engineer. */ -"Set group preferences" = "Csoportbeállítások megadása"; +"Set group preferences" = "Csoport-beállítások megadása"; /* No comment provided by engineer. */ "Set it instead of system authentication." = "Rendszerhitelesítés helyetti beállítás."; @@ -4547,6 +4748,10 @@ /* notification title */ "Somebody" = "Valaki"; +/* blocking reason + report reason */ +"Spam" = "Kéretlen tartalom"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Négyzet, kör vagy bármi a kettő között."; @@ -4604,6 +4809,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Csevegés megállítása folyamatban"; +/* No comment provided by engineer. */ +"Storage" = "Tárhely"; + /* No comment provided by engineer. */ "strike" = "áthúzott"; @@ -4737,10 +4945,10 @@ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet!"; /* No comment provided by engineer. */ -"The future of messaging" = "A privát üzenetküldés következő generációja"; +"The future of messaging" = "Az üzenetváltás jövője"; /* No comment provided by engineer. */ -"The hash of the previous message is different." = "Az előző üzenet hasító értéke különbözik."; +"The hash of the previous message is different." = "Az előző üzenet hasítóértéke különbözik."; /* 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." = "A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő."; @@ -4763,6 +4971,9 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "A profilja csak az ismerőseivel van megosztva."; +/* No comment provided by engineer. */ +"The report will be archived for you." = "A jelentés archiválva lesz az Ön számára."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**."; @@ -4800,7 +5011,7 @@ "These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi **%@** nevű csevegő-profiljára vonatkoznak."; /* No comment provided by engineer. */ -"They can be overridden in contact and group settings." = "Ezek felülbírálhatók az ismerős- és csoportbeállításokban."; +"They can be overridden in contact and group settings." = "Ezek felülbírálhatók az ismerős- és csoport-beállításokban."; /* No comment provided by engineer. */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; @@ -4808,6 +5019,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek."; @@ -5015,10 +5229,7 @@ /* authentication reason */ "Unlock app" = "Alkalmazás feloldása"; -/* No comment provided by engineer. */ -"unmute" = "némítás megszüntetése"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Némítás megszüntetése"; /* No comment provided by engineer. */ @@ -5079,7 +5290,7 @@ "Use %@" = "%@ használata"; /* No comment provided by engineer. */ -"Use chat" = "Csevegés használata"; +"Use chat" = "Simplex Chat használata"; /* No comment provided by engineer. */ "Use current profile" = "Jelenlegi profil használata"; @@ -5169,7 +5380,7 @@ "Via browser" = "Böngészőn keresztül"; /* chat list item description */ -"via contact address link" = "a kapcsolattartási cím-hivatkozáson keresztül"; +"via contact address link" = "a kapcsolattartási címhivatkozáson keresztül"; /* chat list item description */ "via group link" = "a csoporthivatkozáson keresztül"; @@ -5199,7 +5410,7 @@ "Video will be received when your contact is online, please wait or check later!" = "A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"Videos and files up to 1gb" = "Videók és fájlok 1Gb méretig"; +"Videos and files up to 1gb" = "Videók és fájlok legfeljebb 1GB méretig"; /* No comment provided by engineer. */ "View conditions" = "Feltételek megtekintése"; @@ -5339,6 +5550,9 @@ /* pref value */ "yes" = "igen"; +/* No comment provided by engineer. */ +"Yes" = "Igen"; + /* No comment provided by engineer. */ "you" = "Ön"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index de0b761e4e..b016babfc1 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* No comment provided by engineer. */ "\n" = "\n"; +/* No comment provided by engineer. */ +" " = " "; + /* No comment provided by engineer. */ " (" = " ("; @@ -199,6 +202,9 @@ /* time interval */ "%d sec" = "%d sec"; +/* delete after time */ +"%d seconds(s)" = "%d secondo/i"; + /* integrity error chat item */ "%d skipped message(s)" = "%d messaggio/i saltato/i"; @@ -304,6 +310,9 @@ time interval */ "1 week" = "1 settimana"; +/* delete after time */ +"1 year" = "1 anno"; + /* No comment provided by engineer. */ "1-time link" = "Link una tantum"; @@ -397,6 +406,9 @@ /* No comment provided by engineer. */ "Add friends" = "Aggiungi amici"; +/* No comment provided by engineer. */ +"Add list" = "Aggiungi elenco"; + /* No comment provided by engineer. */ "Add profile" = "Aggiungi profilo"; @@ -412,6 +424,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Aggiungi ad un altro dispositivo"; +/* No comment provided by engineer. */ +"Add to list" = "Aggiungi ad un elenco"; + /* No comment provided by engineer. */ "Add welcome message" = "Aggiungi messaggio di benvenuto"; @@ -469,12 +484,18 @@ /* chat item text */ "agreeing encryption…" = "concordando la crittografia…"; +/* No comment provided by engineer. */ +"All" = "Tutte"; + /* No comment provided by engineer. */ "All app data is deleted." = "Tutti i dati dell'app vengono eliminati."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Tutte le chat e i messaggi verranno eliminati. Non è reversibile!"; +/* alert message */ +"All chats will be removed from the list (text), and the list deleted." = "Tutte le chat verranno rimosse dall'elenco (testo) e l'elenco eliminato."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Tutti i dati vengono cancellati quando inserito."; @@ -601,6 +622,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "e altri %lld eventi"; +/* report reason */ +"Another reason" = "Altro motivo"; + /* No comment provided by engineer. */ "Answer call" = "Rispondi alla chiamata"; @@ -616,6 +640,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "L'app cripta i nuovi file locali (eccetto i video)."; +/* No comment provided by engineer. */ +"App group:" = "Gruppo app:"; + /* No comment provided by engineer. */ "App icon" = "Icona app"; @@ -643,18 +670,33 @@ /* No comment provided by engineer. */ "Apply to" = "Applica a"; +/* No comment provided by engineer. */ +"Archive" = "Archivia"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivia e carica"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archivia contatti per chattare più tardi."; +/* No comment provided by engineer. */ +"Archive report" = "Archivia la segnalazione"; + +/* No comment provided by engineer. */ +"Archive report?" = "Archiviare la segnalazione?"; + /* No comment provided by engineer. */ "Archived contacts" = "Contatti archiviati"; +/* No comment provided by engineer. */ +"archived report" = "segnalazione archiviata"; + /* No comment provided by engineer. */ "Archiving database" = "Archiviazione del database"; +/* No comment provided by engineer. */ +"Ask" = "Chiedi"; + /* No comment provided by engineer. */ "Attach" = "Allega"; @@ -814,6 +856,9 @@ /* No comment provided by engineer. */ "Business chats" = "Chat di lavoro"; +/* No comment provided by engineer. */ +"Businesses" = "Lavorative"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -884,6 +929,9 @@ /* No comment provided by engineer. */ "Change" = "Cambia"; +/* alert title */ +"Change automatic message deletion?" = "Cambiare l'eliminazione automatica dei messaggi?"; + /* authentication reason */ "Change chat profiles" = "Modifica profili utente"; @@ -1032,6 +1080,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Svuotare la conversazione?"; +/* No comment provided by engineer. */ +"Clear or delete group?" = "Svuotare o eliminare il gruppo?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Svuotare le note private?"; @@ -1047,6 +1098,9 @@ /* No comment provided by engineer. */ "colored" = "colorato"; +/* report reason */ +"Community guidelines violation" = "Violazione delle linee guida della comunità"; + /* server test step */ "Compare file" = "Confronta file"; @@ -1218,6 +1272,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Stato della connessione e dei server."; +/* No comment provided by engineer. */ +"Connection blocked" = "Connessione bloccata"; + /* No comment provided by engineer. */ "Connection error" = "Errore di connessione"; @@ -1227,12 +1284,21 @@ /* chat list item title (it should not be shown */ "connection established" = "connessione stabilita"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "La connessione è bloccata dall'operatore del server:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Connessione non pronta."; + /* No comment provided by engineer. */ "Connection notifications" = "Notifiche di connessione"; /* No comment provided by engineer. */ "Connection request sent!" = "Richiesta di connessione inviata!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "La connessione richiede la rinegoziazione della crittografia."; + /* No comment provided by engineer. */ "Connection security" = "Sicurezza della connessione"; @@ -1293,6 +1359,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "I contatti possono contrassegnare i messaggi per l'eliminazione; potrai vederli."; +/* blocking reason */ +"Content violates conditions of use" = "Il contenuto viola le condizioni di utilizzo"; + /* No comment provided by engineer. */ "Continue" = "Continua"; @@ -1335,6 +1404,9 @@ /* No comment provided by engineer. */ "Create link" = "Crea link"; +/* No comment provided by engineer. */ +"Create list" = "Crea elenco"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Crea un nuovo profilo nell'[app desktop](https://simplex.chat/downloads/). 💻"; @@ -1514,6 +1586,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Elimina chat"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Elimina i messaggi di chat dal tuo dispositivo."; + /* No comment provided by engineer. */ "Delete chat profile" = "Elimina il profilo di chat"; @@ -1568,6 +1643,9 @@ /* No comment provided by engineer. */ "Delete link?" = "Eliminare il link?"; +/* alert title */ +"Delete list?" = "Eliminare l'elenco?"; + /* No comment provided by engineer. */ "Delete member message?" = "Eliminare il messaggio del membro?"; @@ -1598,6 +1676,9 @@ /* server test step */ "Delete queue" = "Elimina coda"; +/* No comment provided by engineer. */ +"Delete report" = "Elimina la segnalazione"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Elimina fino a 20 messaggi contemporaneamente."; @@ -1706,6 +1787,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Disattiva (mantieni sostituzioni)"; +/* alert title */ +"Disable automatic message deletion?" = "Disattivare l'eliminazione automatica dei messaggi?"; + +/* alert button */ +"Disable delete messages" = "Disattiva eliminazione messaggi"; + /* No comment provided by engineer. */ "Disable for all" = "Disattiva per tutti"; @@ -1766,6 +1853,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NON usare SimpleX per chiamate di emergenza."; +/* No comment provided by engineer. */ +"Documents:" = "Documenti:"; + /* No comment provided by engineer. */ "Don't create address" = "Non creare un indirizzo"; @@ -1775,6 +1865,9 @@ /* No comment provided by engineer. */ "Don't show again" = "Non mostrare più"; +/* No comment provided by engineer. */ +"Done" = "Fatto"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Esegui downgrade e apri chat"; @@ -1956,6 +2049,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "richiesta rinegoziazione della crittografia per %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Rinegoziazione della crittografia in corso."; + /* No comment provided by engineer. */ "ended" = "terminata"; @@ -2043,6 +2139,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Errore nella creazione del link del gruppo"; +/* alert title */ +"Error creating list" = "Errore nella creazione dell'elenco"; + /* No comment provided by engineer. */ "Error creating member contact" = "Errore di creazione del contatto"; @@ -2052,6 +2151,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Errore nella creazione del profilo!"; +/* No comment provided by engineer. */ +"Error creating report" = "Errore nella creazione del resoconto"; + /* No comment provided by engineer. */ "Error decrypting file" = "Errore decifrando il file"; @@ -2121,9 +2223,15 @@ /* No comment provided by engineer. */ "Error removing member" = "Errore nella rimozione del membro"; +/* alert title */ +"Error reordering lists" = "Errore riordinando gli elenchi"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Errore di azzeramento statistiche"; +/* alert title */ +"Error saving chat list" = "Errore nel salvataggio dell'elenco di chat"; + /* No comment provided by engineer. */ "Error saving group profile" = "Errore nel salvataggio del profilo del gruppo"; @@ -2259,12 +2367,18 @@ /* swipe action */ "Favorite" = "Preferito"; +/* No comment provided by engineer. */ +"Favorites" = "Preferite"; + /* file error alert title */ "File error" = "Errore del file"; /* alert message */ "File errors:\n%@" = "Errori di file:\n%@"; +/* file error text */ +"File is blocked by server operator:\n(info.reason.text)." = "Il file è bloccato dall'operatore del server:\n(info.reason.text)."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "File non trovato - probabilmente è stato eliminato o annullato."; @@ -2496,6 +2610,9 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Il gruppo verrà eliminato per te. Non è reversibile!"; +/* No comment provided by engineer. */ +"Groups" = "Gruppi"; + /* No comment provided by engineer. */ "Help" = "Aiuto"; @@ -2535,6 +2652,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Come aiuta la privacy"; +/* alert button */ +"How it works" = "Come funziona"; + /* No comment provided by engineer. */ "How SimpleX works" = "Come funziona SimpleX"; @@ -2622,6 +2742,12 @@ /* No comment provided by engineer. */ "inactive" = "inattivo"; +/* report reason */ +"Inappropriate content" = "Contenuto inappropriato"; + +/* report reason */ +"Inappropriate profile" = "Profilo inappropriato"; + /* No comment provided by engineer. */ "Incognito" = "Incognito"; @@ -2889,6 +3015,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Desktop collegati"; +/* swipe action */ +"List" = "Elenco"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Il nome dell'elenco e l'emoji dovrebbero essere diversi per tutte le liste."; + +/* No comment provided by engineer. */ +"List name..." = "Nome elenco..."; + /* No comment provided by engineer. */ "LIVE" = "IN DIRETTA"; @@ -3066,6 +3201,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "I messaggi da %@ verranno mostrati!"; +/* alert message */ +"Messages in this chat will never be deleted." = "I messaggi in questa chat non verranno mai eliminati."; + /* No comment provided by engineer. */ "Messages received" = "Messaggi ricevuti"; @@ -3138,9 +3276,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "moderato da %@"; +/* member role */ +"moderator" = "moderatore"; + /* time unit */ "months" = "mesi"; +/* swipe action */ +"More" = "Altro"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Altri miglioramenti sono in arrivo!"; @@ -3156,10 +3300,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Profili di chat multipli"; -/* No comment provided by engineer. */ -"mute" = "silenzia"; - -/* swipe action */ +/* notification label action */ "Mute" = "Silenzia"; /* No comment provided by engineer. */ @@ -3255,6 +3396,15 @@ /* Authentication unavailable */ "No app password" = "Nessuna password dell'app"; +/* No comment provided by engineer. */ +"No chats" = "Nessuna chat"; + +/* No comment provided by engineer. */ +"No chats found" = "Nessuna chat trovata"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Nessuna chat nell'elenco %@"; + /* No comment provided by engineer. */ "No contacts selected" = "Nessun contatto selezionato"; @@ -3324,12 +3474,18 @@ /* copied message info in history */ "no text" = "nessun testo"; +/* No comment provided by engineer. */ +"No unread chats" = "Nessuna chat non letta"; + /* No comment provided by engineer. */ "No user identifiers." = "Nessun identificatore utente."; /* No comment provided by engineer. */ "Not compatible!" = "Non compatibile!"; +/* No comment provided by engineer. */ +"Notes" = "Note"; + /* No comment provided by engineer. */ "Nothing selected" = "Nessuna selezione"; @@ -3407,6 +3563,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Solo i proprietari del gruppo possono attivare i messaggi vocali."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Solo il mittente e i moderatori lo vedono"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Solo tu e i moderatori lo vedete"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Solo tu puoi aggiungere reazioni ai messaggi."; @@ -3455,12 +3617,21 @@ /* No comment provided by engineer. */ "Open group" = "Apri gruppo"; +/* No comment provided by engineer. */ +"Open link" = "Apri link"; + +/* No comment provided by engineer. */ +"Open links from chat list" = "Apri i link dall'elenco delle chat"; + /* authentication reason */ "Open migration to another device" = "Apri migrazione ad un altro dispositivo"; /* No comment provided by engineer. */ "Open Settings" = "Apri le impostazioni"; +/* No comment provided by engineer. */ +"Open web link?" = "Aprire il link?"; + /* No comment provided by engineer. */ "Opening app…" = "Apertura dell'app…"; @@ -3936,6 +4107,30 @@ /* chat item action */ "Reply" = "Rispondi"; +/* chat item action */ +"Report" = "Segnala"; + +/* report reason */ +"Report content: only group moderators will see it." = "Segnala contenuto: solo i moderatori del gruppo lo vedranno."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Segnala profilo: solo i moderatori del gruppo lo vedranno."; + +/* report reason */ +"Report other: only group moderators will see it." = "Segnala altro: solo i moderatori del gruppo lo vedranno."; + +/* No comment provided by engineer. */ +"Report reason?" = "Motivo della segnalazione?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Segnala spam: solo i moderatori del gruppo lo vedranno."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Segnala violazione: solo i moderatori del gruppo lo vedranno."; + +/* No comment provided by engineer. */ +"Reports" = "Segnalazioni"; + /* chat list item title */ "requested to connect" = "richiesto di connettersi"; @@ -4039,6 +4234,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Salva il profilo del gruppo"; +/* No comment provided by engineer. */ +"Save list" = "Salva elenco"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Salva la password e apri la chat"; @@ -4339,6 +4537,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Imposta 1 giorno"; +/* No comment provided by engineer. */ +"Set chat name…" = "Imposta il nome della chat…"; + /* No comment provided by engineer. */ "Set contact name…" = "Imposta nome del contatto…"; @@ -4547,6 +4748,10 @@ /* notification title */ "Somebody" = "Qualcuno"; +/* blocking reason + report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Quadrata, circolare o qualsiasi forma tra le due."; @@ -4604,6 +4809,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Arresto della chat"; +/* No comment provided by engineer. */ +"Storage" = "Archiviazione"; + /* No comment provided by engineer. */ "strike" = "barrato"; @@ -4763,6 +4971,9 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Il profilo è condiviso solo con i tuoi contatti."; +/* No comment provided by engineer. */ +"The report will be archived for you." = "La segnalazione verrà archiviata per te."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Le stesse condizioni si applicheranno all'operatore **%@**."; @@ -4808,6 +5019,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Questa azione non è reversibile: i messaggi inviati e ricevuti in questa chat prima della selezione verranno eliminati."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile."; @@ -5015,10 +5229,7 @@ /* authentication reason */ "Unlock app" = "Sblocca l'app"; -/* No comment provided by engineer. */ -"unmute" = "riattiva notifiche"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Riattiva notifiche"; /* No comment provided by engineer. */ @@ -5339,6 +5550,9 @@ /* pref value */ "yes" = "sì"; +/* No comment provided by engineer. */ +"Yes" = "Sì"; + /* No comment provided by engineer. */ "you" = "tu"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 39b455f8d1..b97da471b2 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -2201,7 +2201,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "複数チャットのプロフィール"; -/* swipe action */ +/* notification label action */ "Mute" = "ミュート"; /* No comment provided by engineer. */ @@ -3256,7 +3256,7 @@ /* authentication reason */ "Unlock app" = "アプリのロック解除"; -/* swipe action */ +/* notification label action */ "Unmute" = "ミュート解除"; /* swipe action */ diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 4ec93e1188..0f03f40496 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* No comment provided by engineer. */ "\n" = "\n"; +/* No comment provided by engineer. */ +" " = " "; + /* No comment provided by engineer. */ " (" = " ("; @@ -199,6 +202,9 @@ /* time interval */ "%d sec" = "%d sec"; +/* delete after time */ +"%d seconds(s)" = "%d seconden"; + /* integrity error chat item */ "%d skipped message(s)" = "%d overgeslagen bericht(en)"; @@ -304,6 +310,9 @@ time interval */ "1 week" = "1 week"; +/* delete after time */ +"1 year" = "1 jaar"; + /* No comment provided by engineer. */ "1-time link" = "Eenmalige link"; @@ -397,6 +406,9 @@ /* No comment provided by engineer. */ "Add friends" = "Vrienden toevoegen"; +/* No comment provided by engineer. */ +"Add list" = "Lijst toevoegen"; + /* No comment provided by engineer. */ "Add profile" = "Profiel toevoegen"; @@ -412,6 +424,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Toevoegen aan een ander apparaat"; +/* No comment provided by engineer. */ +"Add to list" = "Toevoegen aan lijst"; + /* No comment provided by engineer. */ "Add welcome message" = "Welkom bericht toevoegen"; @@ -469,12 +484,18 @@ /* chat item text */ "agreeing encryption…" = "versleuteling overeenkomen…"; +/* No comment provided by engineer. */ +"All" = "alle"; + /* No comment provided by engineer. */ "All app data is deleted." = "Alle app-gegevens worden verwijderd."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Alle chats en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt!"; +/* alert message */ +"All chats will be removed from the list (text), and the list deleted." = "Alle chats worden uit de lijst (tekst) verwijderd en de lijst wordt verwijderd."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Alle gegevens worden bij het invoeren gewist."; @@ -601,6 +622,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "en %lld andere gebeurtenissen"; +/* report reason */ +"Another reason" = "Een andere reden"; + /* No comment provided by engineer. */ "Answer call" = "Beantwoord oproep"; @@ -616,6 +640,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "App versleutelt nieuwe lokale bestanden (behalve video's)."; +/* No comment provided by engineer. */ +"App group:" = "App-groep:"; + /* No comment provided by engineer. */ "App icon" = "App icon"; @@ -643,18 +670,33 @@ /* No comment provided by engineer. */ "Apply to" = "Toepassen op"; +/* No comment provided by engineer. */ +"Archive" = "Archief"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiveren en uploaden"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiveer contacten om later te chatten."; +/* No comment provided by engineer. */ +"Archive report" = "Rapport archiveren"; + +/* No comment provided by engineer. */ +"Archive report?" = "Rapport archiveren?"; + /* No comment provided by engineer. */ "Archived contacts" = "Gearchiveerde contacten"; +/* No comment provided by engineer. */ +"archived report" = "gearchiveerd rapport"; + /* No comment provided by engineer. */ "Archiving database" = "Database archiveren"; +/* No comment provided by engineer. */ +"Ask" = "Vragen"; + /* No comment provided by engineer. */ "Attach" = "Bijvoegen"; @@ -814,6 +856,9 @@ /* No comment provided by engineer. */ "Business chats" = "Zakelijke chats"; +/* No comment provided by engineer. */ +"Businesses" = "bedrijven"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -884,6 +929,9 @@ /* No comment provided by engineer. */ "Change" = "Veranderen"; +/* alert title */ +"Change automatic message deletion?" = "Automatisch verwijderen van berichten wijzigen?"; + /* authentication reason */ "Change chat profiles" = "Gebruikersprofielen wijzigen"; @@ -1032,6 +1080,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Gesprek wissen?"; +/* No comment provided by engineer. */ +"Clear or delete group?" = "Groep wissen of verwijderen?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Privénotities verwijderen?"; @@ -1047,6 +1098,9 @@ /* No comment provided by engineer. */ "colored" = "gekleurd"; +/* report reason */ +"Community guidelines violation" = "Schending van de communityrichtlijnen"; + /* server test step */ "Compare file" = "Bestand vergelijken"; @@ -1218,6 +1272,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Verbindings- en serverstatus."; +/* No comment provided by engineer. */ +"Connection blocked" = "Verbinding geblokkeerd"; + /* No comment provided by engineer. */ "Connection error" = "Verbindingsfout"; @@ -1227,12 +1284,21 @@ /* chat list item title (it should not be shown */ "connection established" = "verbinding gemaakt"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Verbinding is geblokkeerd door serveroperator:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Verbinding nog niet klaar."; + /* No comment provided by engineer. */ "Connection notifications" = "Verbindingsmeldingen"; /* No comment provided by engineer. */ "Connection request sent!" = "Verbindingsverzoek verzonden!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Verbinding vereist heronderhandeling over encryptie."; + /* No comment provided by engineer. */ "Connection security" = "Beveiliging van de verbinding"; @@ -1293,6 +1359,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Contact personen kunnen berichten markeren voor verwijdering; u kunt ze wel bekijken."; +/* blocking reason */ +"Content violates conditions of use" = "Inhoud schendt de gebruiksvoorwaarden"; + /* No comment provided by engineer. */ "Continue" = "Doorgaan"; @@ -1335,6 +1404,9 @@ /* No comment provided by engineer. */ "Create link" = "Maak link"; +/* No comment provided by engineer. */ +"Create list" = "Maak een lijst"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Maak een nieuw profiel aan in [desktop-app](https://simplex.chat/downloads/). 💻"; @@ -1514,6 +1586,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Chat verwijderen"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Verwijder chatberichten van uw apparaat."; + /* No comment provided by engineer. */ "Delete chat profile" = "Chatprofiel verwijderen"; @@ -1568,6 +1643,9 @@ /* No comment provided by engineer. */ "Delete link?" = "Link verwijderen?"; +/* alert title */ +"Delete list?" = "Lijst verwijderen?"; + /* No comment provided by engineer. */ "Delete member message?" = "Bericht van lid verwijderen?"; @@ -1598,6 +1676,9 @@ /* server test step */ "Delete queue" = "Wachtrij verwijderen"; +/* No comment provided by engineer. */ +"Delete report" = "Rapport verwijderen"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Verwijder maximaal 20 berichten tegelijk."; @@ -1706,6 +1787,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Uitschakelen (overschrijvingen behouden)"; +/* alert title */ +"Disable automatic message deletion?" = "Automatisch verwijderen van berichten uitschakelen?"; + +/* alert button */ +"Disable delete messages" = "Berichten verwijderen uitschakelen"; + /* No comment provided by engineer. */ "Disable for all" = "Uitschakelen voor iedereen"; @@ -1766,6 +1853,9 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "Gebruik SimpleX NIET voor noodoproepen."; +/* No comment provided by engineer. */ +"Documents:" = "Documenten:"; + /* No comment provided by engineer. */ "Don't create address" = "Maak geen adres aan"; @@ -1775,6 +1865,9 @@ /* No comment provided by engineer. */ "Don't show again" = "Niet meer weergeven"; +/* No comment provided by engineer. */ +"Done" = "Klaar"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Downgraden en chat openen"; @@ -1956,6 +2049,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "heronderhandeling van versleuteling vereist voor % @"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Er wordt opnieuw onderhandeld over de encryptie."; + /* No comment provided by engineer. */ "ended" = "geëindigd"; @@ -2043,6 +2139,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fout bij maken van groep link"; +/* alert title */ +"Error creating list" = "Fout bij het aanmaken van de lijst"; + /* No comment provided by engineer. */ "Error creating member contact" = "Fout bij aanmaken contact"; @@ -2052,6 +2151,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Fout bij aanmaken van profiel!"; +/* No comment provided by engineer. */ +"Error creating report" = "Fout bij maken van rapport"; + /* No comment provided by engineer. */ "Error decrypting file" = "Fout bij het ontsleutelen van bestand"; @@ -2121,9 +2223,15 @@ /* No comment provided by engineer. */ "Error removing member" = "Fout bij verwijderen van lid"; +/* alert title */ +"Error reordering lists" = "Fout bij het opnieuw ordenen van lijsten"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Fout bij het resetten van statistieken"; +/* alert title */ +"Error saving chat list" = "Fout bij het opslaan van chatlijst"; + /* No comment provided by engineer. */ "Error saving group profile" = "Fout bij opslaan van groep profiel"; @@ -2259,12 +2367,18 @@ /* swipe action */ "Favorite" = "Favoriet"; +/* No comment provided by engineer. */ +"Favorites" = "Favorieten"; + /* file error alert title */ "File error" = "Bestandsfout"; /* alert message */ "File errors:\n%@" = "Bestandsfouten:\n%@"; +/* file error text */ +"File is blocked by server operator:\n(info.reason.text)." = "Bestand is geblokkeerd door serveroperator:\n(info.reason.text)."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Bestand niet gevonden - hoogstwaarschijnlijk is het bestand verwijderd of geannuleerd."; @@ -2496,6 +2610,9 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "De groep wordt voor u verwijderd, dit kan niet ongedaan worden gemaakt!"; +/* No comment provided by engineer. */ +"Groups" = "Groepen"; + /* No comment provided by engineer. */ "Help" = "Help"; @@ -2535,6 +2652,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Hoe het de privacy helpt"; +/* alert button */ +"How it works" = "Hoe het werkt"; + /* No comment provided by engineer. */ "How SimpleX works" = "Hoe SimpleX werkt"; @@ -2622,6 +2742,12 @@ /* No comment provided by engineer. */ "inactive" = "inactief"; +/* report reason */ +"Inappropriate content" = "Ongepaste inhoud"; + +/* report reason */ +"Inappropriate profile" = "Ongepast profiel"; + /* No comment provided by engineer. */ "Incognito" = "Incognito"; @@ -2889,6 +3015,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Gelinkte desktops"; +/* swipe action */ +"List" = "Lijst"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn."; + +/* No comment provided by engineer. */ +"List name..." = "Naam van lijst..."; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -3066,6 +3201,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Berichten van %@ worden getoond!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Berichten in deze chat zullen nooit worden verwijderd."; + /* No comment provided by engineer. */ "Messages received" = "Berichten ontvangen"; @@ -3138,9 +3276,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "gemodereerd door %@"; +/* member role */ +"moderator" = "moderator"; + /* time unit */ "months" = "maanden"; +/* swipe action */ +"More" = "Meer"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Meer verbeteringen volgen snel!"; @@ -3156,10 +3300,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Meerdere chatprofielen"; -/* No comment provided by engineer. */ -"mute" = "dempen"; - -/* swipe action */ +/* notification label action */ "Mute" = "Dempen"; /* No comment provided by engineer. */ @@ -3255,6 +3396,15 @@ /* Authentication unavailable */ "No app password" = "Geen app wachtwoord"; +/* No comment provided by engineer. */ +"No chats" = "Geen chats"; + +/* No comment provided by engineer. */ +"No chats found" = "Geen chats gevonden"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Geen chats in lijst %@"; + /* No comment provided by engineer. */ "No contacts selected" = "Geen contacten geselecteerd"; @@ -3324,12 +3474,18 @@ /* copied message info in history */ "no text" = "geen tekst"; +/* No comment provided by engineer. */ +"No unread chats" = "Geen ongelezen chats"; + /* No comment provided by engineer. */ "No user identifiers." = "Geen gebruikers-ID's."; /* No comment provided by engineer. */ "Not compatible!" = "Niet compatibel!"; +/* No comment provided by engineer. */ +"Notes" = "Notities"; + /* No comment provided by engineer. */ "Nothing selected" = "Niets geselecteerd"; @@ -3407,6 +3563,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Alleen groep eigenaren kunnen spraak berichten inschakelen."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Alleen de verzender en moderators zien het"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Alleen jij en moderators zien het"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Alleen jij kunt bericht reacties toevoegen."; @@ -3455,12 +3617,21 @@ /* No comment provided by engineer. */ "Open group" = "Open groep"; +/* No comment provided by engineer. */ +"Open link" = "Link openen"; + +/* No comment provided by engineer. */ +"Open links from chat list" = "Open links van chatlijst"; + /* authentication reason */ "Open migration to another device" = "Open de migratie naar een ander apparaat"; /* No comment provided by engineer. */ "Open Settings" = "Open instellingen"; +/* No comment provided by engineer. */ +"Open web link?" = "Weblink openen?"; + /* No comment provided by engineer. */ "Opening app…" = "App openen…"; @@ -3936,6 +4107,30 @@ /* chat item action */ "Reply" = "Antwoord"; +/* chat item action */ +"Report" = "Rapport"; + +/* report reason */ +"Report content: only group moderators will see it." = "Inhoud melden: alleen groepsmoderators kunnen dit zien."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Rapporteer ledenprofiel: alleen groepsmoderators kunnen dit zien."; + +/* report reason */ +"Report other: only group moderators will see it." = "Anders melden: alleen groepsmoderators kunnen het zien."; + +/* No comment provided by engineer. */ +"Report reason?" = "Reden melding?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Spam melden: alleen groepsmoderators kunnen het zien."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Rapporteer overtreding: alleen groepsmoderators kunnen dit zien."; + +/* No comment provided by engineer. */ +"Reports" = "Rapporten"; + /* chat list item title */ "requested to connect" = "verzocht om verbinding te maken"; @@ -4039,6 +4234,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Groep profiel opslaan"; +/* No comment provided by engineer. */ +"Save list" = "Lijst opslaan"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Wachtwoord opslaan en open je chats"; @@ -4339,6 +4537,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Stel 1 dag in"; +/* No comment provided by engineer. */ +"Set chat name…" = "Stel chatnaam in…"; + /* No comment provided by engineer. */ "Set contact name…" = "Contactnaam instellen…"; @@ -4547,6 +4748,10 @@ /* notification title */ "Somebody" = "Iemand"; +/* blocking reason + report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Vierkant, cirkel of iets daartussenin."; @@ -4604,6 +4809,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Chat stoppen"; +/* No comment provided by engineer. */ +"Storage" = "Opslag"; + /* No comment provided by engineer. */ "strike" = "staking"; @@ -4763,6 +4971,9 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Het profiel wordt alleen gedeeld met uw contacten."; +/* No comment provided by engineer. */ +"The report will be archived for you." = "Het rapport wordt voor u gearchiveerd."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dezelfde voorwaarden gelden voor operator **%@**."; @@ -4808,6 +5019,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Deze actie kan niet ongedaan worden gemaakt, de berichten die eerder zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. Het kan enkele minuten duren."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Deze actie kan niet ongedaan worden gemaakt. De berichten die eerder in deze chat zijn verzonden en ontvangen dan geselecteerd, worden verwijderd."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Deze actie kan niet ongedaan worden gemaakt. Uw profiel, contacten, berichten en bestanden gaan definitief verloren."; @@ -5015,10 +5229,7 @@ /* authentication reason */ "Unlock app" = "Ontgrendel app"; -/* No comment provided by engineer. */ -"unmute" = "dempen opheffen"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Dempen opheffen"; /* No comment provided by engineer. */ @@ -5339,6 +5550,9 @@ /* pref value */ "yes" = "Ja"; +/* No comment provided by engineer. */ +"Yes" = "Ja"; + /* No comment provided by engineer. */ "you" = "jij"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index b32a9e37d4..592194f19a 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -199,6 +199,9 @@ /* time interval */ "%d sec" = "%d sek"; +/* delete after time */ +"%d seconds(s)" = "%d sekundach"; + /* integrity error chat item */ "%d skipped message(s)" = "%d pominięte wiadomość(i)"; @@ -304,6 +307,9 @@ time interval */ "1 week" = "1 tydzień"; +/* delete after time */ +"1 year" = "1 roku"; + /* No comment provided by engineer. */ "1-time link" = "link jednorazowy"; @@ -394,6 +400,9 @@ /* No comment provided by engineer. */ "Add friends" = "Dodaj znajomych"; +/* No comment provided by engineer. */ +"Add list" = "Dodaj listę"; + /* No comment provided by engineer. */ "Add profile" = "Dodaj profil"; @@ -409,6 +418,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Dodaj do innego urządzenia"; +/* No comment provided by engineer. */ +"Add to list" = "Dodaj do listy"; + /* No comment provided by engineer. */ "Add welcome message" = "Dodaj wiadomość powitalną"; @@ -466,12 +478,18 @@ /* chat item text */ "agreeing encryption…" = "uzgadnianie szyfrowania…"; +/* No comment provided by engineer. */ +"All" = "Wszystko"; + /* No comment provided by engineer. */ "All app data is deleted." = "Wszystkie dane aplikacji są usunięte."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Wszystkie czaty i wiadomości zostaną usunięte - nie można tego cofnąć!"; +/* alert message */ +"All chats will be removed from the list (text), and the list deleted." = "Wszystkie rozmowy zostaną usunięte z listy (text), a lista usunięta."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Wszystkie dane są usuwane po jego wprowadzeniu."; @@ -808,6 +826,9 @@ /* No comment provided by engineer. */ "Business chats" = "Czaty biznesowe"; +/* No comment provided by engineer. */ +"Businesses" = "Firmy"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -3042,10 +3063,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Wiele profili czatu"; -/* No comment provided by engineer. */ -"mute" = "wycisz"; - -/* swipe action */ +/* notification label action */ "Mute" = "Wycisz"; /* No comment provided by engineer. */ @@ -4751,10 +4769,7 @@ /* authentication reason */ "Unlock app" = "Odblokuj aplikację"; -/* No comment provided by engineer. */ -"unmute" = "wyłącz wyciszenie"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Wyłącz wyciszenie"; /* No comment provided by engineer. */ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index b43c46f208..5d78aaa35b 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -199,6 +199,9 @@ /* time interval */ "%d sec" = "%d сек"; +/* delete after time */ +"%d seconds(s)" = "%d секунд"; + /* integrity error chat item */ "%d skipped message(s)" = "%d пропущенных сообщение(й)"; @@ -304,6 +307,9 @@ time interval */ "1 week" = "1 неделю"; +/* delete after time */ +"1 year" = "1 год"; + /* No comment provided by engineer. */ "1-time link" = "Одноразовая ссылка"; @@ -397,6 +403,9 @@ /* No comment provided by engineer. */ "Add friends" = "Добавить друзей"; +/* No comment provided by engineer. */ +"Add list" = "Добавить список"; + /* No comment provided by engineer. */ "Add profile" = "Добавить профиль"; @@ -412,6 +421,9 @@ /* No comment provided by engineer. */ "Add to another device" = "Добавить на другое устройство"; +/* No comment provided by engineer. */ +"Add to list" = "Добавить в список"; + /* No comment provided by engineer. */ "Add welcome message" = "Добавить приветственное сообщение"; @@ -469,6 +481,9 @@ /* chat item text */ "agreeing encryption…" = "шифрование согласовывается…"; +/* No comment provided by engineer. */ +"All" = "Все"; + /* No comment provided by engineer. */ "All app data is deleted." = "Все данные приложения будут удалены."; @@ -601,6 +616,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "и %lld других событий"; +/* report reason */ +"Another reason" = "Другая причина"; + /* No comment provided by engineer. */ "Answer call" = "Принять звонок"; @@ -643,12 +661,21 @@ /* No comment provided by engineer. */ "Apply to" = "Применить к"; +/* No comment provided by engineer. */ +"Archive" = "Архивировать"; + /* No comment provided by engineer. */ "Archive and upload" = "Архивировать и загрузить"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Архивируйте контакты чтобы продолжить переписку."; +/* No comment provided by engineer. */ +"Archive report" = "Архивировать отчет"; + +/* No comment provided by engineer. */ +"Archive report?" = "Архивировать отчет?"; + /* No comment provided by engineer. */ "Archived contacts" = "Архивированные контакты"; @@ -3156,10 +3183,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Много профилей чата"; -/* No comment provided by engineer. */ -"mute" = "без звука"; - -/* swipe action */ +/* notification label action */ "Mute" = "Без звука"; /* No comment provided by engineer. */ @@ -5015,10 +5039,7 @@ /* authentication reason */ "Unlock app" = "Разблокировать"; -/* No comment provided by engineer. */ -"unmute" = "уведомлять"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Уведомлять"; /* No comment provided by engineer. */ diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index a959e29f08..0a3564fe91 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -1928,7 +1928,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "โปรไฟล์การแชทหลายรายการ"; -/* swipe action */ +/* notification label action */ "Mute" = "ปิดเสียง"; /* No comment provided by engineer. */ @@ -2971,7 +2971,7 @@ /* authentication reason */ "Unlock app" = "ปลดล็อคแอป"; -/* swipe action */ +/* notification label action */ "Unmute" = "เปิดเสียง"; /* swipe action */ diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index 0da95054aa..dd0d7c6217 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -3141,10 +3141,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Çoklu sohbet profili"; -/* No comment provided by engineer. */ -"mute" = "Sessiz"; - -/* swipe action */ +/* notification label action */ "Mute" = "Sustur"; /* No comment provided by engineer. */ @@ -4859,10 +4856,7 @@ /* authentication reason */ "Unlock app" = "Uygulama kilidini aç"; -/* No comment provided by engineer. */ -"unmute" = "susturmayı kaldır"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Susturmayı kaldır"; /* No comment provided by engineer. */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 9b4a1d0980..f750d1ac8b 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -3156,10 +3156,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "Кілька профілів чату"; -/* No comment provided by engineer. */ -"mute" = "приглушити"; - -/* swipe action */ +/* notification label action */ "Mute" = "Вимкнути звук"; /* No comment provided by engineer. */ @@ -5015,10 +5012,7 @@ /* authentication reason */ "Unlock app" = "Розблокувати додаток"; -/* No comment provided by engineer. */ -"unmute" = "увімкнути звук"; - -/* swipe action */ +/* notification label action */ "Unmute" = "Увімкнути звук"; /* No comment provided by engineer. */ diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index f7ab7f7994..68ca7a839f 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -2865,10 +2865,7 @@ /* No comment provided by engineer. */ "Multiple chat profiles" = "多个聊天资料"; -/* No comment provided by engineer. */ -"mute" = "静音"; - -/* swipe action */ +/* notification label action */ "Mute" = "静音"; /* No comment provided by engineer. */ @@ -4508,10 +4505,7 @@ /* authentication reason */ "Unlock app" = "解锁应用程序"; -/* No comment provided by engineer. */ -"unmute" = "取消静音"; - -/* swipe action */ +/* notification label action */ "Unmute" = "取消静音"; /* No comment provided by engineer. */ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 417f6a717a..7075b9b185 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1207,7 +1207,7 @@ سيتم استلام الفيديو عندما تكون جهة اتصالك متصلة بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! يمكنك مشاركة هذا العنوان مع جهات اتصالك للسماح لهم بالاتصال بـ%s. أُزيلت %1$s - تحديث + حدّث قاعدة بيانات الدردشة الخاصة بك غير مُعمّاة - عيّن عبارة مرور لحمايتها. عبارة مرور قاعدة بيانات خاطئة سيتم إرسال ملف تعريف الدردشة الخاص بك إلى أعضاء المجموعة @@ -1844,7 +1844,7 @@ لا يمكن إرسال الرسالة تفضيلات الدردشة المحدّدة تحظر هذه الرسالة. التفاصيل - بدءًا من %s.\nجميع البيانات خاصة على جهازك.. + بدءًا من %s.\nجميع البيانات خاصة على جهازك. أرسلت الإجمالي الحجم الملفات المرفوعة @@ -2318,4 +2318,10 @@ عيّن اسم الدردشة… لا يمكن التراجع عن هذا الإجراء - سيتم حذف الرسائل المُرسلة والمُستلمة في هذه الدردشة قبل التاريخ المحدّد. تغيير حذف الرسائل التلقائي؟ + استخدم منفذ TCP %1$s عندما لا يتم تحديد أي منفذ. + استخدم منفذ الويب + منفذ TCP للمُراسلة + اكتم الكل + ذّكورات غير مقروءة + يمكنك ذكر ما يصل إلى %1$s من الأعضاء في الرسالة الواحدة! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index ca95b77827..c37d4e6924 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1202,7 +1202,7 @@ Ще бъдете свързани, когато устройството на вашия контакт е онлайн, моля, изчакайте или проверете по-късно! Няма да загубите контактите си, ако по-късно изтриете адреса си. Вашите настройки - Вашият SimpleX адрес + Вашият адрес в SimpleX Използвай за нови връзки Вашите XFTP сървъри Използвай сървърите на SimpleX Chat\? @@ -1443,7 +1443,7 @@ Несъвместим! Изчаква се мобилното устройство да се свърже: Изтрий %d съобщения? - Свържи мобилно устройство + Свързване на мобилно устройство Свързване с %1$s? Премахни член Блокирай @@ -2269,4 +2269,5 @@ Как помага за поверителността Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. Добави членове на екипа + Заглушаване на всички diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 53d1dbabfa..dd2f9a764c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -2294,4 +2294,7 @@ Desactivar la supressió automàtica de missatges? Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat abans del seleccionat se suprimiran. Els missatges d\'aquest xat no se suprimiran mai. + Port TCP per a missatgeria + Emprar port web + Emprar el port TCP %1$s quan no se n\'especifica cap. 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 a3fddd820c..fc4c421e3b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -601,9 +601,9 @@ Keine empfangenen oder gesendeten Dateien %d Datei(en) mit einem Gesamtspeicherverbrauch von %s nie - täglich - wöchentlich - monatlich + Älter als ein Tag + Älter als eine Woche + Älter als einen Monat %s Sekunde(n) Löschen der Nachrichten Automatisches Löschen von Nachrichten aktivieren? @@ -2395,12 +2395,18 @@ Web-Link öffnen? Ja Chat-Name festlegen… - Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Dies kann nicht rückgängig gemacht werden! + Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden! Automatisches Löschen von Nachrichten ändern? Chat-Nachrichten von Ihrem Gerät löschen Automatisches Löschen von Nachrichten deaktivieren? Löschen von Nachrichten deaktivieren - 1 Jahr + Älter als ein Jahr Voreinstellung (%s) - Die Nachrichten in diesem Chat werden nie gelöscht. + Nachrichten in diesem Chat werden nie gelöscht. + Solange kein Port konfiguriert ist, wird der TCP-Port %1$s genutzt. + Web-Port nutzen + TCP-Port für die Nachrichtenübermittlung + Sie können bis zu %1$s Mitglieder pro Nachricht erwähnen! + Alle stummschalten + Ungelesene Erwähnungen diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index fec6e52948..48d9969d06 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -110,7 +110,7 @@ mensaje duplicado Herramientas desarrollo Eliminar los archivos de todos los perfiles - Eliminar mensaje + Eliminar mensajes ¡Base de datos cifrada! La base de datos está cifrada con una contraseña aleatoria, puedes cambiarla. Error en base de datos @@ -605,7 +605,7 @@ Notificaciones privadas imagen del perfil No se permiten mensajes de voz. - Proteger la pantalla de la aplicación + Proteger la pantalla repositorio GitHub .]]> Grabar mensaje de voz ha expulsado a %1$s @@ -748,7 +748,7 @@ Para proteger tu privacidad, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos. Para proteger tu información, activa el Bloqueo SimpleX. \nSe te pedirá que completes la autenticación antes de activar esta función. - Al actualizar la configuración el cliente se reconectará a todos los servidores. + Para actualizar la configuración el cliente se reconectará a todos los servidores. ¿Usar servidores SimpleX Chat\? Enlace de grupo SimpleX Invitación única SimpleX @@ -1319,7 +1319,7 @@ ¿Permitir confirmaciones para grupos\? Las confirmaciones están deshabilitadas para los grupos %d Las confirmaciones están activadas para %d grupos - Grupos pequeños (máx. 20) + Grupos pequeños (max. 20) Activar para todos los grupos %s: %s desactivado @@ -2196,7 +2196,7 @@ %s.]]> %s.]]> %s, debes aceptar las condiciones de uso.]]> - Servidores para archivos nuevos en tu perfil + Servidores para enviar archivos en tu perfil ¡Segundo operador predefinido! Puedes configurar los servidores a través de su configuración. Para protegerte contra una sustitución del enlace, puedes comparar los códigos de seguridad con tu contacto. @@ -2272,10 +2272,10 @@ Todos los chats serán quitados de la lista %s y esta será eliminada Crear lista Eliminar - Eliminar lista? + ¿Eliminar lista? Editar - Nombre de lista... - El nombre de lista y el emoji deben ser differentes en todas las listas. + Nombre de la lista... + El nombre y el emoji deben ser diferentes en todas las listas. Guardar lista Ningún chat sin leer Error al crear lista de chat @@ -2314,9 +2314,9 @@ Contenido inapropiado Perfil inapropiado Solo el remitente y el moderador pueden verlo - Solo tú y el moderador podéis verlo + Solo tú y los moderadores podéis verlo Spam - Abrir enlace desde la lista de chat + Abrir enlaces desde listado de chats Si No ¿Abrir enlace web? @@ -2324,11 +2324,17 @@ Abrir enlace Nombre para el chat… ¿Desactivar la eliminación automática de mensajes? - Desactivar la eliminación de mensajes + Desactivar ¿Modificar la eliminación automática de mensajes? - Eliminar todos los mensajes del dispositivo - Los mensajes nunca se borran en esta conversación - Todos los mensajes previos a la selección serán eliminados de este chat. ¡No podrá deshacerse! + Elimina los mensajes del dispositivo + Los mensajes de esta conversación nunca se borran. + Todos los mensajes previos a la selección serán eliminados del chat. ¡No podrá deshacerse! Un año predefinido (%s) + Puerto TCP para mensajes + Usar puerto TCP %1$s cuando no se especifica. + Usar puerto web + Silenciar todo + Menciones sin leer + ¡Puedes mencionar hasta %1$s miembros por mensaje! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index fffe525e0f..7f5b73a1d2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -5,11 +5,11 @@ Prekini Prosledi do 20 poruka odjednom. Sakriti kontakt i poruku - %1$d dokument greska(e):\n%2$s - %1$d dokument(a) se i dalje preuzima. - %1$d dokument(a) neuspešno preuzet. - %1$d dokument(a) su odstranjena. - %1$d dokument(a) nije preuzet. + %1$d greska(e) datoteka:\n%2$s + %1$d datoteka se i dalje preuzima. + %1$d datoteka neuspešno preuzeta. + %1$d odstranjene datoteke. + %1$d datoteka nije preuzeta. završen %1$s želi da se poveže sa tobom putem Prihvatiti @@ -21,7 +21,7 @@ Sakriti 5 minuta Ceo Link - Prosledi poruke… + Proslediti poruke… Prekini menjanje adrese? Ako se ne možeš sastati, pokaži QR kod u video pozivu ili podeli link. Sakriti profil @@ -30,7 +30,7 @@ Dodeliti dozvolu Slušalice POMOĆ - Grupa će biti obrisana za tebe – ovo ne može da se poništi! + Grupa će biti obrisana za Vas – ovo ne može da se poništi! Akcenat Grupni linkovi Madjarski i Turski UI @@ -54,7 +54,7 @@ 30 sekundi Sakriti: Sakriti - Prosledi poruke bez dokumenata? + Prosledi poruke bez datoteka? Kako da Prihvatiti %1$d poruke preskočene. @@ -86,7 +86,7 @@ Grupa Ako izabereš da odbiješ pošiljalac NEĆE biti obavešten. Skriveni razgovori - Grupa će biti obrisana za sve članove – ovo ne može da se poništi! + Grupa će biti obrisana za sve članove – ovo se ne može poništiti! Domaćin Kako utiče na bateriju Kako pomaže privatnosti @@ -117,7 +117,7 @@ Poslano u: %s Ažuriraj Šifra profila - Administratori mogu da blokiraju + Administratori mogu da blokiraju. Greška Napravi jednokratnu poveznicu Nalepiti @@ -127,7 +127,7 @@ Odstranjeno odstranjeno Napraviti - PORUKE I DOKUMENTA + PORUKE I DATOTEKE Poruka SERVERI Odstraniti profil razgovora @@ -139,7 +139,7 @@ Odstraniti Podešavanje Ažuriraj - Odstraniti sve dokumente + Odstraniti sve datoteke Odstraniti profil razgovora? Poruke administrator @@ -167,7 +167,7 @@ poslano Odstraniti profil razgovora za Prihvati anonimno - Podeli + Podeliti Dodaj na listu Pristup na servere pomoću SOCKS proxy na portu %d? Proxy mora da bude uključen pre omogućavanja ove opcije. Prihvaćeni uslovi @@ -213,7 +213,7 @@ povezivanje… Kamera %d mesec - Dokument nije pronađen + Datoteka nije pronađena %d poruke blokirane administratorom %d izvještaji poziv @@ -221,9 +221,9 @@ pozivanje… %d min %d sec - Preuzeti dokumenti + Preuzeti datoteke blokirano administratorom - vi + Vi nepoznat status Poveži se anonimno greška u pozivu @@ -244,12 +244,12 @@ POZIVI Blokiraj članove grupe Nepoznati serveri! - Dokument - Dokument + Datoteka + Datoteka Uključiti zvuk Kamera i mikrofon vi: %1$s - Povezan na telefon + Povezano sa telefonom blokirano %d poruka blokirano povezivanje @@ -296,7 +296,7 @@ %d minut(a) Proveri ažuriranje Stabilno - DOKUMENTA + DATOTEKE %s otpremljeno Onemogućiti obavještenja %s nije verifikovan @@ -304,9 +304,9 @@ Profil razgovora Onemogućiti %d nedelja - - opcionalno obavestiti o obrisanim kontaktima.\n- imena profila sa razmacima.\n- i još mnogo toga!\" + - neobavezno obavestiti o obrisanim kontaktima.\n- imena profila sa razmacima.\n- i još mnogo toga!\" Uvoženje arhive - Dokumenta + Datoteke Otpremljeno %s je verifikovan Konzola razgovora @@ -316,11 +316,11 @@ BAZA PODATAKA RAZGOVORA onemogućeno Greška pri uvoženju teme - Dokumenta i medijski sadržaji su zabranjeni. + Datoteke i medijski sadržaji su zabranjeni. Ili nalepiti link arhive Onemogućeno za sve grupe Nestaje u - Dokumenta i medijski sadržaji + Datoteke i medijski sadržaji svi članovi Poruke koje nestaju onemogućeno @@ -330,10 +330,10 @@ Onemogućeno Aplikacija RAZGOVORI - Dokumenta i medijski sadržaji su zabranjeni! + Datoteke i medijski sadržaji su zabranjeni! Poruke koje nestaju su zabranjene u ovom razgovoru. Razgovor je zaustavljen - Dokument sačuvan + Datoteka sačuvana Verzija aplikacije Verzija aplikacije: v%s Tema razgovora @@ -344,7 +344,7 @@ Poruke koje nestaju Poruke koje nestaju su zabranjene. Adresa servera - Dokumenta i medijski sadržaji nisu dozvoljeni + Datoteke i medijski sadržaji nisu dozvoljeni Dozvoliti Razgovor sa kreatorima Razgovori @@ -383,7 +383,7 @@ Uvesti Boje razgovora %s: %s - Ili uvesti arhivirani dokument + Ili uvesti arhiviranu datoteku Razgovor će biti odstranjen za sve članove - ovo se ne može vratiti! Direktne poruke između članova su zabranjene u ovim porukama. Prekinuti @@ -435,7 +435,7 @@ Kontakti Isključeno Zahvaljujući korisnicima – doprinesi pomoću Weblate! - Omogući + Omogućiti Vaši serveri Sačuvati sek @@ -633,7 +633,7 @@ Adresa računara Pristupiti Vašoj grupi? Trenutna obaveštenja su onemogućena! - Zaustaviti dokument + Zaustaviti datoteku pristupiti kao %s Informacije Odgovoriti @@ -732,12 +732,12 @@ Pozvati u razgovor Odblokirati Italijanski interfejs - Dokument: %s + Datoteka: %s Pristupiti anonimno Napustiti grupu? Uređaji Izabrati - Poslati priznanice + Poslati potvrde Popraviti vezu? Napustiti Napustiti razgovor @@ -746,7 +746,7 @@ Ukloniti člana? Ponoviti Veličina teksta - Videa i dokumenta do 1gb + Videa i datoteke do 1gb Nekompatibilna verzija Ponoviti preuzimanje Zaustaviti razgovor? @@ -771,8 +771,8 @@ Kontrolisanje vaše mreže Kopirano Poruka je prevelika - Otpremiti dokument - Napraviti dokument + Otpremiti datoteku + Napraviti datoteku Napraviti tajnu grupu kreator Otkriti @@ -831,7 +831,7 @@ Završeno Proširiti Prikazati QR kod - Preuzeti dokument + Preuzeti datoteku Napraviti grupni link Biznisi Bolje korisničko iskustvo @@ -849,4 +849,362 @@ završiti Proširiti selekciju uloga Otvaranje baze podataka… + Obnoviti + Zvučnik isključen + obojen + Podeli vezu + Spam + Zvučnik + Spam + Razgovori nisu pronađeni + Zvučnik uključen + Bezbednije grupe + Odbiti + Odbiti + Razgovori nisu u listi %s. + Previše slika! + autor + Izvesti temu + Žive poruke + Website + dani + Prosleđivanje %1$s poruka + Kada je dostupno + Proslediti i sačuvati poruke + Prosleđeno + Server za prosleđivanje: %1$s\nGreška: %2$s + Označiti da je pročitano + Server za prosleđivanje : %1$s\nGreška odredišnog servera: %2$s + Verifikovati veze + Primeniti na + Zaustavljanje razgovora + Nalepiti link koji ste primili + Oceniti aplikaciju + Nema nepročitanih razgovora + Izveštaj će biti arhiviran za Vas. + Sačuvati neiskorišćenu pozivnicu? + Glasovna poruka… + Koristiti trenutni profil + Nalepiti link + Nalepiti link za povezivanje! + ŽIVO + Zvezdica na GitHubu + Razmera + Verifikovati bezbednost veze + Trenutni profil + Odstraniti grupu + prosleđeno + Tekst poruke + Prosleđeno od + Učitavanje razgovora… + Proslediti %1$s poruku(e)? + Bezbednost veze + Kada je aplikacija pokrenuta + Proslediti + Velika datoteka! + Primeniti + Poruka će biti odstranjena - ovo se ne može poništiti! + Poruka je prevelika! + Učitavanje datoteke + Glasovna poruka + Glasovna poruka (%1$s) + Naučiti više + Označiti da je verifikovano + Koristiti razgovor + Označiti da nije pročitano + Verifikovati vezu + zahtev za povezivanje + Dodirnuti za skeniranje + Nepoznata greška u bazi podataka: %s + Primljene poruke + duplikati + da + Migracija završena + Da + Omogućiti flux + Greška: %s + Greška u zaustavljanju razgovora + Doprinesite + upravio %s + Trenutan Pin kod + Čekanje na video + Čekanje na dokument + Neophodno + Uvek + Preuzimanje %s (%s) + Ignorisati + Novi Pin kod + Pin kod aplikacije + Omogućiti pin kod za samouništenje + Pin kod za samouništenje + Isključiti + Upravljen u + Vidljiva istorija + Prekinuta veza iz razloga: %s + Mobilna + Poruke poslane + Za sve + Omogući evidenciju + Odmah + greška u slanju + Odstraniti server + blokirali ste %s + Kvantno otporno šifrovanje + Ne praviti adresu + Omogućiti svim grupama + Ne prikazivati ponovo + minuti + meseci + Pregled + Uneti Pin kod + Sesija aplikacije + Prikazati samo kontakt + nepoznato + Promeniti redosled + promena adrese za %s… + Greška u promeni profila! + Koristiti novi anoniman profil + veza uspostavljena + Promeniti listu + Slušalica + Isključiti? + Ovaj uređaj + Omogućiti samouništenje + Uvek uključeno + Odstraniti izveštaj + Poslana slika + Potvrditi + Još nekoliko stvari + Povezati pomoću jednokratne veze? + Pin kod + Vi i Vaš kontakt, možete da šaljete poruke koje nestaju. + Zvukovi u pozivu + standardno od kraja-do-kraja šifrovanje + moderator + uvek + Smanjiti upotrebu baterije + Proxied(posrednički) serveri + Novi server + Prikazati procente + Napustiti bez čuvanja + POKRENUTI RAZGOVOR + Odblokirati člana za sve? + Priprema za preuzimanje + Proxied(posredovan) + Greška u dešifrovanju + Prikazati pregled + Migracije: %s + Ova grupa više ne postoji. + promenili ste ulogu %s u %s + posmatrač + Status poruke + Status datoteke + podrazumevano (%s) + Persijski UI + Statistika servera ce biti obnovljena - ovo se ne može poništiti! + Promeniti pin kod + Čekanje na sliku + Čekanje na sliku + Čekanje na video + Za početak novog razgovora + Omogućiti svima + Greška u pokretanju razgovora + uloga promenjena iz %s u %s + Promeniti ulogu grupe? + Odstraniti datoteku + SimpleX Tim + upravljen + Operator servera blokira vezu:\n%1$s. + Promeniti ulogu + Ime ovog uređaja + Dodirnuti za početak novog razgovora + pozvani ste u grupu + Slika sačuvana u Galeriji + vaša uloga je promenjena u %s + promenili ste adresu + Kontakt je odstranjen. + Japanski i Portugalski UI + Pin kod za samouništenje + Pronađite i pristupite grupama + Odstraniti za mene + Greška u dekodiranju + Prekinuta veza + Potvrditi brisanje kontakta? + Greška u promeni profila + Pin kod za samouništenje promenjen! + Pin kod za samouništenje omogućen! + Uvesti bazu podataka razgovora? + Odblokirati člana + Odblokirati člana? + Poslati greške + Promeniti + Čekanje na računar… + Ne dozvoliti + omogućeno + Vaš nasumičan profil + Molimo pokušajte kasnije. + Pošaljite nam email + Upravljen u: %s + Potvrditi otpremanje + Popuniti + Greške u dešifrovanju + Ukupno + Potvrditi unapređenje baze podataka + Pozvani ste u grupu + promenili ste vašu ulogu na %s + Potvrda je onemogućena + Imunitet na spam + duplikat poruke + Da + Greška u bazi podataka + podrazumevano (%s) + 1 godina + uklonili ste %1$s + napustili ste + promenili ste adresu za %s + Odblokirati za sve + Status poruke: %s + Status datoteke: %s + TCP veza тајм-аут + Poslana poruka + Omogućeno za kontakt + Omogućeno za Vas + Vi i Vaš kontakt, možete da šaljete glasovne poruke. + Omogućeno za + Nadograditi aplikaciju automatski + Litvanski UI + Prikazati informacije za + WiFi + Uskoro! + istekao + Vi i Vaš kontakt, možete da obavljate pozive. + Kontakt već postoji + Pogledati bezbednostni kod + Podeliti jednokratnu veza + Proslediti poruku… + Adresa ili jednokratna veza? + Omogućiti pristup kameri + Nevažeći QR kod + Jednokratna pozivnica + Uneti poruku dobrodošlice… + Čuvanje %1$s poruka + Prenosna izolacija + Uneti poruku dobrodošlice… (neobavezno) + Koncept poruke + Neispravan pin kod + čeka se potvrda… + Uneti server ručno + kontakt nema e2e šifrovanje + Kontakt odstranjen! + Nevažeći QR kod + Procena bezbednosti + Uporediti datoteku + Kontakt i sve poruke biće odstranjene - ovo ne može biti vraćeno! + Datoteka će biti odstranjena sa servera. + Opozvati + Poništiti pregled slike + %s u %s + Poništiti živu poruku + Jednokratna pozivnica + Nevažeća adresa server! + Sačuvani WebRTC ICE biće uklonjeni. + Unos pin koda + Prosleđena poruka + Ništa za prosleđivanje + Pregledati kasnije + kontakt ima e2e šifrovanje + Zaključati nakon + Skriven kontakt: + Poništiti pregled datoteke + Omogućiti potvrde? + Omogućiti potvrde za grupe? + Transparentnost + Pregled uslova + Poništiti migraciju + Opozvati datoteku + Opozvati datoteku? + Živa poruka! + Greške u preuzimanju + Sačuvati poruku dobrodošlice? + Nekompatibilno! + Pripremanje otpremanja + Sačuvati šifru profila + WebRTC ICE serveri + Pregled obaveštenja + povezivanje poziva… + Pin kod nije promenjen! + Izvesti bazu podataka + Istekla pozivnica! + pozivnica u grupu %1$s + Nekompatibilna verzija baze podataka + Status mreže + Za slanje + Koncept poruke + Prenosna izolacija + Uneti ime ovog uređaja… + Prenosne sesije + Neuspešno preuzimanje + Zaustaviti deljenje adrese? + Greška u primanju datoteke + Greška u čuvanju datoteke + Podeliti profil + Zaustaviti deljenje + Prikazati poslednje poruke + Odstraniti do 20 poruka odjednom. + %s prekinuta veza]]> + Ovo je Vaša vlastita SimpleX adresa! + Očistiti verifikaciju + Greška u otvaranju pretraživača + Nema razgovora + Povezati se sa %1$s? + želi da se poveze sa Vama! + Prikazati status poruke + Veza nije spremna. + Povežite se brže sa svojim prijateljima. + %s prekinuta veza]]> + Datoteka je odstranjena ill je link neispravan + Ukupno poslano + Za društvene mreže + %s nedostaje]]> + Preuzimanje arhive + Ukupno primljeno + %s je neaktivan]]> + Ime kontakta + Greška u isporuci poruke + Poruke će biti označene za odstranjivanje. Primaoci će moći da otkriju te poruke. + Odstraniti datoteke i medijski sadržaj? + Primanje datoteke biće zaustavljeno. + Datoteka će biti primljena kada vaš kontakt bude na mreži, molimo sačekajte ili proverite kasnije! + Izabrati datoteku + Migriranje + Migriraj uređaj + Zaustaviti slanje datoteke? + Nije izabran nijedan razgovor + promena adrese… + %s je zauzet]]> + promena adrese… + Do 100 poslednjih poruka se šalje novim članovima. + Bolje poruke + Poruke će biti odstranjene - ovo se ne može poništiti! + Slanje datoteke biće zaustavljeno. + Zaustaviti primanje datoteke? + Greška u datoteci + Odstraniti bez obaveštenja + Greška u otpremanju + Ukupno grešaka + Detaljna statistika + Greška prilikom odstranjivanja + Ponoviti otpremanje + Koristiti sa računara + Povezati sa računarom + Povezano sa računarom + Dozvoliti glasovne poruke? + Promeniti prijemnu adresu? + Reakcije na poruku + Dozvoliti reakcije na poruku. + Reakcije na poruku + Promeniti prijemnu adresu + Nema poruka + Nema istorije + Reakcije na poruku su zabranjene. 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 abf13f2142..5cfb9fb8da 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -43,7 +43,7 @@ Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. - Érvénytelen az üzenet hasító-értéke + Érvénytelen az üzenet hasítóértéke Háttér Megjegyzés: az üzenet- és fájltovábbító-kiszolgálók SOCKS-proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése @@ -63,7 +63,7 @@ Kapcsolódás folyamatban! Nem lehet fogadni a fájlt Hitelesítés elérhetetlen - Alkalmazás verzió + Az alkalmazás verziója Üdvözlőüzenet hozzáadása titkosítás elfogadása %s számára… \nElérhető az v5.1-es kiadásban @@ -122,7 +122,7 @@ Élő csevegési üzenet visszavonása Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra) Hang- és videóhívások - érvénytelen az üzenet hasító-értéke + érvénytelen az üzenet hasítóértéke Mindig fut Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi az értesítések fogadását. Az összes alkalmazásadat törölve. @@ -161,7 +161,7 @@ Mindkét fél tud hívásokat kezdeményezni. Sikertelen hitelesítés Az összes %s által írt új üzenet el lesz rejtve! - Alkalmazás verzió: v%s + Az alkalmazás verziója: v%s A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva @@ -220,7 +220,7 @@ Időtúllépés kapcsolódáskor Kapcsolódás a következővel: %1$s? Létrehozás - Ismerős beállításai + Ismerős-beállítások Kapcsolat Kapcsolat megszakítva kapcsolat létrehozva @@ -233,7 +233,7 @@ Ismerősök Kapcsolódási hiba Az ismerős még nem kapcsolódott! - - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (20 tagig).\n- gyorsabb és stabilabb. + - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. Hozzájárulás kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása @@ -425,7 +425,7 @@ Az adatbázis titkosítva lesz, a jelmondat pedig a Keystore-ban lesz tárolva. Automatikus üzenet törlés engedélyezése? Törlés - az adatbázis verziója újabb, mint az alkalmazásé, visszafelé átköltöztetés nem lehetséges a következőhöz: %s + az adatbázis verziója újabb, mint az alkalmazásé, de a visszafelé történő átköltöztetés viszont nem lehetséges a következőhöz: %s Leírás %d óra %dp @@ -600,7 +600,7 @@ Teljes hivatkozás Hiba a cím megváltoztatásakor A tagok küldhetnek hangüzeneteket. - Csoportbeállítások + Csoport-beállítások Hiba: %s Eltűnő üzenetek SimpleX-zár bekapcsolása @@ -665,7 +665,7 @@ Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> - Megerősítés esetén az üzenetküldő-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. + Megerősítés esetén az üzenetváltó-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. A kép akkor érkezik meg, amikor a küldője befejezte annak feltöltését. QR-kód beolvasásával.]]> A kapott SimpleX Chat-meghívó-hivatkozását megnyithatja a böngészőjében: @@ -695,7 +695,7 @@ moderált A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy az XFTP-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. - Nincs kijelölve ismerős + Nincs ismerős kijelölve Nincsenek fogadott, vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben @@ -774,7 +774,7 @@ Nincsenek szűrt csevegések érvénytelen adat Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. - Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. + Csak a csoport-tulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. A fájl betöltése… Nincs hozzáadandó ismerős Üzenetvázlat @@ -791,10 +791,10 @@ Némítás Hordozható eszköz társítása Értesítési szolgáltatás - Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. + Csak a csoport-tulajdonosok engedélyezhetik a hangüzenetek küldését. A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás - Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat. + Csak a csoport-tulajdonosok változtathatják meg a csoport-beállításokat. Nincsenek előzmények Érvénytelen QR-kód Megjelölés olvasottként @@ -818,7 +818,7 @@ Menük és figyelmeztetések Tagok meghívása csatlakozás mint %s - Nincs kijelölve csevegés + Nincs csevegés kijelölve Csak helyi profiladatok inkognitó egy egyszer használható hivatkozáson keresztül Moderálva lett ekkor: %s @@ -874,7 +874,7 @@ Kapcsolatkérés megismétlése? Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti őket ). (24 óra) Szerepkör - SimpleX-kapcsolattartási-cím + SimpleX kapcsolattartási cím Megállítás Előre beállított kiszolgáló Új csevegés indítása @@ -913,7 +913,7 @@ Csökkentett akkumulátor-használat Mentés és az ismerősök értesítése Előnézet - Csevegés használata + Simplex Chat használata Megosztás Fogadott üzenet Üdvözlőüzenet @@ -1091,7 +1091,7 @@ Jelkód Ismeretlen hiba Saját SMP-kiszolgálójának címe - Csevegés konzol megnyitása + Csevegési konzol megnyitása Eltávolítás Adatbázis-jelmondat beállítása Biztonsági kód megtekintése @@ -1102,7 +1102,7 @@ A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása egy egyszer használható meghívó-hivatkozáson keresztül - Csoportbeállítások megadása + Csoport-beállítások megadása a következőn keresztül: %1$s igen Hangüzenet @@ -1261,7 +1261,7 @@ SimpleX Chat-kiszolgálók használatban. SimpleX Chat-kiszolgálók használata? Csevegési profil felfedése - Videók és fájlok 1Gb méretig + Videók és fájlok legfeljebb 1GB méretig TCP kapcsolat időtúllépése A(z) %1$s nevű profiljának SimpleX-címe meg lesz osztva. Ön már kapcsolódott a következőhöz: %1$s. @@ -1270,7 +1270,7 @@ Figyelmeztetés: néhány adat elveszhet! Koppintson ide az új csevegés indításához Várakozás a számítógépre… - Az üzenetküldés jövője + Az üzenetváltás jövője Hálózati beállítások megváltoztatása? Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat hitelesítése @@ -1284,7 +1284,7 @@ Alkalmazás új kapcsolatokhoz Az új üzeneteket az alkalmazás rendszeresen lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése - a kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási címhivatkozáson keresztül a SimpleX a háttérben fut a push értesítések használata helyett.]]> Az ismerősének online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt az ismerőskérelmet és eltávolíthatja az ismerőst (ezt később ismét megpróbálhatja egy új hivatkozással). A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonságimentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. @@ -1350,7 +1350,7 @@ 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.]]> Biztonságos kvantumálló-protokollon keresztül. - - 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése. + - legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! @@ -1427,7 +1427,7 @@ Ez a funkció még nem támogatott. Próbálja meg a következő kiadásban. A bejegyzés frissítve: %s Tagok meghívásának kihagyása - Ezek felülbírálhatók az ismerős- és csoportbeállításokban. + Ezek felülbírálhatók az ismerős- és csoport-beállításokban. Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEzt később megváltoztathatja. Koppintson ide az inkognitóban való kapcsolódáshoz @@ -1450,7 +1450,7 @@ A kézbesítési jelentések engedélyezve vannak %d csoportban A szerepkör meg fog változni a következőre: %s. A csoport tagjai értesítést fognak kapni. Profil és kiszolgálókapcsolatok - Egy üzenetküldő- és alkalmazásplatform, amely védi az adatait és biztonságát. + Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. A profil aktiválásához koppintson az ikonra. A kézbesítési jelentések le vannak tiltva %d ismerősnél Munkamenet kód @@ -1475,7 +1475,7 @@ Használati útmutatóban olvasható.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban - Az előző üzenet hasító értéke különbözik. + Az előző üzenet hasítóértéke különbözik. Ezek a beállítások csak a jelenlegi csevegő-profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub tárolónkban.]]> @@ -1498,7 +1498,7 @@ Az előzmények nem lesznek elküldve az új tagok számára. Újrapróbálkozás A kamera nem elérhető - Az utolsó 100 üzenet elküldése az új tagok számára. + Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára. Az előzmények ne legyenek elküldve az új tagok számára. Vagy mutassa meg ezt a kódot Kamera hozzáférés engedélyezése @@ -1788,7 +1788,7 @@ További kiemelés 2 Alkalmazás téma Perzsa kezelőfelület - Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. + Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. Ismeretlen kiszolgálókról származó fájlok megerősítése. Javított üzenetkézbesítés Alkalmazás témájának visszaállítása @@ -1856,7 +1856,7 @@ Üzenetküldési hibák Közvetlenül küldött Összes elküldött üzenet - Proxyn keresztül küldve + Proxyn keresztül küldött SMP-kiszolgáló Statisztikagyűjtés kezdete: %s. Feltöltve @@ -1978,7 +1978,7 @@ Üzenet… Kijelölés Az üzenetek az összes tag számára moderáltként lesznek megjelölve. - Nincs kijelölve semmi + Nincs semmi kijelölve Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Törli a tagok %d üzenetét? %d kijelölve @@ -2286,7 +2286,7 @@ Hivatkozás megnyitása a csevegési listából Nem Webhivatkozás megnyitása? - Csevegési név beállítása… + Csevegés nevének beállítása… Automatikus üzenettörlés letiltása? Üzenetek törlésének letiltása Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. @@ -2295,4 +2295,10 @@ Csevegési üzenetek törlése az Ön eszközéről. Automatikus üzenettörlés módosítása? Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + A(z) %1$s port használata, amikor nincs port megadva. + TCP-port az üzenetváltáshoz + Webport használata + Olvasatlan megemlítések + Összes némítása + Legfeljebb %1$s tagot említhet meg egy üzenetben! 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 840ca777ce..fca0124b30 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2285,4 +2285,25 @@ Hanya pengirim dan moderator dapat melihat Hanya Anda dan moderator dapat melihat Spam + Setel nama obrolan… + Buka tautan peramban? + Buka tautan + Ubah hapus pesan otomatis? + Matikan hapus pesan otomatis? + Pesan dalam obrolan ini tidak akan pernah dihapus. + Hapus pesan obrolan dari perangkat Anda. + Matikan hapus pesan + Tindakan tak dapat dibatalkan - pesan yang dikirim dan diterima dalam obrolan ini sebelum yang dipilih akan dihapus. + Tanya + Tidak + Buka tautan dari daftar obrolan + Ya + 1 tahun + bawaan (%s) + Bisukan semua + Sebutan belum terbaca + Port TCP untuk pesan + Gunakan port TCP %1$s jika tidak ada port yang ditentukan. + Gunakan port peramban + Anda dapat menyebut hingga %1$s anggota per pesan! 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 63307330cc..efbcb1d77a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2332,4 +2332,10 @@ Elimina i messaggi di chat dal tuo dispositivo. I messaggi in questa chat non verranno mai eliminati. predefinito (%s) + Usa la porta TCP %1$s quando nessuna porta è specificata. + Porta TCP per i messaggi + Usa porta web + Silenzia tutto + Menzioni non lette + Puoi menzionare fino a %1$s membri per messaggio! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index b915586b90..410c65b2de 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -1491,4 +1491,14 @@ 연결에 암호화 재협상이 필요합니다. 암호화 재협상이 진행 중입니다. 팀 멤버를 대화에 추가하세요. - \ No newline at end of file + 다른 이유 + 보관 + 1 보고서 + 리스트 추가 + 전부 + 보관된 멤버 리포트 + 목록 %s의 모든 차트가 제거되었고, 목록도 삭제되었습니다. + 1 년 + 리스트에 추가하기 + 보관된 보고서 + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 867638621b..71613f2d8a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1059,7 +1059,7 @@ Toegangscode ingesteld! Systeem Decodering fout - De hash van het vorige bericht is anders. + De hash van het vorige bericht is anders.\" %1$d berichten overgeslagen. Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt. Meld het alsjeblieft aan de ontwikkelaars. @@ -1832,7 +1832,7 @@ Systeem Wallpaper achtergrond Stel het standaard thema in - Toon chatlijst in nieuw venster + Toon chat-lijst in nieuw venster geen Foutopsporing bezorging Informatie over berichtenwachtrij @@ -1872,7 +1872,7 @@ Details Berichten ontvangen Bericht ontvangst - Beginnend vanaf %s. \nAlle gegevens zijn privé op uw apparaat. + Vanaf %s.\nAlle gegevens blijven privé op uw apparaat. Verbonden servers In behandeling Eerder verbonden servers @@ -2039,11 +2039,11 @@ Verwijder maximaal 20 berichten tegelijk. Sommige bestanden zijn niet geëxporteerd Alle hints resetten - Chatlijst wisselen: + Chat-lijst wisselen: U kunt dit wijzigen in de instellingen onder uiterlijk Creëren Vervagen voor betere privacy. - Afspelen via de chatlijst. + Afspelen via de chat-lijst. Download nieuwe versies van GitHub. Vergroot het lettertype. App automatisch upgraden @@ -2253,7 +2253,7 @@ Verbinding herstellen? Herstel Verbinding nog niet klaar - Fout bij het bijwerken van de chatlijst + Fout bij het bijwerken van de chat-lijst Geen chats in lijst %s. Favorieten Groepen @@ -2270,12 +2270,12 @@ De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn. Lijst opslaan Lijst - Fout bij het laden van chatlijsten + Fout bij het laden van chat-lijsten Alle chats worden verwijderd uit de lijst %s, en de lijst wordt verwijderd Toevoegen aan lijst bedrijven Contacten - Fout bij het aanmaken van chatlijst + Fout bij het aanmaken van chat-lijst Geen ongelezen chats Notities Lijst wijzigen @@ -2314,7 +2314,7 @@ Bestand is geblokkeerd door server operator:\n%1$s. Nee Link openen - Open links van chatlijst + Open links vIn de chat-lijst Vragen Rapporten Spam @@ -2329,4 +2329,10 @@ Verwijder chatberichten van uw apparaat. Berichten verwijderen uitschakelen standaard (%s) + TCP-poort voor berichtenuitwisseling + Gebruik een webpoort + Gebruik TCP-poort %1$s wanneer er geen poort is opgegeven. + Alles dempen + Ongelezen vermeldingen + Je kunt maximaal %1$s leden per bericht vermelden! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 1ebaf99ea8..8d31873b2d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -418,7 +418,7 @@ odrzucone połączenie sekret uruchamianie… - strajk + przekreślenie Brak identyfikatorów użytkownika. Następna generacja \nprywatnych wiadomości oczekiwanie na odpowiedź… 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 b592fec10c..000517d28c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -2329,4 +2329,7 @@ типово (%s) Змінити автоматичне видалення повідомлень? Цю дію не можна скасувати — повідомлення, надіслані та отримані в цьому чаті раніше за обраний час, будуть видалені. + TCP-порт для повідомлень + Використовувати TCP-порт %1$s, якщо порт не вказано. + Використовувати веб-порт diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 638c94b012..b7954bcea8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -7,7 +7,7 @@ Địa chỉ Hủy bỏ Chấp nhận - liên kết dùng một lần + đường dẫn dùng 1 lần Thêm liên hệ Thông tin về SimpleX Chat Thêm máy chủ @@ -41,7 +41,7 @@ 1 tháng 1 tuần Hủy bỏ việc đổi địa chỉ - Các quản trị viên có thể tạo liên kết để tham gia nhóm. + Các quản trị viên có thể tạo đường dẫn để tham gia nhóm. Một ngày Biến thể của màu thứ cấp Tất cả các thành viên trong nhóm vẫn sẽ được giữ kết nối. @@ -74,7 +74,7 @@ Cho phép gửi tin nhắn tự xóa. Cho phép gửi tin nhắn thoại. Cho phép thả biểu tượng cảm xúc. - Cho phép gửi các tập tin, ảnh và video. + Cho phép gửi các tệp và đa phương tiện. Luôn luôn bật Tất cả liên hệ của bạn vẫn sẽ được giữ kết nối. Tất cả liên hệ của bạn vẫn sẽ được giữ kết nối. Bản cập nhật hồ sơ sẽ được gửi tới các liên hệ của bạn. @@ -122,7 +122,7 @@ Các cuộc gọi thoại/video tất cả các thành viên Các cuộc gọi thoại/video bị cấm. - Cho phép gửi liên kết SimpleX. + Cho phép gửi đường dẫn SimpleX. quản trị viên tác giả Quá trình xác thực đã bị hủy bỏ @@ -136,7 +136,7 @@ Dịch vụ nền luôn luôn chạy - thông báo sẽ được hiển thị ngay khi nhận được tin nhắn. Tự động chấp nhận yêu cầu liên hệ Địa chỉ máy tính xấu - Thêm liên hệ: để tạo liên kết mời mới, hoặc kết nối qua liên kết bạn nhận được.]]> + Thêm liên hệ: để tạo đường dẫn mời mới, hoặc kết nối qua đường dẫn bạn nhận được.]]> hàm băm tin nhắn xấu " \nCó sẵn ở v5.1" @@ -169,7 +169,7 @@ in đậm Cả bạn và liên hệ của bạn đều có thể gửi tin nhắn tự xóa. Cả bạn và liên hệ của bạn đều có thể thả cảm xúc tin nhắn. - Xin lưu ý: relay tin nhắn và tệp được kết nối thông qua SOCKS proxy. Các cuộc gọi và bản xem trước liên kết sử dụng kết nối trực tiếp.]]> + Xin lưu ý: relay tin nhắn và tệp được kết nối thông qua SOCKS proxy. Các cuộc gọi và bản xem trước đường dẫn sử dụng kết nối trực tiếp.]]> Tốt cho pin. Ứng dụng kiểm tra tin nhắn 10 phút một lần. Bạn có thể bỏ lỡ các cuộc gọi hoặc tin nhắn khẩn cấp.]]> Luôn luôn đang gọi… @@ -200,7 +200,7 @@ Camera và mic Không thể mời liên hệ! Không thể truy cập Keystore để lưu mật khẩu cơ sở dữ liệu - hủy bỏ xem trước liên kết + hủy bỏ xem trước đường dẫn Hủy bỏ di chuyển Chủ đề ứng dụng Không thể nhận tệp @@ -302,10 +302,10 @@ đang kết nối (lời mời giới thiệu) Kết nối tới máy tính đang ở trong tình trạng không tốt Kết nối đã bị ngắt - Kết nối thông qua liên kết / mã QR + Kết nối thông qua đường dẫn / mã QR Kết nối tới chính bạn? Yêu cầu kết nối đã được gửi! - Kết nối thông qua liên kết + Kết nối thông qua đường dẫn Đang kết nối cuộc gọi Kết nối đã bị ngắt Đang kết nối tới máy tính @@ -318,7 +318,7 @@ Thời gian chờ kết nối Lỗi kết nối Kết nối - Kết nối thông qua liên kết? + Kết nối thông qua đường dẫn? kết nối %1$d kết nối đã được tạo lập Kết nối với %1$s? @@ -326,7 +326,7 @@ Kết nối đang kết nối cuộc gọi… Kết nối thông qua địa chỉ liên lạc? - Kết nối thông qua liên kết dùng một lần? + Kết nối thông qua đường dẫn dùng một lần? Liên hệ đã được kiểm tra Các liên hệ Liên hệ có cho phép @@ -354,8 +354,8 @@ Không thể gửi tin nhắn Tạo tệp Tạo một địa chỉ để cho mọi người kết nối với bạn. - Tạo liên kết nhóm - Tạo liên kết + Tạo đường dẫn nhóm + Tạo đường dẫn Được tạo ra tại Tạo địa chỉ Được tạo ra tại: %s @@ -370,8 +370,8 @@ Các màu chế độ tối Tối Lỗi nghiêm trọng - Tạo liên kết lời mời dùng một lần - Đang tạo liên kết… + Tạo đường dẫn lời mời dùng một lần + Đang tạo đường dẫn… Tạo hồ sơ Tùy chỉnh và chia sẻ các chủ đề màu sắc. Các chủ đề tùy chỉnh @@ -388,7 +388,7 @@ Tạo địa chỉ SimpleX Tạo hồ sơ Tạo hàng đợi - Tạo liên kết lưu trữ + Tạo đường dẫn lưu trữ tùy chỉnh Chủ đề tối Hạ cấp cơ sở dữ liệu @@ -447,10 +447,10 @@ Xóa nhóm Xóa kết nối đang chờ? Xóa tệp cho tất cả các hồ sơ trò chuyện - Xóa liên kết? + Xóa đường dẫn? Xóa hồ sơ Xóa cho mọi người - Xóa liên kết + Xóa đường dẫn Xóa ảnh Xóa các tin nhắn Xóa nhóm? @@ -535,12 +535,12 @@ Đã bị tắt Tắt đã bị tắt - Tin nhắn tự xóa bị cấm trong nhóm này. + Tin nhắn tự xóa bị cấm. gọi kết nối Liên hệ sẽ bị xóa - điều này không thể hoàn tác! Cuộc trò chuyện đã bị xóa! - Làm mờ phương tiện truyền thông + Làm mờ đa phương tiện Không thể gọi liên hệ Cho phép thực hiện cuộc gọi? Cuộc gọi bị cấm! @@ -597,13 +597,13 @@ Không bật Đã tải về Đang tải về bản cập nhật ứng dụng, đừng đóng ứng dụng - Các tập tin đã tải về + Các tệp đã tải về Lỗi tải về mã hóa đầu cuối Tải xuống các phiên bản mới từ GitHub. %d giây %ds - Đang tải xuống chi tiết liên kết + Đang tải xuống chi tiết đường dẫn Tên hiển thị trùng lặp! %d tuần %d tuần @@ -637,7 +637,7 @@ cho phép tái đàm phán mã hóa với %s cần tái đàm phán mã hóa cho %s Bật chỉ báo đã nhận? - Mã hóa các tệp và phương tiện được lưu trữ + Mã hóa các tệp và đa phương tiện được lưu trữ Lỗi Kết thúc cuộc gọi Bật trong cuộc trò chuyện trực tiếp (BETA)! @@ -685,12 +685,12 @@ Lỗi xóa cơ sở dữ liệu SimpleX Chat Lỗi tạo liên hệ thành viên Lỗi hủy bỏ thay đổi địa chỉ - Lỗi tạo liên kết nhóm + Lỗi tạo đường dẫn nhóm Lỗi thay đổi chức vụ Lỗi kết nối đến máy chủ chuyển tiếp %1$s. Vui lòng thử lại sau. Lỗi xóa liên hệ Lỗi xóa nhóm - Lỗi xóa liên kết nhóm + Lỗi xóa đường dẫn nhóm Lỗi xóa yêu cầu liên hệ Lỗi xóa cơ sở dữ liệu Lỗi xóa kết nối liên hệ đang chờ xử lý @@ -718,7 +718,7 @@ Lỗi kết nối lại máy chủ Lỗi Lỗi khôi phục thống kê - Lỗi cập nhật liên kết nhóm + Lỗi cập nhật đường dẫn nhóm lỗi hiển thị tin nhắn lỗi hiển thị nội dung Lỗi lưu máy chủ ICE @@ -750,13 +750,13 @@ Tập tin đã xuất không tồn tại TẬP TIN Không thể tải tin nhắn - Không tìm thấy tập tin - có thể tập tin đã bị xóa và hủy bỏ. - Lỗi tập tin + Không tìm thấy tệp - có thể tập tin đã bị xóa và hủy bỏ. + Lỗi tệp Xuất chủ đề Không thể tải tin nhắn Tập tin Nhanh chóng và không cần phải đợi người gửi hoạt động! - Không tìm thấy tập tin + Không tìm thấy tệp Tập tin: %s Tham gia nhanh chóng hơn và xử lý tin nhắn ổn định hơn. Tập tin @@ -764,14 +764,14 @@ Tập tin Lỗi máy chủ tệp: %1$s Trạng thái tệp - Tệp và phương tiện truyền thông không được cho phép - Tệp và phương tiện truyền thông bị cấm trong nhóm này. + Tệp và đa phương tiện không được cho phép + Tệp và đa phương tiện bị cấm. Tệp sẽ bị xóa khỏi máy chủ. - Tệp & phương tiện truyền thông - Tệp và phương tiện truyền thông bị cấm! + Tệp & đa phương tiện + Tệp và đa phương tiện bị cấm! Tệp sẽ được nhận khi liên hệ của bạn hoàn tất quá trình tải lên. - Tệp và phương tiện truyền thông - Tệp đã bị xóa hoặc liên kết không hợp lệ + Tệp và đa phương tiện + Tệp đã bị xóa hoặc đường dẫn không hợp lệ Tệp đã được lưu Tệp sẽ được nhận khi liên hệ của bạn hoạt động, vui lòng chờ hoặc kiểm tra lại sau! Trạng thái tệp: %s @@ -828,26 +828,26 @@ Chào buổi chiều! Giao diện tiếng Pháp Đã tìm thấy máy tính - Liên kết đầy đủ + Đầy đủ đường dẫn Từ Thư viện Tên đầy đủ: Tuyệt đối phi tập trung - chỉ hiển thị cho thành viên. Chào buổi sáng! nhóm đã bị xóa - Các thành viên nhóm có thể thả cảm xúc tin nhắn. - Các thành viên nhóm có thể xóa theo cách không thể hồi phục các tin nhắn đã gửi. (24 giờ) - Các thành viên trong nhóm có thể gửi tin nhắn thoại. + Các thành viên có thể thả cảm xúc tin nhắn. + Các thành viên có thể xóa các tin nhắn đã gửi mà không thể phục hồi lại. (24 giờ) + Các thành viên có thể gửi tin nhắn thoại. Cấp quyền - Liên kết nhóm + Đường dẫn nhóm Nhóm Tên đầy đủ nhóm: - Các thành viên trong nhóm có thể gửi tin nhắn tự xóa. - Các thành viên trong nhóm có thể gửi tệp và phương tiện truyền thông. - Các thành viên trong nhóm có thể gửi liên kết SimpleX. + Các thành viên có thể gửi tin nhắn tự xóa. + Các thành viên có thể gửi tệp và đa phương tiện. + Các thành viên có thể gửi đường dẫn SimpleX. Nhóm không hoạt động Nhóm đã tồn tại rồi! - Các thành viên trong nhóm có thể gửi tin nhắn trực tiếp. - Liên kết nhóm + Các thành viên có thể gửi tin nhắn trực tiếp. + Đường dẫn nhóm Lời mời nhóm đã hết hạn Lời mới nhóm không còn có hiệu lực, nó đã bị xóa bởi người gửi. Cho phép thực hiện cuộc gọi @@ -907,15 +907,15 @@ Ngay lập tức Hình ảnh sẽ được nhận khi liên hệ của bạn hoàn thành việc tải lên. Hình ảnh - Nếu bạn không thể gặp mặt trực tiếp, cho liên hệ của bạn xem mã QR trong một cuộc gọi video, hoặc chia sẻ liên kết. + Nếu bạn không thể gặp mặt trực tiếp, cho liên hệ của bạn xem mã QR trong một cuộc gọi video, hoặc chia sẻ đường dẫn. Hình ảnh đã được gửi Hình ảnh Hình ảnh đã được lưu vào Thư viện - Nếu bạn nhận được liên kết mời SimpleX Chat, bạn có thể mở nó trong trình duyệt của mình: - quét mã QR trong cuộc gọi video, hoặc liên hệ của bạn có thể chia sẻ một liên kết mời.]]> + Nếu bạn nhận được đường dẫn mời SimpleX Chat, bạn có thể mở nó trong trình duyệt của mình: + quét mã QR trong cuộc gọi video, hoặc liên hệ của bạn có thể chia sẻ một đường dẫn mời.]]> Nếu bạn xác nhận, các máy chủ truyền tin nhắn sẽ có thể biết địa chỉ IP, và nhà cung cấp của bạn - máy chủ nào mà bạn đang kết nối. Nếu bạn chọn từ chối người gửi sẽ KHÔNG được thông báo. - cho liên hệ của bạn xem mã QR trong cuộc gọi video, hoặc chia sẻ liên kết.]]> + cho liên hệ của bạn xem mã QR trong cuộc gọi video, hoặc chia sẻ đường dẫn.]]> Nhập Bỏ qua không hoạt động @@ -933,15 +933,15 @@ Nhập cơ sở dữ liệu Âm thanh trong cuộc gọi Nâng cao bảo mật và sự riêng tư - ẩn danh qua liên kết dùng một lần + ẩn danh qua đường dẫn dùng một lần Thông tin gián tiếp (%1$s) - Để tiếp tục, hãy ngắt kết nối tới các máy chủ dùng để truyền dẫn tin nhắn. + Để tiếp tục, hãy ngắt kết nối trò chuyện. Cài đặt SimpleX Chat cho cửa sổ câu lệnh - ẩn danh qua liên kết địa chỉ liên lạc + ẩn danh qua đường dẫn địa chỉ liên lạc Cuộc gọi thoại đến Chức vụ ban đầu - ẩn danh qua liên kết nhóm + ẩn danh qua đường dẫn nhóm Mã bảo mật không đúng! Trả lời đến Phiên bản cơ sở dữ liệu không tương thích @@ -955,16 +955,16 @@ Phiên bản không tương thích MÀU SẮC GIAO DIỆN đã được mời - Liên kết không hợp lệ + Đường dẫn không hợp lệ tác vụ trò chuyện không hợp lệ dữ liệu không hợp lệ định dạng tin nhắn không hợp lệ Mời Lời mời đã hết hạn! - Liên kết không hợp lệ! - Liên kết không hợp lệ + Đường dẫn không hợp lệ! + Đường dẫn không hợp lệ Tên hiển thị không hợp lệ! - Liên kết kết nối không hợp lệ + Đường dẫn kết nối không hợp lệ Thông báo tức thời! Mã QR không hợp lệ Thông báo tức thời @@ -1023,7 +1023,7 @@ Chế độ khóa Giao diện tiếng Ý thiết bị này v%s)]]> - Biểu đạt cảm xúc tin nhắn bị cấm trong nhóm này. + Thả biểu tượng cảm xúc tin nhắn bị cấm. đã được mời để kết nối Tìm hiểu thêm Giữ @@ -1047,17 +1047,17 @@ Trung bình Tin nhắn động Giữ lại cuộc trò chuyện - Hình ảnh xem trước của liên kết + Hình ảnh xem trước của đường dẫn Thành viên sẽ bị xóa khỏi nhóm - việc này không thể được hoàn tác! Chế độ sáng UI tiếng Litva TIN NHẮN VÀ TỆP - Việc xóa tin nhắn mà không thể phục hồi bị cấm trong nhóm này. + Việc xóa tin nhắn mà không thể phục hồi là bị cấm. Tham gia vào các cuộc trò chuyện nhóm Chế độ định tuyến tin nhắn Hãy trò chuyện trên SimpleX Chat in nghiêng - Nó có thể được thay đổi sau trong phần cài đặt. + Nó ảnh hưởng đến pin như thế nào Việc xóa tin nhắn mà không thể phục hồi bị cấm trong cuộc trò chuyện này. Biểu đạt cảm xúc tin nhắn bị cấm trong cuộc trò chuyện này. Bản nháp tin nhắn @@ -1066,7 +1066,7 @@ Tin nhắn Lỗi keychain đã rời - đã được mời thông qua liên kết nhóm của bạn + đã được mời thông qua đường dẫn nhóm của bạn thành viên đã rời Nó cho phép việc có các kết nối ẩn danh mà không có bất kỳ dữ liệu chung nào giữa chúng trong một hồ sơ trò chuyện @@ -1081,7 +1081,7 @@ Xóa tin nhắn mà không thể phục hồi Tin nhắn Máy chủ tin nhắn - Máy chủ tệp và phương tiện + Máy chủ tệp và đa phương tiện Nội dung tin nhắn Tin nhắn đã bị xóa sau khi bạn chọn chúng. Tin nhắn từ %s sẽ được hiển thị! @@ -1147,7 +1147,7 @@ Chức vụ thành viên mới Nhiều cải tiến hơn nữa sắp ra mắt! %s có một phiên bản không được hỗ trợ. Xin vui lòng đảm bảo rằng bạn dùng cùng một phiên bản trên cả hai thiết bị.]]> - Các tùy chọn phương tiện mới + Các tùy chọn đa phương tiện mới Cuộc gọi nhỡ Nhiều cải tiến hơn nữa sắp ra mắt! Di chuyển sang một thiết bị khác qua mã QR. @@ -1178,13 +1178,13 @@ Không có gì để chuyển tiếp! Thông báo sẽ dừng hoạt động cho đến khi bạn khởi động lại ứng dụng Không có thông tin định danh người dùng. - không có nội dung + không có văn bản tắt tắt` Chỉ bạn mới có thể thực hiện cuộc gọi. Chỉ liên hệ của bạn mới có thể thả cảm xúc tin nhắn. Chỉ có thể gửi 10 video cùng một lúc - Liên kết lời mời dùng một lần + Đường dẫn lời mời dùng một lần (chỉ được lưu trữ bởi thành viên nhóm) Xem trước thông báo Dịch vụ thông báo @@ -1192,7 +1192,7 @@ Không có tệp nào được gửi hay được nhận quan sát viên Không có kết nối mạng - Chỉ chủ nhóm mới có thể bật tính năng cho phép gửi tệp và phương tiện. + Chỉ chủ nhóm mới có thể bật tính năng cho phép gửi tệp và đa phương tiện. Chỉ có thể gửi 10 hình ảnh cùng một lúc Chỉ một thiết bị mới có thể hoạt động cùng một lúc Không tương thích! @@ -1200,10 +1200,10 @@ OK Chỉ xóa cuộc trò chuyện Chỉ bạn mới có thể gửi tin nhắn thoại. - bảo mật đầu cuối 2 lớp.]]> + Chỉ thiết bị cuối mới lưu trữ các hồ sơ người dùng, liên hệ, nhóm, và tin nhắn Chỉ chủ nhóm mới có thể bật tính năng tin nhắn thoại. Chỉ bạn mới có thể gửi tin nhắn tự xóa. - Liên kết lời mời dùng một lần + Đường dẫn lời mời dùng một lần Chỉ có bạn mới có thể thả cảm xúc tin nhắn. Thông báo sẽ chỉ được gửi cho đến khi ứng dụng dừng! Chỉ bạn mới có thể xóa tin nhắn mà không thể phục hồi (liên hệ của bạn có thể đánh dấu chúng để xóa). (24 giờ) @@ -1212,7 +1212,7 @@ được đề nghị %s: %2s Tắt Chỉ chủ nhóm mới có thể điều chỉnh các tùy chọn nhóm. - Giờ thì quản trị viên có thể:\n- xóa tin nhắn của thành viên\n- vô hiệu hóa thành viên (quyền hạn quan sát viên) + Giờ thì quản trị viên có thể:\n- xóa tin nhắn của thành viên\n- vô hiệu hóa thành viên (chức vụ quan sát viên) Không có cuộc trò chuyện nào được chọn Không có gì được chọn Mở @@ -1228,7 +1228,7 @@ Chỉ liên hệ của bạn mới có thể gửi tin nhắn thoại. chủ sở hữu Mở cài đặt máy chủ - Mở liên kết trong trình duyệt có thể làm giảm sự riêng tư và bảo mật của kết nối. Liên kết SimpleX không đáng tin cậy sẽ được đánh dấu màu đỏ. + Mở đường dẫn trong trình duyệt có thể làm giảm sự riêng tư và bảo mật của kết nối. Đường dẫn SimpleX không đáng tin cậy sẽ được đánh dấu màu đỏ. Chỉ liên hệ của bạn mới có thể xóa tin nhắn mà không thể phục hồi (bạn có thể đánh dấu chúng để xóa). (24 giờ) Mở cài đặt ứng dụng Mục mã truy cập @@ -1244,7 +1244,7 @@ Mở SimpleX Chat để chấp nhận cuộc gọi Mở Cài đặt Safari / Trang Web / Mic, rồi chọn Cho phép với localhost. Mã truy cập - Mở cuộc hội thoại + Mở kết nối trò chuyện Mở vị trí tệp Sử dụng từ máy tính trong ứng dụng di động và quét mã QR.]]> Hoặc dán đường dẫn lưu trữ @@ -1256,7 +1256,7 @@ các lỗi khác Các máy chủ SMP khác Các máy chủ XFTP khác - Dán đường dẫn mà bạn nhận được để kết nối với liên hệ hệ của bạn… + Dán đường dẫn mà bạn nhận được để kết nối với liên hệ của bạn… Dán đường dẫn Mật khẩu Định kỳ @@ -1285,7 +1285,7 @@ Xin vui lòng nhập đúng mật khẩu hiện tại. Mở từ danh sách cuộc trò chuyện. Cuộc gọi hình trong hình - Xin vui lòng báo cáo với các nhà phát triển:\n%s + Xin vui lòng báo cáo với các nhà phát triển: \n%s Thông báo định kỳ đã bị tắt! Xin vui lòng kiểm tra rằng thiết bị di động và máy tính kết nối tới cùng một mạng cục bộ, và tường lửa của máy tính cho phép kết nối.\nHãy chia sẻ bất kỳ vấn đề nào khác với nhà phát triển. Xin vui lòng kiểm tra rằng bạn đã dùng đúng đường dẫn hoặc yêu cầu liên hệ của bạn gửi cho bạn một đường dẫn khác. @@ -1296,12 +1296,12 @@ Xin vui lòng nhập mật khẩu trước đó sau khi khôi phục bản sao lưu cơ sở dữ liệu. Việc này không thể được hoàn tác. Xin vui lòng liên lạc với quản trị viên nhóm. Xin vui lòng báo cáo với các nhà phát triển. - Xin vui lòng báo cáo tới các nhà phát triển:\n%s\n\nGợi ý rằng bạn nên khởi động lại ứng dụng. + Xin vui lòng báo cáo với các nhà phát triển: \n%s\n\nGợi ý rằng bạn nên khởi động lại ứng dụng. Có lẽ vân tay chứng chỉ trong địa chỉ máy chủ là không chính xác Đang chuẩn bị tải lên Cổng cổng %d - Xin vui lòng lưu trữ mật khẩu một cách an toàn, bạn sẽ KHÔNG thể trò chuyện nếu bạn làm mất nó. + Xin vui lòng lưu trữ mật khẩu một cách an toàn, bạn sẽ KHÔNG thể try cập kết nối trò chuyện nếu bạn làm mất nó. Xin vui lòng chờ trong khi tệp đang được tải từ thiết bị được liên kết Địa chỉ máy chủ cài sẵn Lưu lại bản nháp tin nhắn cuối cùng, với các tệp đính kèm. @@ -1345,12 +1345,12 @@ Thời gian chờ giao thức Cấm gửi tin nhắn tự xóa. Bảo vệ màn hình ứng dụng - Cấm gửi tệp và phương tiện truyền thông. + Cấm gửi tệp và đa phương tiện. Cấm gửi tin nhắn thoại. Cấm gửi tin nhắn tự xóa. Cấm gửi đường dẫn SimpleX Được proxy - Địa chỉ hay đường dẫn dùng một lần? + Địa chỉ hay đường dẫn dùng 1 lần? với chỉ một liên hệ - chia sẻ trực tiếp hoặc thông qua bất kỳ ứng dụng tin nhắn nào.]]> Cài đặt máy chủ .onion thành Không nếu proxy SOCKS không hỗ trợ chúng.]]> %s.]]> @@ -1360,7 +1360,7 @@ Thanh công cụ ứng dụng Làm mờ Chấp nhận điều kiện - Đã thêm các máy chủ truyền tệp & phương tiện + Đã thêm các máy chủ truyền tệp & đa phương tiện Đã chấp nhận điều kiện Cài đặt địa chỉ %s.]]> @@ -1381,7 +1381,7 @@ %1$s!]]> %1$s rồi.]]> Mở trong ứng dụng di động.]]> - Chọn nhà cung cấp + Các bên vận hành máy chủ kết nối với các nhà phát triển SimpleX Chat để hỏi bất kỳ câu hỏi nào và nhận thông tin cập nhật.]]> không được sử dụng cùng một cơ sở dữ liệu trên hai thiết .]]> Lỗi chấp nhận điều kiện @@ -1476,7 +1476,7 @@ Không có máy chủ để gửi tệp. Không có dịch vụ nền Máy chủ mới - Không có máy chủ tệp và phương tiện. + Không có máy chủ tệp và đa phương tiện. Thanh công cụ trò chuyện trong tầm Ngẫu nhiên Các máy chủ cài sẵn @@ -1488,7 +1488,7 @@ Mã hóa kháng lượng tử Sự riêng tư cho các khách hàng của bạn. Xin vui lòng giảm kích thước tin nhắn và gửi lại. - Xin vui lòng giảm kích thước tin nhắn hoặc xóa phương tiện và gửi lại. + Xin vui lòng giảm kích thước tin nhắn hoặc xóa đa phương tiện và gửi lại. Xác thực proxy Mã QR Mở bằng %s @@ -1601,7 +1601,7 @@ Quét mã QR từ máy tính Đã được bảo mật Thanh tìm kiếm chấp nhận đường dẫn lời mời. - Củng cố hàng đợi + Rào hàng đợi Gửi lỗi Lưu Chọn @@ -1758,7 +1758,7 @@ Chia sẻ với các liên hệ Chia sẻ tệp… Chia sẻ địa chỉ - Chia sẻ nội dung đa phương tiện… + Chia sẻ đa phương tiện… Chia sẻ hồ sơ Hiển thị tùy chọn cho nhà phát triển Tạo khuôn ảnh đại diện @@ -1869,10 +1869,10 @@ 1 báo cáo %d báo cáo Các báo cáo của thành viên - Đã lưu trữ các báo cáo của thành viên + Các báo cáo thành viên đã được lưu trữ Lỗi đăng ký dài hạn Các báo cáo - Bắt đầu từ %s.\nTất cả dữ liệu được lưu trữ một cách riêng tư trên thiết bị của bạn.. + Bắt đầu từ %s.\nTất cả dữ liệu được lưu trữ một cách riêng tư trên thiết bị của bạn. Hỗ trợ bluetooth và nhiều cải tiến khác. Các đăng ký dài hạn bị bỏ qua Dừng nhận tệp? @@ -1909,7 +1909,7 @@ Nhấn để tham gia một cách ẩn danh Hệ thống Chuyển đổi âm thanh và video trong lúc gọi. - Chuyển đổi hồ sơ trò chuyện cho các lời mời dùng một lần. + Chuyển đổi hồ sơ trò chuyện cho các lời mời dùng 1 lần. Kiểm tra thất bại tại bước %s. Xác thực hệ thống Nhấn để quét @@ -1943,7 +1943,7 @@ Ứng dụng bảo vệ sự riêng tư của bạn bằng cách sử dụng các bên vận hành khác nhau trong mỗi cuộc trò chuyện. Ứng dụng có thể bị đóng sau 1 phút chạy trong nền. Ứng dụng tìm nhận tin nhắn mới một cách định kỳ - nó tiêu tốn một vài phần trăm pin mỗi ngày. Ứng dụng không sử dụng thông báo đẩy - dữ liệu ở thiết bị của bạn không được gửi đi tới máy chủ nào. - Mã mà bạn đã quét không phải là một mã QR liên kết SimpleX. + Mã mà bạn đã quét không phải là một mã QR dẫn SimpleX. Chủ đề Mã băm của tin nhắn trước có sự khác biệt.\" ID của tin nhắn tiếp theo là không chính xác (nhỏ hơn hoặc bằng với cái trước).\nViệc này có thể xảy ra do một vài lỗi hoặc khi kết nối bị xâm phạm. @@ -1958,7 +1958,7 @@ Video không thể được giải mã. Xin vui lòng thử với một video khác hoặc liên lạc với các nhà phát triển. Tin nhắn sẽ bị đánh dấu là đã được kiểm duyệt cho tất cả các thành viên. Tin nhắn sẽ bị xóa cho tất cả các thành viên. - Việc này không thể được hoàn tác - tất cả các nội dung đa phương tiện và tệp đã được gửi và nhận sẽ bị xóa. Những hình ảnh chất lượng thấp sẽ được giữ lại. + Việc này không thể được hoàn tác - tất cả các đa phương tiện và tệp đã được gửi và nhận sẽ bị xóa. Những hình ảnh chất lượng thấp sẽ được giữ lại. Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận sớm hơn so với thời gian được chọn sẽ bị xóa. Có thể mất vài phút để hoàn thành. Nhóm này không còn tồn tại. Nhóm này có trên %1$d thành viên, chỉ báo đã nhận không được gửi. @@ -1978,7 +1978,7 @@ Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong nhóm sẽ được thông báo. Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. - Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể không thể khôi phục. + Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. Những máy chủ cho các kết nối mới của hồ sơ trò chuyện hiện tại của bạn Báo cáo sẽ được lưu trữ cho bạn. Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. @@ -2093,7 +2093,7 @@ Sử dụng hồ sơ ẩn danh mới thông qua %1$s Sử dụng proxy SOCKS - Sử dụng trò chuyện + Sử dụng SimpleX Chat Sử dụng cho các kết nối mới Sử dụng máy chủ Sử dụng proxy SOCKS? @@ -2185,7 +2185,7 @@ Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các relay XFTP sau đây:\n%1$s. Các máy chủ XFTP Khi có nhiều hơn một bên vận hành được kích hoạt, không ai trong số họ có siêu dữ liệu để biết được ai trò chuyện với ai. - Với các tệp và nội dung đa phương tiện được mã hóa. + Với các tệp và đa phương tiện được mã hóa. Mức sử dụng pin đã được giảm xuống. Mức sử dụng pin đã được giảm xuống. Với lời chào tùy chọn. @@ -2224,4 +2224,89 @@ Bạn đã chấp nhận kết nối bạn bạn được mời vào nhóm + Các cuộc gọi của bạn + Bạn có thể bật Khóa SimpleX thông qua Cài đặt. + Bạn không có cuộc trò chuyện nào + Bạn vẫn có thể xem cuộc hội thoại với %1$s trong danh sách các cuộc trò chuyện. + Bạn đã tham gia nhóm này + Bạn đã tham gia nhóm này. Đang kết nối tới thành viên nhóm đi mời. + Bạn đã không thể được xác minh; xin vui lòng thử lại. + bạn đã thay đổi chức vụ của mình thành %s + Bạn có thể lưu bản lưu trữ đã được xuất. + Cơ sở dữ liệu trò chuyện của bạn không được mã hóa - đặt mật khẩu để bảo vệ nó. + Bạn phải nhập mật khẩu mỗi lần ứng dụng khởi chạy - nó không được lưu trên thiết bị của bạn. + bạn đã rời + Bạn cần cho phép liên hệ của mình thực hiện cuộc gọi để có thể gọi cho họ. + bạn đã thay đổi chức vụ của %s thành %s + bạn đã thay đổi địa chỉ + Bạn đã yêu cầu kết nối thông qua địa chỉ này rồi! + Bạn đã mời một liên hệ + Bạn có thể chia sẻ địa chỉ của mình dưới dạng một đường dẫn hoặc mã QR - bất kỳ ai cũng có thể kết nối với bạn. + Bạn có thể xem đường dẫn mời lần nữa trong chi tiết kết nối. + Bạn kiểm soát cuộc trò chuyện của mình! + Bạn không thể gửi tin nhắn! + Bạn có thể chia sẻ địa chỉ này với các liên hệ của mình để họ kết nối với %s. + Bạn cần cho phép liên hệ của mình gửi tin nhắn thoại để có thể gửi cho họ. + Bạn có thể di chuyển cơ sở dữ liệu đã được xuất. + Bạn CHỈ nên sử dụng phiên bản gần nhất của cơ sở dữ liệu trò chuyện của mình trên một thiết bị mà thôi, nếu không thì bạn có thể dừng nhận tin nhắn từ một vài liên hệ. + bạn đã thay đổi địa chỉ cho %s + Bạn có thể sử dụng markdown để định dạng tin nhắn: + Bạn có thể bắt đầu kết nối trò chuyện thông qua phần Cài đặt / Cơ sở dữ liệu ở trên ứng dụng hoặc bằng cách khởi động lại ứng dụng. + Bạn quyết định ai có thể kết nối tới. + Cơ sở dữ liệu trò chuyện của bạn + Sự riêng tư của bạn + Cơ sở dữ liệu trò chuyện hiện tại của bạn sẽ bị XÓA và THAY THẾ bằng cái được nhập vào.\nViệc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. + bạn đã xóa %1$s + Các liên hệ của bạn vẫn sẽ được kết nối. + Hồ sơ, các liên hệ và những tin nhắn đã được gửi của bạn được lưu trữ trên thiết bị bạn dùng. + Các liên hệ của bạn có thể cho phép xóa tin nhắn hoàn toàn. + Thu phóng + Các máy chủ SMP của bạn + Bạn đã chia sẻ đường dẫn dùng một lần + Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên nhóm + Hồ sơ hiện tại của bạn + Bạn đang dùng một hồ sơ ẩn danh cho nhóm này - để tránh khỏi chia sẻ hồ sơ chính của mình với các liên hệ đi mời mà không được cho phép + Các tùy chọn của bạn + Hồ sơ ngẫu nhiên của bạn + Bạn đã gửi lời mời nhóm + Bạn đã từ chối lời mời nhóm + bạn đã bỏ chặn %s + Hồ sơ trò chuyện của bạn sẽ được gửi\ntới liên hệ của bạn + bạn đã chia sẻ đường dẫn ẩn danh dùng một lần + Địa chỉ SimpleX của bạn + Liên hệ của bạn cần phải trực tuyến để cho kết nối hoàn thành.\nBạn có thể hủy kết nối này và xóa liên hệ (và thử lại sau với một đường dẫn mới). + Các máy chủ ICE của bạn + Bạn đang cố mời liên hệ mà bạn đã chia sẻ một hồ sơ ẩn danh với để tham gia nhóm mà bạn đang dùng hồ sơ chính của mình + Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên có liên lạc + Bạn sẽ được kết nối khi yêu cầu kết nối của bạn được chấp nhận, xin vui lòng đợi hoặc kiểm tra sau! + Hồ sơ %1$s sẽ được chia sẻ. + Các máy chủ XFTP của bạn + Hồ sơ của bạn sẽ được gửi tới liên hệ mà bạn đã nhận từ người đó đường dẫn này. + Bạn sẽ kết nối với tất cả các thành viên nhóm. + Bạn đã chia sẻ một đường dẫn tệp không hợp lệ. Báo cáo vấn đề tới các nhà phát triển ứng dụng. + Các máy chủ của bạn + Bạn vẫn sẽ nhận các cuộc gọi và thông báo từ các hồ sơ đã bị tắt thông báo khi chúng hoạt động. + Yêu cầu kết nối sẽ được gửi tới thành viên nhóm này. + Bạn sẽ dừng nhận tin nhắn từ cuộc hội thoại này. Lịch sử trò chuyện sẽ được giữ lại. + Bạn sẽ dừng nhận tin nhắn từ nhóm này. Lịch sử trò chuyện sẽ được giữ lại. + Liên hệ của bạn đã gửi một tệp có kích thước lớn hơn so với kích thước tối đa hiện đang được hỗ trợ (%1$s). + Bạn sẽ không mất các liên hệ của mình nếu bạn sau đó xóa địa chỉ của mình đi. + Các liên hệ của bạn + Các hồ sơ trò chuyện của bạn + Cài đặt của bạn + Máy chủ của bạn + Địa chỉ máy chủ của bạn + Các máy chủ ICE của bạn + Thông tin định danh của bạn có thể bị gửi mà không được mã hóa. + Hồ sơ của bạn được lưu trên thiết bị bạn dùng và chỉ được chia sẻ với các liên hệ bạn có. Các máy chủ SimpleX không thể xem hồ sơ của bạn. + Kết nối của bạn đã bị chuyển tới %s nhưng một lỗi không mong muốn đã xảy ra trong khi chuyển hướng bạn đến hồ sơ. + Bạn sẽ được kết nối tới nhóm khi thiết bị của chủ nhóm trực tuyến, xin vui lòng đợi hoặc kiểm tra sau! + Bạn sẽ được kết nối khi thiết bị của liên hệ bạn trực tuyến, xin vui lòng đợi hoặc kiểm tra sau! + Bạn sẽ được yêu cầu xác thực khi bạn khởi động hoặc tiếp tục ứng dụng sau 30 giây trong nền. + Cổng TCP để nhắn tin + Sử dụng cổng TCP %1$s khi không có cổng nào được chỉ định. + Sử dụng cổng web + Bạn có thể nhắc đến tối đa là %1$s thành viên trong một tin nhắn! + Tắt thông báo tất cả + Những lời nhắc chưa đọc 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 a7938d97d4..8c9d0429d2 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 @@ -1917,7 +1917,7 @@ 重置 服务器地址不兼容网络设置:%1$s。 起始自 %s。 - 自 %s 起 \n所有数据均私密地保存在你的设备上.. + 自 %s 起 \n所有数据均私密地保存在你的设备上. 已订阅 已认可 服务器版本不兼容你的应用:%1$s. @@ -2316,4 +2316,10 @@ 此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。 此聊天中的消息永远不会被删除。 更改消息自动删除设置? + 用于消息收发的 TCP 端口 + 未指定端口时,使用 TCP 端口 %1$s。 + 使用 web 端口 + 全部静音 + 取消提及的已读状态 + 每条消息最多提及 %1$s 名成员! From c5bb2c4ca2a96f28d4422a0b8d9e47ec100c0efb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 10 Feb 2025 09:13:23 +0000 Subject: [PATCH 052/289] website: update livestream link --- website/src/livestream.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/livestream.html b/website/src/livestream.html index c95660da21..ee7a96ab30 100644 --- a/website/src/livestream.html +++ b/website/src/livestream.html @@ -2,7 +2,7 @@ layout: layouts/group_link.html title: "SimpleX Chat: Power to the People" description: "Join the group for livestream Q&A" -groupLink: "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FM1UImZkstXUcr3UPsO8Xe0swAkleCBD4%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEA4lkP2FVVIBBbSdP4GX14FEPJFXgfmGSDbutQJ0vl3Bk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22groupLinkId%22%3A%22N2tlDw-owTy_Yi05iBaZlw%3D%3D%22%7D" +groupLink: "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FSkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w%3D%40smp9.simplex.im%2FoVQ-kg2rjMRituleO6t26DhQDPW6OjLL%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEATIRrsU4GwjpF6SeMWa6Li20Rkibgu4ozZMADZfdAZzE%253D%26srv%3Djssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion" groupLinkText: Open Livestream Q&A group link templateEngineOverride: njk --- \ No newline at end of file From e7361cf0253e8216bdd8998a1fa2001a4aa3b74f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 10 Feb 2025 22:07:14 +0700 Subject: [PATCH 053/289] ui: archive multiple reports (#5619) * android, desktop: archive multiple reports * ios * change * changes * fix changing counter * fix changing counter2 * fix changing counter3 * unused * fix android * android notification * simplify * ios notification * orange * orange * core: update api * buttons * ios api * android api * fix 4 buttons * buttons and check for member active status * android colors and member active * show delete group button when not in the group anymore * title --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 86 +++++++----- apps/ios/Shared/Views/Chat/ChatView.swift | 82 ++++++++++-- .../Chat/SelectableChatItemToolbars.swift | 21 ++- .../Views/ChatList/ChatListNavLink.swift | 75 ++++++++--- .../ios/SimpleX NSE/NotificationService.swift | 7 +- apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 7 - apps/ios/SimpleXChat/Notifications.swift | 7 +- .../chat/simplex/common/model/ChatModel.kt | 4 +- .../chat/simplex/common/model/SimpleXAPI.kt | 123 +++++++++++------- .../simplex/common/platform/NtfManager.kt | 7 +- .../simplex/common/views/chat/ChatView.kt | 64 ++++++++- .../views/chat/SelectableChatItemToolbars.kt | 10 +- .../common/views/chat/item/ChatItemView.kt | 57 ++++++-- .../views/chatlist/ChatListNavLinkView.kt | 37 ++++++ .../commonMain/resources/MR/base/strings.xml | 7 + src/Simplex/Chat/Library/Commands.hs | 4 +- tests/ChatTests/Groups.hs | 6 +- 19 files changed, 465 insertions(+), 147 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8b84999577..90c95f4fc5 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -918,7 +918,7 @@ final class ChatModel: ObservableObject { } func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) { - changeGroupReportsCounter(chatId, -1) + changeGroupReportsCounter(chatId, -by) } private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 515b39284c..3418531d3d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -516,6 +516,18 @@ func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> throw r } +func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse { + let r = await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay) + if case .groupChatItemsDeleted = r { return r } + throw r +} + +func apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { + let r = await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay) + if case let .chatItemsDeleted(_, chatItemDeletions, _) = r { return chatItemDeletions } + throw r +} + func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) { let r = chatSendCmdSync(.apiGetNtfToken) switch r { @@ -2134,42 +2146,13 @@ func processReceivedMsg(_ res: ChatResponse) async { } else { m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem) } + if item.deletedChatItem.chatItem.isActiveReport { + m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id) + } } } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): - if !active(user) { - do { - let users = try listUsers() - await MainActor.run { - m.users = users - } - } catch { - logger.error("Error loading users: \(error)") - } - return - } - let im = ItemsModel.shared - let cInfo = ChatInfo.group(groupInfo: groupInfo) - await MainActor.run { - m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) - } - var notFound = chatItemIDs.count - for ci in im.reversedChatItems { - if chatItemIDs.contains(ci.id) { - let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { - CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) - } else { - CIDeleted.deleted(deletedTs: Date.now) - } - await MainActor.run { - var newItem = ci - newItem.meta.itemDeleted = deleted - _ = m.upsertChatItem(cInfo, newItem) - } - notFound -= 1 - if notFound == 0 { break } - } - } + await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) case let .receivedGroupInvitation(user, groupInfo, _, _): if active(user) { await MainActor.run { @@ -2512,6 +2495,43 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async { } } +func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemIDs: Set, _ member_: GroupMember?) async { + let m = ChatModel.shared + if !active(user) { + do { + let users = try listUsers() + await MainActor.run { + m.users = users + } + } catch { + logger.error("Error loading users: \(error)") + } + return + } + let im = ItemsModel.shared + let cInfo = ChatInfo.group(groupInfo: groupInfo) + await MainActor.run { + m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) + } + var notFound = chatItemIDs.count + for ci in im.reversedChatItems { + if chatItemIDs.contains(ci.id) { + let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { + CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) + } else { + CIDeleted.deleted(deletedTs: Date.now) + } + await MainActor.run { + var newItem = ci + newItem.meta.itemDeleted = deleted + _ = m.upsertChatItem(cInfo, newItem) + } + notFound -= 1 + if notFound == 0 { break } + } + } +} + func refreshCallInvitations() async throws { let m = ChatModel.shared let callInvitations = try await apiGetCallInvitations() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 8c8e98c2f1..e83391e386 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -46,6 +46,7 @@ struct ChatView: View { @State private var forwardedChatItems: [ChatItem] = [] @State private var selectedChatItems: Set? = nil @State private var showDeleteSelectedMessages: Bool = false + @State private var showArchiveSelectedReports: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @@ -100,6 +101,9 @@ struct ChatView: View { allowToDeleteSelectedMessagesForAll = forAll showDeleteSelectedMessages = true }, + archiveItems: { + showArchiveSelectedReports = true + }, moderateItems: { if case let .group(groupInfo) = chat.chatInfo { showModerateSelectedMessagesAlert(groupInfo) @@ -135,6 +139,20 @@ struct ChatView: View { } } } + .confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) { + Button("For me", role: .destructive) { + if let selected = selectedChatItems { + archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages) + } + } + 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) + } + } + } + } .appSheet(item: $selectedMember) { member in Group { if case let .group(groupInfo) = chat.chatInfo { @@ -914,6 +932,8 @@ struct ChatView: View { @State private var showDeleteMessage = false @State private var deletingItems: [Int64] = [] @State private var showDeleteMessages = false + @State private var archivingReports: Set? = nil + @State private var showArchivingReports = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var msgWidth: CGFloat = 0 @@ -1233,6 +1253,22 @@ struct ChatView: View { deleteMessages(chat, deletingItems, moderate: false) } } + .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { + Button("For me", role: .destructive) { + if let reports = self.archivingReports { + archiveReports(chat.chatInfo, reports.sorted(), false) + self.archivingReports = [] + } + } + 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) + self.archivingReports = [] + } + } + } + } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { @@ -1694,18 +1730,9 @@ struct ChatView: View { } private func archiveReportButton(_ cItem: ChatItem) -> Button { - Button(role: .destructive) { - AlertManager.shared.showAlert( - Alert( - title: Text("Archive report?"), - message: Text("The report will be archived for you."), - primaryButton: .destructive(Text("Archive")) { - deletingItem = cItem - deleteMessage(.cidmInternalMark, moderate: false) - }, - secondaryButton: .cancel() - ) - ) + Button { + archivingReports = [cItem.id] + showArchivingReports = true } label: { Label("Archive report", systemImage: "archivebox") } @@ -1936,6 +1963,37 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe } } +func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) { + if itemIds.count > 0 { + Task { + do { + let deleted = try await apiDeleteReceivedReports( + groupId: chatInfo.apiId, + itemIds: itemIds, + mode: forAll ? CIDeleteMode.cidmBroadcast : CIDeleteMode.cidmInternalMark + ) + + await MainActor.run { + for di in deleted { + if let toItem = di.toChatItem { + _ = ChatModel.shared.upsertChatItem(chatInfo, toItem.chatItem) + } else { + ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) + } + let deletedItem = di.deletedChatItem.chatItem + if deletedItem.isActiveReport { + ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id) + } + } + } + await onSuccess() + } catch { + logger.error("ChatView.archiveReports error: \(error.localizedDescription)") + } + } + } +} + 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 { diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 81498ee497..85d6b279c5 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -30,12 +30,15 @@ struct SelectedItemsBottomToolbar: View { var chatInfo: ChatInfo // Bool - delete for everyone is possible var deleteItems: (Bool) -> Void + var archiveItems: () -> Void var moderateItems: () -> Void //var shareItems: () -> Void var forwardItems: () -> Void @State var deleteEnabled: Bool = false @State var deleteForEveryoneEnabled: Bool = false + @State var canArchiveReports: Bool = false + @State var canModerate: Bool = false @State var moderateEnabled: Bool = false @@ -50,7 +53,11 @@ struct SelectedItemsBottomToolbar: View { HStack(alignment: .center) { Button { - deleteItems(deleteForEveryoneEnabled) + if canArchiveReports { + archiveItems() + } else { + deleteItems(deleteForEveryoneEnabled) + } } label: { Image(systemName: "trash") .resizable() @@ -109,19 +116,25 @@ 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 { + info + } else { + nil + } if let selected = selectedItems { let me: Bool let onlyOwnGroupItems: Bool - (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in + (deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in if selected.contains(ci.id) { - var (de, dee, me, onlyOwnGroupItems, fe, sel) = r + var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r de = de && ci.canBeDeletedForSelf dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport + ar = ar && ci.isActiveReport && ci.chatDir != .groupSnd && groupInfo != nil && groupInfo!.membership.memberRole >= .moderator onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list - return (de, dee, me, onlyOwnGroupItems, fe, sel) + return (de, dee, ar, me, onlyOwnGroupItems, fe, sel) } else { return r } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 39268d4727..e3aad890bb 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -211,23 +211,32 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { tagChatButton(chat) + 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 totalNumberOfButtons = 1 + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) + let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) - if showClearButton, totalNumberOfButtons <= 3 { + if showClearButton && totalNumberOfButtons <= 3 { clearChatButton() } - if (showLeaveGroup) { + + if showReportsButton && totalNumberOfButtons <= 3 { + archiveAllReportsButton() + } + + if showLeaveGroup { leaveGroupChatButton(groupInfo) } - - if showDeleteGroup { - if totalNumberOfButtons <= 3 { + + if showDeleteGroup && totalNumberOfButtons <= 3 { + deleteGroupChatButton(groupInfo) + } else if totalNumberOfButtons > 3 { + if showDeleteGroup && !groupInfo.membership.memberActive { deleteGroupChatButton(groupInfo) + moreOptionsButton(false, chat, groupInfo) } else { - moreOptionsButton(chat, groupInfo) + moreOptionsButton(true, chat, groupInfo) } } } @@ -313,6 +322,14 @@ struct ChatListNavLink: View { } } + private func archiveAllReportsButton() -> some View { + Button { + AlertManager.shared.showAlert(archiveAllReportsAlert()) + } label: { + SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI) + } + } + private func clearChatButton() -> some View { Button { AlertManager.shared.showAlert(clearChatAlert()) @@ -354,15 +371,20 @@ struct ChatListNavLink: View { ) } - private func moreOptionsButton(_ chat: Chat, _ groupInfo: GroupInfo?) -> some View { + private func moreOptionsButton(_ canShowGroupDelete: Bool, _ chat: Chat, _ groupInfo: GroupInfo?) -> some View { Button { - var buttons: [Alert.Button] = [ - .default(Text("Clear")) { - AlertManager.shared.showAlert(clearChatAlert()) - } - ] - - if let gi = groupInfo, gi.canDelete { + var buttons: [Alert.Button] = [] + buttons.append(.default(Text("Clear")) { + AlertManager.shared.showAlert(clearChatAlert()) + }) + + if let groupInfo, chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator && groupInfo.ready { + buttons.append(.default(Text("Archive reports")) { + AlertManager.shared.showAlert(archiveAllReportsAlert()) + }) + } + + if canShowGroupDelete, let gi = groupInfo, gi.canDelete { buttons.append(.destructive(Text("Delete")) { AlertManager.shared.showAlert(deleteGroupAlert(gi)) }) @@ -372,7 +394,7 @@ struct ChatListNavLink: View { actionSheet = SomeActionSheet( actionSheet: ActionSheet( - title: Text("Clear or delete group?"), + title: canShowGroupDelete ? Text("Clear or delete group?") : Text("Clear group?"), buttons: buttons ), id: "other options" @@ -490,6 +512,27 @@ struct ChatListNavLink: View { ) } + private func archiveAllReportsAlert() -> Alert { + Alert( + title: Text("Archive all reports?"), + message: Text("All reports will be archived for you."), + primaryButton: .destructive(Text("Archive")) { + Task { await archiveAllReportsForMe(chat.chatInfo.apiId) } + }, + secondaryButton: .cancel() + ) + } + + private func archiveAllReportsForMe(_ apiId: Int64) async { + do { + if case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member) = try await apiArchiveReceivedReports(groupId: apiId) { + await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member) + } + } catch { + logger.error("archiveAllReportsForMe error: \(responseError(error))") + } + } + private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index c553132dd2..ba94463d61 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -790,7 +790,12 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - return (chatItem.chatId, ntf) + let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { + groupMember.id + } else { + chatItem.chatInfo.id + } + return (chatIdOrMemberId, ntf) } else { return nil } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4c26b0cf00..bd022165f0 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -55,6 +55,8 @@ public enum ChatCommand { case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, 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 apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64]) @@ -230,6 +232,8 @@ public enum ChatCommand { 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 .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 .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: ","))" @@ -404,6 +408,8 @@ public enum ChatCommand { case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" + case .apiArchiveReceivedReports: return "apiArchiveReceivedReports" + case .apiDeleteReceivedReports: return "apiDeleteReceivedReports" case .apiChatItemReaction: return "apiChatItemReaction" case .apiGetReactionMembers: return "apiGetReactionMembers" case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e974c2ca86..58bbfdbfd8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2435,13 +2435,6 @@ public struct ChatItemDeletion: Decodable, Hashable { public struct AChatItem: Decodable, Hashable { public var chatInfo: ChatInfo public var chatItem: ChatItem - - public var chatId: String { - if case let .groupRcv(groupMember) = chatItem.chatDir { - return groupMember.id - } - return chatInfo.id - } } public struct CIMentionMember: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index a922e3a816..97cc633115 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -203,6 +203,11 @@ func hideSecrets(_ cItem: ChatItem) -> String { } return res } else { - return cItem.text + let mc = cItem.content.msgContent + if case let .report(text, reason) = mc { + return NSLocalizedString("Report: \(text.isEmpty ? reason.text : text)", comment: "report in notification") + } else { + return cItem.text + } } } 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 3d64fda251..51c27b21de 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 @@ -851,8 +851,8 @@ object ChatModel { changeGroupReportsCounter(rhId, chatId, 1) } - fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { - changeGroupReportsCounter(rhId, chatId, -1) + fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 1) { + changeGroupReportsCounter(rhId, chatId, -by) } private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { 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 abb449f0cc..7205bcc89b 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 @@ -1071,6 +1071,20 @@ object ChatController { return null } + suspend fun apiArchiveReceivedReports(rh: Long?, groupId: Long): CR.GroupChatItemsDeleted? { + val r = sendCmd(rh, CC.ApiArchiveReceivedReports(groupId)) + if (r is CR.GroupChatItemsDeleted) return r + Log.e(TAG, "apiArchiveReceivedReports bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiDeleteReceivedReports(rh: Long?, groupId: Long, itemIds: List, mode: CIDeleteMode): List? { + val r = sendCmd(rh, CC.ApiDeleteReceivedReports(groupId, itemIds, mode)) + if (r is CR.ChatItemsDeleted) return r.chatItemDeletions + Log.e(TAG, "apiDeleteReceivedReports bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) @@ -2606,6 +2620,9 @@ object ChatController { } else { upsertChatItem(rhId, cInfo, toChatItem.chatItem) } + if (cItem.isActiveReport) { + decreaseGroupReportsCounter(rhId, cInfo.id) + } } withReportsChatsIfOpen { if (cItem.isReport) { @@ -2619,54 +2636,7 @@ object ChatController { } } is CR.GroupChatItemsDeleted -> { - if (!active(r.user)) { - val users = chatController.listUsers(rhId) - chatModel.users.clear() - chatModel.users.addAll(users) - return - } - val cInfo = ChatInfo.Group(r.groupInfo) - withChats { - r.chatItemIDs.forEach { itemId -> - decreaseGroupReportsCounter(rhId, cInfo.id) - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach - if (chatModel.chatId.value != null) { - // Stop voice playback only inside a chat, allow to play in a chat list - AudioPlayer.stop(cItem) - } - val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id - if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { - ntfManager.cancelNotificationsForChat(cInfo.id) - ntfManager.displayNotification( - r.user, - cInfo.id, - cInfo.displayName, - generalGetString(MR.strings.marked_deleted_description) - ) - } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { - CIDeleted.Moderated(Clock.System.now(), r.member_) - } else { - CIDeleted.Deleted(Clock.System.now()) - } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) - } - } - withReportsChatsIfOpen { - r.chatItemIDs.forEach { itemId -> - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach - if (chatModel.chatId.value != null) { - // Stop voice playback only inside a chat, allow to play in a chat list - AudioPlayer.stop(cItem) - } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { - CIDeleted.Moderated(Clock.System.now(), r.member_) - } else { - CIDeleted.Deleted(Clock.System.now()) - } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) - } - } + groupChatItemsDeleted(rhId, r) } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { @@ -3150,6 +3120,57 @@ object ChatController { } } + suspend fun groupChatItemsDeleted(rhId: Long?, r: CR.GroupChatItemsDeleted) { + if (!activeUser(rhId, r.user)) { + val users = chatController.listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + return + } + val cInfo = ChatInfo.Group(r.groupInfo) + withChats { + r.chatItemIDs.forEach { itemId -> + decreaseGroupReportsCounter(rhId, cInfo.id) + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { + ntfManager.cancelNotificationsForChat(cInfo.id) + ntfManager.displayNotification( + r.user, + cInfo.id, + cInfo.displayName, + generalGetString(MR.strings.marked_deleted_description) + ) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } + } + withReportsChatsIfOpen { + r.chatItemIDs.forEach { itemId -> + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } + } + } + private suspend fun chatItemUpdateNotify(rh: Long?, user: UserLike, aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem @@ -3375,6 +3396,8 @@ sealed class 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 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 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() @@ -3553,6 +3576,8 @@ sealed class CC { 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 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 ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { @@ -3719,6 +3744,8 @@ sealed class CC { is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" + is ApiArchiveReceivedReports -> "apiArchiveReceivedReports" + is ApiDeleteReceivedReports -> "apiDeleteReceivedReports" is ApiChatItemReaction -> "apiChatItemReaction" is ApiGetReactionMembers -> "apiGetReactionMembers" is ApiForwardChatItems -> "apiForwardChatItems" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 43ea0a499e..5efd3747a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -134,7 +134,12 @@ abstract class NtfManager { } res } else { - cItem.text + val mc = cItem.content.msgContent + if (mc is MsgContent.MCReport) { + generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text }) + } else { + cItem.text + } } } } 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 51dc568e24..8b94f36d75 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 @@ -196,6 +196,7 @@ fun ChatView( ) } }, + archiveItems = { archiveItems(chatRh, chatInfo, selectedChatItems) }, moderateItems = { if (chatInfo is ChatInfo.Group) { val itemIds = selectedChatItems.value @@ -397,6 +398,7 @@ fun ChatView( } }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, + archiveReports = { itemIds, forAll -> archiveReports(chatRh, chatInfo, itemIds, forAll) }, receiveFile = { fileId -> withBGApi { chatModel.controller.receiveFile(chatRh, user, fileId) } }, @@ -673,6 +675,7 @@ fun ChatLayout( loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -745,7 +748,7 @@ fun ChatLayout( }) { ChatItemsList( remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -792,6 +795,7 @@ fun ChatLayout( }) } }, + archiveItems = { archiveItems(remoteHostId, chatInfo, selectedChatItems) }, moderateItems = {}, forwardItems = {} ) @@ -1138,6 +1142,7 @@ fun BoxScope.ChatItemsList( loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -1291,7 +1296,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, 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(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, 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) } } @@ -2368,6 +2373,59 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List, forAll: Boolean, onSuccess: () -> Unit = {}) { + if (itemIds.isNotEmpty()) { + withBGApi { + val deleted = chatModel.controller.apiDeleteReceivedReports( + chatRh, + groupId = chatInfo.apiId, + itemIds = itemIds, + mode = if (forAll) CIDeleteMode.cidmBroadcast else CIDeleteMode.cidmInternalMark + ) + if (deleted != null) { + withChats { + for (di in deleted) { + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } + val deletedItem = di.deletedChatItem.chatItem + if (deletedItem.isActiveReport) { + decreaseGroupReportsCounter(chatRh, chatInfo.id) + } + } + } + withReportsChatsIfOpen { + for (di in deleted) { + if (di.deletedChatItem.chatItem.isReport) { + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } + } + } + } + onSuccess() + } + } + } +} + +private fun archiveItems(rhId: Long?, chatInfo: ChatInfo, selectedChatItems: MutableState?>) { + val itemIds = selectedChatItems.value + if (itemIds != null) { + showArchiveReportsAlert(itemIds.sorted(), chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberActive, archiveReports = { ids, forAll -> + archiveReports(rhId, chatInfo, ids, forAll) { + selectedChatItems.value = null + } + }) + } +} + private fun markUnreadChatAsRead(chatId: String) { val chat = chatModel.chats.value.firstOrNull { it.id == chatId } if (chat?.chatStats?.unreadChat != true) return @@ -2716,6 +2774,7 @@ fun PreviewChatLayout() { loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -2791,6 +2850,7 @@ fun PreviewGroupChatLayout() { loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = {}, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, 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 457f8f1e40..85e3f3936e 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 @@ -53,11 +53,13 @@ fun SelectedItemsBottomToolbar( contentTag: MsgContentTag?, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible + archiveItems: () -> Unit, moderateItems: () -> Unit, forwardItems: () -> Unit, ) { val deleteEnabled = remember { mutableStateOf(false) } val deleteForEveryoneEnabled = remember { mutableStateOf(false) } + val canArchiveReports = remember { mutableStateOf(false) } val canModerate = remember { mutableStateOf(false) } val moderateEnabled = remember { mutableStateOf(false) } val forwardEnabled = remember { mutableStateOf(false) } @@ -80,7 +82,7 @@ fun SelectedItemsBottomToolbar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - IconButton({ deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !deleteCountProhibited.value) { + IconButton({ if (canArchiveReports.value) archiveItems() else deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !deleteCountProhibited.value) { Icon( painterResource(MR.images.ic_delete), null, @@ -111,7 +113,7 @@ fun SelectedItemsBottomToolbar( } val chatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value } } LaunchedEffect(chatInfo, chatItems.value, selectedChatItems.value) { - recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } } @@ -120,6 +122,7 @@ private fun recheckItems(chatInfo: ChatInfo, selectedChatItems: MutableState?>, deleteEnabled: MutableState, deleteForEveryoneEnabled: MutableState, + canArchiveReports: MutableState, canModerate: MutableState, moderateEnabled: MutableState, forwardEnabled: MutableState, @@ -133,6 +136,7 @@ private fun recheckItems(chatInfo: ChatInfo, val selected = selectedChatItems.value ?: return var rDeleteEnabled = true var rDeleteForEveryoneEnabled = true + var rCanArchiveReports = true var rModerateEnabled = true var rOnlyOwnGroupItems = true var rForwardEnabled = true @@ -141,6 +145,7 @@ private fun recheckItems(chatInfo: ChatInfo, if (selected.contains(ci.id)) { rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport + rCanArchiveReports = rCanArchiveReports && ci.isActiveReport && ci.chatDir !is CIDirection.GroupSnd && chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport @@ -150,6 +155,7 @@ private fun recheckItems(chatInfo: ChatInfo, rModerateEnabled = rModerateEnabled && !rOnlyOwnGroupItems deleteEnabled.value = rDeleteEnabled deleteForEveryoneEnabled.value = rDeleteForEveryoneEnabled + canArchiveReports.value = rCanArchiveReports moderateEnabled.value = rModerateEnabled forwardEnabled.value = rForwardEnabled selectedChatItems.value = rSelectedChatItems 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 10e902f24b..8c053ad1d2 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 @@ -77,6 +77,7 @@ fun ChatItemView( selectChatItem: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -301,7 +302,7 @@ fun ChatItemView( cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { DefaultDropdownMenu(showMenu) { if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - ArchiveReportItemAction(cItem, showMenu, deleteMessage) + ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) } DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() @@ -914,23 +915,53 @@ private fun ReportItemAction( } @Composable -private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { +private fun ArchiveReportItemAction(id: Long, allowForAll: Boolean, showMenu: MutableState, archiveReports: (List, Boolean) -> Unit) { ItemAction( stringResource(MR.strings.archive_report), painterResource(MR.images.ic_inventory_2), onClick = { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.report_archive_alert_title), - text = generalGetString(MR.strings.report_archive_alert_desc), - onConfirm = { - deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) - }, - destructive = true, - confirmText = generalGetString(MR.strings.archive_verb), - ) + showArchiveReportsAlert(listOf(id), allowForAll, archiveReports) showMenu.value = false + } + ) +} + +fun showArchiveReportsAlert(ids: List, allowForAll: Boolean, archiveReports: (List, Boolean) -> Unit) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = if (ids.size == 1) { + generalGetString(MR.strings.report_archive_alert_title) + } else { + generalGetString(MR.strings.report_archive_alert_title_nth).format(ids.size) }, - color = Color.Red + text = null, + buttons = { + // Archive for me + SectionItemView({ + AlertManager.shared.hideAlert() + archiveReports(ids, false) + }) { + Text( + generalGetString(MR.strings.report_archive_for_me), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + if (allowForAll) { + // Archive for all moderators + SectionItemView({ + AlertManager.shared.hideAlert() + archiveReports(ids, true) + }) { + Text( + stringResource(MR.strings.report_archive_for_all_moderators), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + } + } ) } @@ -1310,6 +1341,7 @@ fun PreviewChatItemView( selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -1356,6 +1388,7 @@ fun PreviewChatItemViewDeletedContent() { selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, 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 8e01c2b4bb..4cdd4b5c05 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* @@ -299,6 +300,11 @@ fun GroupMenuItems( ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, groupInfo.chatSettings.enableNtfs.nextMode(true), showMenu) TagListAction(chat, showMenu) + if (chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveAllReportsItemAction(showMenu) { + archiveAllReportsForMe(chat.remoteHostId, chat.chatInfo.apiId) + } + } ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) @@ -563,6 +569,18 @@ private fun InvalidDataView() { } } +@Composable +private fun ArchiveAllReportsItemAction(showMenu: MutableState, archiveReports: () -> Unit) { + ItemAction( + stringResource(MR.strings.archive_reports), + painterResource(MR.images.ic_inventory_2), + onClick = { + showArchiveAllReportsForMeAlert(archiveReports) + showMenu.value = false + } + ) +} + fun markChatRead(c: Chat) { var chat = c withApi { @@ -886,6 +904,25 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } } +private fun showArchiveAllReportsForMeAlert(archiveReports: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_archive_alert_title_all), + text = generalGetString(MR.strings.report_archive_alert_desc_all), + onConfirm = archiveReports, + destructive = true, + confirmText = generalGetString(MR.strings.archive_verb), + ) +} + +private fun archiveAllReportsForMe(chatRh: Long?, apiId: Long) { + withBGApi { + val r = chatModel.controller.apiArchiveReceivedReports(chatRh, apiId) + if (r != null) { + controller.groupChatItemsDeleted(chatRh, r) + } + } +} + @Composable expect fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, 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 4798c9df56..eb4012255c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -312,7 +312,12 @@ This message was deleted or not received yet. Report reason? Archive report? + Archive %d reports? + Archive all reports? The report will be archived for you. + All reports will be archived for you. + For me + For all moderators Error: %1$s @@ -341,6 +346,7 @@ Search Archive Archive report + Archive reports Delete report Sent message Received message @@ -447,6 +453,7 @@ Businesses Notes Reports + Report: %s All Add list 1 report diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4aed1b053d..4c5beb9cee 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3740,8 +3740,8 @@ chatCommandP = "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <*> (" json" *> jsonP <|> " text " *> updatedMessagesTextP)), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), - "/_archive reports " *> (APIArchiveReceivedReports <$> A.decimal), - "/_delete reports " *> (APIDeleteReceivedReports <$> A.decimal <*> _strP <*> _strP), + "/_archive reports #" *> (APIArchiveReceivedReports <$> A.decimal), + "/_delete reports #" *> (APIDeleteReceivedReports <$> A.decimal <*> _strP <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 04976ad5cd..ca74e5e1bc 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6031,12 +6031,12 @@ testGroupMemberReports = bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) cath #$> ("/_get chat #1 content=report count=100", chat, []) dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content"), (1, "report spam")]) - alice ##> "/_archive reports 1" + alice ##> "/_archive reports #1" alice <## "#jokes: 2 messages deleted by user" (bob ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) - bob ##> "/_archive reports 1" + bob ##> "/_archive reports #1" bob <## "#jokes: 2 messages deleted by user" bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) -- delete reports for all admins @@ -6063,7 +6063,7 @@ testGroupMemberReports = ] alice ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine alice - alice ##> ("/_delete reports 1 " <> show i <> " broadcast") + alice ##> ("/_delete reports #1 " <> show i <> " broadcast") alice <## "message marked deleted by you" bob <# "#jokes dan> [marked deleted by alice] report content" alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) From e06741c936af6c3aeac6d108266fe1f174946e7b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 10 Feb 2025 15:34:07 +0000 Subject: [PATCH 054/289] core: 6.3.0.4 (simplexmq 6.3.0.4) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 806555e86c..dcd465c658 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: a58d3540add65e43146d026666e9d82550977d24 + tag: bd97cb04495b90412c1300fd1a4862f488db85cb source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 9c894eb925..d440afb61f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a58d3540add65e43146d026666e9d82550977d24" = "15jpcqr6hd33fil6ldvy9l60ys8k0rq08w9y2rf73hdal3ilix4l"; + "https://github.com/simplex-chat/simplexmq.git"."bd97cb04495b90412c1300fd1a4862f488db85cb" = "19i0r2b4kfkq2zlbmq134a0hk0vszhm6wdlfyp58d35zqrc0xadf"; "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 55b3f11e96..7fbe800025 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.0.3 +version: 6.3.0.4 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 2402795aad..5a6ef3b847 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, 0, 2] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 4] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 2] +minRemoteHostVersion = AppVersion [6, 3, 0, 4] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From d39b4863b48d6d474372c28df0ba8287e5a42ecd Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 10 Feb 2025 15:38:49 +0000 Subject: [PATCH 055/289] website: translations (#5614) * Translated using Weblate (Polish) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ --------- Co-authored-by: Jester Hodl --- website/langs/pl.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/website/langs/pl.json b/website/langs/pl.json index d0674e3d8a..a25155e018 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -244,15 +244,17 @@ "stable-versions-built-by-f-droid-org": "Wersje stabilne zbudowane przez F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "Wydania na tym repo są kilka dni później", "comparison-section-list-point-4a": "Przekaźniki SimpleX nie mogą skompromitować szyfrowania e2e. Zweryfikuj kody bezpieczeństwa aby złagodzić atak na kanał pozapasmowy", - "hero-overlay-3-title": "Ocena bezpieczeństwa", + "hero-overlay-3-title": "Oceny bezpieczeństwa", "hero-overlay-card-3-p-2": "Trail of Bits przejrzał komponenty kryptograficzne i sieciowe platformy SimpleX w listopadzie 2022. Przeczytaj więcej w ogłoszeniach.", "jobs": "Dołącz do zespołu", - "hero-overlay-3-textlink": "Ocena bezpieczeństwa", + "hero-overlay-3-textlink": "Analizy bezpieczeństwa", "hero-overlay-card-3-p-1": "Trail of Bits jest wiodącą firmą konsultingową w zakresie bezpieczeństwa i technologii, której klientami są duże firmy technologiczne, agencje rządowe i główne projekty blockchain.", "docs-dropdown-9": "Pliki do pobrania", "please-enable-javascript": "Prosimy o włączenie JavaScript w celu wyświetlenia kodu QR.", "please-use-link-in-mobile-app": "Prosimy o skorzystanie z linku w aplikacji mobilnej", "docs-dropdown-10": "Przezroczystość", "docs-dropdown-12": "Bezpieczeństwo", - "docs-dropdown-11": "Często zadawane pytania" + "docs-dropdown-11": "Często zadawane pytania", + "hero-overlay-card-3-p-3": "Firma Trail of Bits dokonała analizy projektu kryptograficznego protokołów sieciowych SimpleX w lipcu 2024 roku. Dowiedz się więcej.", + "docs-dropdown-14": "SimpleX dla firm" } From 0e40d4b5ff2745df3c71a52abed872d832a6341c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 10 Feb 2025 17:55:35 +0000 Subject: [PATCH 056/289] ios: export localizations --- .../bg.xcloc/Localized Contents/bg.xliff | 56 +++++++++++++-- .../cs.xcloc/Localized Contents/cs.xliff | 56 +++++++++++++-- .../de.xcloc/Localized Contents/de.xliff | 57 +++++++++++++-- .../en.xcloc/Localized Contents/en.xliff | 70 +++++++++++++++++-- .../es.xcloc/Localized Contents/es.xliff | 57 +++++++++++++-- .../fi.xcloc/Localized Contents/fi.xliff | 56 +++++++++++++-- .../fr.xcloc/Localized Contents/fr.xliff | 56 +++++++++++++-- .../hu.xcloc/Localized Contents/hu.xliff | 57 +++++++++++++-- .../it.xcloc/Localized Contents/it.xliff | 57 +++++++++++++-- .../ja.xcloc/Localized Contents/ja.xliff | 56 +++++++++++++-- .../nl.xcloc/Localized Contents/nl.xliff | 57 +++++++++++++-- .../pl.xcloc/Localized Contents/pl.xliff | 56 +++++++++++++-- .../ru.xcloc/Localized Contents/ru.xliff | 56 +++++++++++++-- .../th.xcloc/Localized Contents/th.xliff | 56 +++++++++++++-- .../tr.xcloc/Localized Contents/tr.xliff | 56 +++++++++++++-- .../uk.xcloc/Localized Contents/uk.xliff | 56 +++++++++++++-- .../Localized Contents/zh-Hans.xliff | 56 +++++++++++++-- 17 files changed, 897 insertions(+), 74 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 04dd78337f..396232e038 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -823,6 +823,10 @@ Всички профили profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Всички ваши контакти ще останат свързани. @@ -898,6 +902,10 @@ Позволи необратимо изтриване на изпратените съобщения. (24 часа) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Разрешаване на изпращане на SimpleX линкове. @@ -1060,6 +1068,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Архивиране и качване @@ -1078,6 +1094,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Архивирани контакти @@ -1615,6 +1635,10 @@ Изчисти разговора? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3576,6 +3600,10 @@ This is your own one-time link! Поправката не се поддържа от члена на групата No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3589,6 +3617,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -4497,6 +4529,10 @@ This is your link for group %@! Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4530,6 +4566,10 @@ This is your link for group %@! Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Членовете на групата могат да изпращат SimpleX линкове. @@ -5638,6 +5678,10 @@ Error: %@ Забрани реакциите на съобщенията. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Забранете изпращането на SimpleX линкове. @@ -6018,6 +6062,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7271,10 +7323,6 @@ It can happen because of some bug or when the connection is compromised.Профилът се споделя само с вашите контакти. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 9b3a40e012..1f417ad85a 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -788,6 +788,10 @@ All profiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Všechny vaše kontakty zůstanou připojeny. @@ -859,6 +863,10 @@ Povolit nevratné smazání odeslaných zpráv. (24 hodin) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. No comment provided by engineer. @@ -1013,6 +1021,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -1029,6 +1045,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1531,6 +1551,10 @@ Vyčistit konverzaci? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3430,6 +3454,10 @@ This is your own one-time link! Opravit nepodporované členem skupiny No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3443,6 +3471,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -4317,6 +4349,10 @@ This is your link for group %@! Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4350,6 +4386,10 @@ This is your link for group %@! Členové skupiny mohou nevratně mazat odeslané zprávy. (24 hodin) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -5420,6 +5460,10 @@ Error: %@ Zakázat reakce na zprávy. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5789,6 +5833,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7018,10 +7070,6 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Profil je sdílen pouze s vašimi kontakty. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index f0f1bf84ef..9ab9b2a818 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -840,6 +840,10 @@ Alle Profile profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Alle Ihre Kontakte bleiben verbunden. @@ -915,6 +919,10 @@ Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Das Senden von SimpleX-Links erlauben. @@ -1080,6 +1088,14 @@ Archiv No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Archivieren und Hochladen @@ -1100,6 +1116,10 @@ Meldung archivieren? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Archivierte Kontakte @@ -1657,6 +1677,10 @@ Chat-Inhalte entfernen? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? Gruppe entfernen oder löschen? @@ -3732,6 +3756,10 @@ Das ist Ihr eigener Einmal-Link! Reparatur wird vom Gruppenmitglied nicht unterstützt No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Für das Chat-Profil %@: @@ -3747,6 +3775,10 @@ Das ist Ihr eigener Einmal-Link! Wenn Ihr Kontakt beispielsweise Nachrichten über einen SimpleX-Chatserver empfängt, wird Ihre App diese über einen der Server von Flux versenden. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Für privates Routing @@ -4693,6 +4725,10 @@ Das ist Ihr Link für die Gruppe %@! Mitglied inaktiv item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Die Rolle des Mitglieds wird auf "%@" geändert. Alle Chat-Mitglieder werden darüber informiert. @@ -4728,6 +4764,10 @@ Das ist Ihr Link für die Gruppe %@! Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Gruppenmitglieder können SimpleX-Links versenden. @@ -5909,6 +5949,10 @@ Fehler: %@ Reaktionen auf Nachrichten nicht erlauben. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Das Senden von SimpleX-Links nicht erlauben. @@ -6314,6 +6358,14 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Verstoß melden: Nur Gruppenmoderatoren werden es sehen. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports Meldungen @@ -7654,11 +7706,6 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Das Profil wird nur mit Ihren Kontakten geteilt. No comment provided by engineer. - - The report will be archived for you. - Die Meldung wird für Sie archiviert. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**. 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 22d61004ca..f1edf1e8cd 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -843,6 +843,11 @@ All profiles profile dropdown + + All reports will be archived for you. + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. All your contacts will remain connected. @@ -918,6 +923,11 @@ Allow to irreversibly delete sent messages. (24 hours) No comment provided by engineer. + + Allow to report messsages to moderators. + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Allow to send SimpleX links. @@ -1083,6 +1093,16 @@ Archive No comment provided by engineer. + + Archive %lld reports? + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + Archive all reports? + No comment provided by engineer. + Archive and upload Archive and upload @@ -1103,6 +1123,11 @@ Archive report? No comment provided by engineer. + + Archive reports + Archive reports + swipe action + Archived contacts Archived contacts @@ -1660,6 +1685,11 @@ Clear conversation? No comment provided by engineer. + + Clear group? + Clear group? + No comment provided by engineer. + Clear or delete group? Clear or delete group? @@ -3740,6 +3770,11 @@ This is your own one-time link! Fix not supported by group member No comment provided by engineer. + + For all moderators + For all moderators + No comment provided by engineer. + For chat profile %@: For chat profile %@: @@ -3755,6 +3790,11 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + For me + No comment provided by engineer. + For private routing For private routing @@ -4706,6 +4746,11 @@ This is your link for group %@! Member inactive item status text + + Member reports + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Member role will be changed to "%@". All chat members will be notified. @@ -4741,6 +4786,11 @@ This is your link for group %@! Members can irreversibly delete sent messages. (24 hours) No comment provided by engineer. + + Members can report messsages to moderators. + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Members can send SimpleX links. @@ -5930,6 +5980,11 @@ Error: %@ Prohibit messages reactions. No comment provided by engineer. + + Prohibit reporting messages to moderators. + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Prohibit sending SimpleX links. @@ -6338,6 +6393,16 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports Reports @@ -7680,11 +7745,6 @@ It can happen because of some bug or when the connection is compromised.The profile is only shared with your contacts. No comment provided by engineer. - - The report will be archived for you. - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. The same conditions will apply to operator **%@**. 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 86043e9fcf..968dd52f13 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -840,6 +840,10 @@ Todos los perfiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Todos tus contactos permanecerán conectados. @@ -915,6 +919,10 @@ Se permite la eliminación irreversible de mensajes. (24 horas) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Se permite enviar enlaces SimpleX. @@ -1080,6 +1088,14 @@ Archivar No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Archivar y subir @@ -1100,6 +1116,10 @@ ¿Archivar informe? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Contactos archivados @@ -1657,6 +1677,10 @@ ¿Vaciar conversación? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? ¿Vaciar o eliminar grupo? @@ -3732,6 +3756,10 @@ This is your own one-time link! Corrección no compatible con miembro del grupo No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Para el perfil de chat %@: @@ -3747,6 +3775,10 @@ This is your own one-time link! Si por ejemplo tu contacto recibe los mensajes a través de un servidor de SimpleX Chat, tu aplicación los entregará a través de un servidor de Flux. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Para enrutamiento privado @@ -4693,6 +4725,10 @@ This is your link for group %@! Miembro inactivo item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. El rol del miembro cambiará a "%@" y todos serán notificados. @@ -4728,6 +4764,10 @@ This is your link for group %@! Los miembros del grupo pueden eliminar mensajes de forma irreversible. (24 horas) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Los miembros del grupo pueden enviar enlaces SimpleX. @@ -5909,6 +5949,10 @@ Error: %@ No se permiten reacciones a los mensajes. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No se permite enviar enlaces SimpleX. @@ -6314,6 +6358,14 @@ Actívalo en ajustes de *Servidores y Redes*. Informar de violación: sólo los moderadores del grupo lo verán. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports Informes @@ -7654,11 +7706,6 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. El perfil sólo se comparte con tus contactos. No comment provided by engineer. - - The report will be archived for you. - El informe será archivado para ti. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Las mismas condiciones se aplicarán al operador **%@**. 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 2a4c3fb9ff..e8113a5553 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -775,6 +775,10 @@ All profiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Kaikki kontaktisi pysyvät yhteydessä. @@ -846,6 +850,10 @@ Salli lähetettyjen viestien peruuttamaton poistaminen. (24 tuntia) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. No comment provided by engineer. @@ -999,6 +1007,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -1015,6 +1031,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1516,6 +1536,10 @@ Tyhjennä keskustelu? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3412,6 +3436,10 @@ This is your own one-time link! Ryhmän jäsen ei tue korjausta No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3425,6 +3453,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -4299,6 +4331,10 @@ This is your link for group %@! Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4332,6 +4368,10 @@ This is your link for group %@! Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti. (24 tuntia) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -5400,6 +5440,10 @@ Error: %@ Estä viestireaktiot. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5769,6 +5813,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -6996,10 +7048,6 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Profiili jaetaan vain kontaktiesi kanssa. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index c030ccf98b..0c9a87126e 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -833,6 +833,10 @@ Tous les profiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Tous vos contacts resteront connectés. @@ -908,6 +912,10 @@ Autoriser la suppression irréversible de messages envoyés. (24 heures) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Autorise l'envoi de liens SimpleX. @@ -1070,6 +1078,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Archiver et téléverser @@ -1088,6 +1104,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Contacts archivés @@ -1642,6 +1662,10 @@ Effacer la conversation ? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3693,6 +3717,10 @@ Il s'agit de votre propre lien unique ! Correction non prise en charge par un membre du groupe No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Pour le profil de discussion %@ : @@ -3708,6 +3736,10 @@ Il s'agit de votre propre lien unique ! Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Pour le routage privé @@ -4647,6 +4679,10 @@ Voici votre lien pour le groupe %@ ! Membre inactif item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés. @@ -4682,6 +4718,10 @@ Voici votre lien pour le groupe %@ ! Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés. (24 heures) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Les membres du groupe peuvent envoyer des liens SimpleX. @@ -5851,6 +5891,10 @@ Erreur : %@ Interdire les réactions aux messages. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Interdire l'envoi de liens SimpleX. @@ -6249,6 +6293,14 @@ Activez-le dans les paramètres *Réseau et serveurs*. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7584,10 +7636,6 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le profil n'est partagé qu'avec vos contacts. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Les mêmes conditions s'appliquent à l'opérateur **%@**. 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 5b212638e7..14746bfb92 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -840,6 +840,10 @@ Összes profil profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Az összes ismerősével kapcsolatban marad. @@ -915,6 +919,10 @@ Elküldött üzenetek végleges törlésének engedélyezése. (24 óra) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. A SimpleX-hivatkozások küldése engedélyezve van. @@ -1080,6 +1088,14 @@ Archívum No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Archiválás és feltöltés @@ -1100,6 +1116,10 @@ Jelentés archiválása? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Archivált ismerősök @@ -1657,6 +1677,10 @@ Üzenetek kiürítése? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? Csoport kiürítése vagy törlése? @@ -3732,6 +3756,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Csoporttag általi javítás nem támogatott No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: A(z) %@ nevű csevegési profilhoz: @@ -3747,6 +3775,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing A privát útválasztáshoz @@ -4693,6 +4725,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Inaktív tag item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. A tag szerepeköre meg fog változni a következőre: "%@". A csevegés tagjai értesítést fognak kapni. @@ -4728,6 +4764,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! A csoport tagjai véglegesen törölhetik az elküldött üzeneteiket. (24 óra) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. A csoport tagjai küldhetnek SimpleX-hivatkozásokat. @@ -5909,6 +5949,10 @@ Hiba: %@ Az üzenetreakciók tiltása. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. A SimpleX-hivatkozások küldése le van tiltva. @@ -6314,6 +6358,14 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Szabálysértés jelentése: csak a csoport moderátorai látják. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports Jelentések @@ -7654,11 +7706,6 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. A profilja csak az ismerőseivel van megosztva. No comment provided by engineer. - - The report will be archived for you. - A jelentés archiválva lesz az Ön számára. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**. 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 2492edc626..8dd8b44054 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -840,6 +840,10 @@ Tutti gli profili profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Tutti i tuoi contatti resteranno connessi. @@ -915,6 +919,10 @@ Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Consenti di inviare link di SimpleX. @@ -1080,6 +1088,14 @@ Archivia No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Archivia e carica @@ -1100,6 +1116,10 @@ Archiviare la segnalazione? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Contatti archiviati @@ -1657,6 +1677,10 @@ Svuotare la conversazione? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? Svuotare o eliminare il gruppo? @@ -3732,6 +3756,10 @@ Questo è il tuo link una tantum! Correzione non supportata dal membro del gruppo No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Per il profilo di chat %@: @@ -3747,6 +3775,10 @@ Questo è il tuo link una tantum! Ad esempio, se il tuo contatto riceve messaggi tramite un server di SimpleX Chat, la tua app li consegnerà tramite un server Flux. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Per l'instradamento privato @@ -4693,6 +4725,10 @@ Questo è il tuo link per il gruppo %@! Membro inattivo item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Il ruolo del membro verrà cambiato in "%@". Verranno notificati tutti i membri della chat. @@ -4728,6 +4764,10 @@ Questo è il tuo link per il gruppo %@! I membri del gruppo possono eliminare irreversibilmente i messaggi inviati. (24 ore) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. I membri del gruppo possono inviare link di Simplex. @@ -5909,6 +5949,10 @@ Errore: %@ Proibisci le reazioni ai messaggi. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Vieta l'invio di link di SimpleX. @@ -6314,6 +6358,14 @@ Attivalo nelle impostazioni *Rete e server*. Segnala violazione: solo i moderatori del gruppo lo vedranno. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports Segnalazioni @@ -7654,11 +7706,6 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il profilo è condiviso solo con i tuoi contatti. No comment provided by engineer. - - The report will be archived for you. - La segnalazione verrà archiviata per te. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Le stesse condizioni si applicheranno all'operatore **%@**. 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 913e227f17..9884967d68 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -813,6 +813,10 @@ すべてのプロフィール profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. あなたの連絡先が繋がったまま継続します。 @@ -886,6 +890,10 @@ 送信済みメッセージの永久削除を許可する。(24時間) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. SimpleXリンクの送信を許可。 @@ -1047,6 +1055,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload アーカイブとアップロード @@ -1064,6 +1080,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1569,6 +1589,10 @@ ダイアログのクリアしますか? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3485,6 +3509,10 @@ This is your own one-time link! グループメンバーによる修正はサポートされていません No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3498,6 +3526,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -4372,6 +4404,10 @@ This is your link for group %@! Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4405,6 +4441,10 @@ This is your link for group %@! グループのメンバーがメッセージを完全削除することができます。(24時間) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -5478,6 +5518,10 @@ Error: %@ メッセージへのリアクションは禁止されています。 No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5846,6 +5890,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7067,10 +7119,6 @@ It can happen because of some bug or when the connection is compromised.プロフィールは連絡先にしか共有されません。 No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index d8ced23aa4..044ea87cd6 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -840,6 +840,10 @@ Alle profielen profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Al uw contacten blijven verbonden. @@ -915,6 +919,10 @@ Sta toe om verzonden berichten definitief te verwijderen. (24 uur) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Sta toe dat SimpleX-links worden verzonden. @@ -1080,6 +1088,14 @@ Archief No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Archiveren en uploaden @@ -1100,6 +1116,10 @@ Rapport archiveren? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Gearchiveerde contacten @@ -1657,6 +1677,10 @@ Gesprek wissen? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? Groep wissen of verwijderen? @@ -3732,6 +3756,10 @@ Dit is uw eigen eenmalige link! Herstel wordt niet ondersteund door groepslid No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Voor chatprofiel %@: @@ -3747,6 +3775,10 @@ Dit is uw eigen eenmalige link! Als uw contactpersoon bijvoorbeeld berichten ontvangt via een SimpleX Chat-server, worden deze door uw app via een Flux-server verzonden. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Voor privé-routering @@ -4693,6 +4725,10 @@ Dit is jouw link voor groep %@! Lid inactief item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. De rol van het lid wordt gewijzigd naar "%@". Alle chatleden worden op de hoogte gebracht. @@ -4728,6 +4764,10 @@ Dit is jouw link voor groep %@! Groepsleden kunnen verzonden berichten onherroepelijk verwijderen. (24 uur) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Groepsleden kunnen SimpleX-links verzenden. @@ -5909,6 +5949,10 @@ Fout: %@ Berichten reacties verbieden. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Verbied het verzenden van SimpleX-links @@ -6314,6 +6358,14 @@ Schakel dit in in *Netwerk en servers*-instellingen. Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports Rapporten @@ -7654,11 +7706,6 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Het profiel wordt alleen gedeeld met uw contacten. No comment provided by engineer. - - The report will be archived for you. - Het rapport wordt voor u gearchiveerd. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Dezelfde voorwaarden gelden voor operator **%@**. 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 c410ff46e3..1c39ed50ba 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -838,6 +838,10 @@ Wszystkie profile profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Wszystkie Twoje kontakty pozostaną połączone. @@ -913,6 +917,10 @@ Zezwól na nieodwracalne usunięcie wysłanych wiadomości. (24 godziny) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Zezwól na wysyłanie linków SimpleX. @@ -1075,6 +1083,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Archiwizuj i prześlij @@ -1093,6 +1109,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Zarchiwizowane kontakty @@ -1648,6 +1668,10 @@ Wyczyścić rozmowę? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3678,6 +3702,10 @@ To jest twój jednorazowy link! Naprawa nie jest obsługiwana przez członka grupy No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3691,6 +3719,10 @@ To jest twój jednorazowy link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -4620,6 +4652,10 @@ To jest twój link do grupy %@! Członek nieaktywny item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4653,6 +4689,10 @@ To jest twój link do grupy %@! Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Członkowie grupy mogą wysyłać linki SimpleX. @@ -5801,6 +5841,10 @@ Błąd: %@ Zabroń reakcje wiadomości. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Zabroń wysyłania linków SimpleX. @@ -6199,6 +6243,14 @@ Włącz w ustawianiach *Sieć i serwery* . Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7514,10 +7566,6 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Profil jest udostępniany tylko Twoim kontaktom. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index d5dc066a9f..9bae61e56d 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -838,6 +838,10 @@ Все профили profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Все контакты, которые соединились через этот адрес, сохранятся. @@ -913,6 +917,10 @@ Разрешить необратимо удалять отправленные сообщения. (24 часа) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Разрешить отправлять ссылки SimpleX. @@ -1077,6 +1085,14 @@ Архивировать No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Архивировать и загрузить @@ -1097,6 +1113,10 @@ Архивировать отчет? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Архивированные контакты @@ -1651,6 +1671,10 @@ Очистить разговор? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3702,6 +3726,10 @@ This is your own one-time link! Починка не поддерживается членом группы No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Для профиля чата %@: @@ -3717,6 +3745,10 @@ This is your own one-time link! Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Для доставки сообщений @@ -4655,6 +4687,10 @@ This is your link for group %@! Член неактивен item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Роль участника будет изменена на "%@". Все участники разговора получат уведомление. @@ -4690,6 +4726,10 @@ This is your link for group %@! Члены группы могут необратимо удалять отправленные сообщения. (24 часа) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Члены группы могут отправлять ссылки SimpleX. @@ -5859,6 +5899,10 @@ Error: %@ Запретить реакции на сообщения. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Запретить отправку ссылок SimpleX. @@ -6257,6 +6301,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7592,10 +7644,6 @@ It can happen because of some bug or when the connection is compromised.Профиль отправляется только Вашим контактам. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Те же самые условия будут приняты для оператора **%@**. 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 ea0ecaa4f0..7239c30b44 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -767,6 +767,10 @@ All profiles profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. ผู้ติดต่อทั้งหมดของคุณจะยังคงเชื่อมต่ออยู่. @@ -838,6 +842,10 @@ อนุญาตให้ลบข้อความที่ส่งไปแล้วอย่างถาวร No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. No comment provided by engineer. @@ -991,6 +999,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -1007,6 +1023,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts No comment provided by engineer. @@ -1508,6 +1528,10 @@ ลบการสนทนา? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3397,6 +3421,10 @@ This is your own one-time link! การแก้ไขไม่สนับสนุนโดยสมาชิกกลุ่ม No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3410,6 +3438,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -4282,6 +4314,10 @@ This is your link for group %@! Member inactive item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4315,6 +4351,10 @@ This is your link for group %@! สมาชิกกลุ่มสามารถลบข้อความที่ส่งแล้วอย่างถาวร No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. No comment provided by engineer. @@ -5379,6 +5419,10 @@ Error: %@ ห้ามแสดงปฏิกิริยาต่อข้อความ No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. No comment provided by engineer. @@ -5746,6 +5790,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -6970,10 +7022,6 @@ It can happen because of some bug or when the connection is compromised.โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 28befea033..0963a750aa 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -833,6 +833,10 @@ Tüm Profiller profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Konuştuğun kişilerin tümü bağlı kalacaktır. @@ -908,6 +912,10 @@ Gönderilen mesajların kalıcı olarak silinmesine izin ver. (24 saat içinde) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. SimpleX bağlantıları göndilmesine izin ver. @@ -1070,6 +1078,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Arşivle ve yükle @@ -1088,6 +1104,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Arşivli kişiler @@ -1642,6 +1662,10 @@ Sohbet temizlensin mi? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3692,6 +3716,10 @@ Bu senin kendi tek kullanımlık bağlantın! Düzeltme grup üyesi tarafından desteklenmiyor No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Sohbet profili için %@: @@ -3707,6 +3735,10 @@ Bu senin kendi tek kullanımlık bağlantın! Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Gizli yönlendirme için @@ -4646,6 +4678,10 @@ Bu senin grup için bağlantın %@! Üye inaktif item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Üye rolü "%@" olarak değiştirilecektir. Tüm sohbet üyeleri bilgilendirilecektir. @@ -4680,6 +4716,10 @@ Bu senin grup için bağlantın %@! Grup üyeleri, gönderilen mesajları kalıcı olarak silebilir. (24 saat içinde) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Grup üyeleri SimpleX bağlantıları gönderebilir. @@ -5828,6 +5868,10 @@ Hata: %@ Mesajlarda tepkileri yasakla. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. SimpleX bağlantısı gönderimini yasakla. @@ -6226,6 +6270,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7544,10 +7596,6 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Profil sadece kişilerinle paylaşılacak. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index a8c1ec27b7..8d48227e77 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -833,6 +833,10 @@ Всі профілі profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. Всі ваші контакти залишаться на зв'язку. @@ -908,6 +912,10 @@ Дозволяє безповоротно видаляти надіслані повідомлення. (24 години) No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. Дозволити надсилати посилання SimpleX. @@ -1070,6 +1078,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload Архівування та завантаження @@ -1088,6 +1104,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts Архівні контакти @@ -1642,6 +1662,10 @@ Відверта розмова? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3693,6 +3717,10 @@ This is your own one-time link! Виправлення не підтримується учасником групи No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: Для профілю чату %@: @@ -3708,6 +3736,10 @@ This is your own one-time link! Наприклад, якщо ваш контакт отримує повідомлення через сервер SimpleX Chat, ваш додаток доставлятиме їх через сервер Flux. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing Для приватної маршрутизації @@ -4647,6 +4679,10 @@ This is your link for group %@! Користувач неактивний item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. Роль учасника буде змінено на "%@". Усі учасники чату отримають сповіщення. @@ -4682,6 +4718,10 @@ This is your link for group %@! Учасники групи можуть безповоротно видаляти надіслані повідомлення. (24 години) No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. Учасники групи можуть надсилати посилання SimpleX. @@ -5851,6 +5891,10 @@ Error: %@ Заборонити реакції на повідомлення. No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. Заборонити надсилання посилань SimpleX. @@ -6249,6 +6293,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7584,10 +7636,6 @@ It can happen because of some bug or when the connection is compromised.Профіль доступний лише вашим контактам. No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Такі ж умови діятимуть і для оператора **%@**. 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 5be4378abb..6ac247c6f1 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 @@ -811,6 +811,10 @@ 所有配置文件 profile dropdown + + All reports will be archived for you. + No comment provided by engineer. + All your contacts will remain connected. 所有联系人会保持连接。 @@ -886,6 +890,10 @@ 允许不可撤回地删除已发送消息 No comment provided by engineer. + + Allow to report messsages to moderators. + No comment provided by engineer. + Allow to send SimpleX links. 允许发送 SimpleX 链接。 @@ -1047,6 +1055,14 @@ Archive No comment provided by engineer. + + Archive %lld reports? + No comment provided by engineer. + + + Archive all reports? + No comment provided by engineer. + Archive and upload 存档和上传 @@ -1065,6 +1081,10 @@ Archive report? No comment provided by engineer. + + Archive reports + swipe action + Archived contacts 已存档的联系人 @@ -1602,6 +1622,10 @@ 清除对话吗? No comment provided by engineer. + + Clear group? + No comment provided by engineer. + Clear or delete group? No comment provided by engineer. @@ -3618,6 +3642,10 @@ This is your own one-time link! 修复群组成员不支持的问题 No comment provided by engineer. + + For all moderators + No comment provided by engineer. + For chat profile %@: servers error @@ -3631,6 +3659,10 @@ This is your own one-time link! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. No comment provided by engineer. + + For me + No comment provided by engineer. + For private routing No comment provided by engineer. @@ -4555,6 +4587,10 @@ This is your link for group %@! 成员不活跃 item status text + + Member reports + chat feature + Member role will be changed to "%@". All chat members will be notified. No comment provided by engineer. @@ -4588,6 +4624,10 @@ This is your link for group %@! 群组成员可以不可撤回地删除已发送的消息 No comment provided by engineer. + + Members can report messsages to moderators. + No comment provided by engineer. + Members can send SimpleX links. 群成员可发送 SimpleX 链接。 @@ -5725,6 +5765,10 @@ Error: %@ 禁止消息回应。 No comment provided by engineer. + + Prohibit reporting messages to moderators. + No comment provided by engineer. + Prohibit sending SimpleX links. 禁止发送 SimpleX 链接。 @@ -6121,6 +6165,14 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason + + Report: (text.isEmpty ? reason.text : text) + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + Reports No comment provided by engineer. @@ -7427,10 +7479,6 @@ It can happen because of some bug or when the connection is compromised.该资料仅与您的联系人共享。 No comment provided by engineer. - - The report will be archived for you. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. From 47997fd90b37dd2769d28e3378b203589cccdd60 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 10 Feb 2025 21:36:55 +0000 Subject: [PATCH 057/289] ios: support opening SimpleX links from camera and other apps --- apps/ios/Shared/ContentView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 3198a3d361..65631954e5 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -317,6 +317,12 @@ struct ContentView: View { .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in + if let url = userActivity.webpageURL { + logger.debug("onContinueUserActivity.NSUserActivityTypeBrowsingWeb: \(url)") + chatModel.appOpenUrl = url + } + } } private func setConditionsNotified_() async { From 8dbebbe3d640028649980da278e17278da9cd2bc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 10 Feb 2025 21:56:24 +0000 Subject: [PATCH 058/289] 6.3-beta.4: ios 263, android 273, desktop 91 --- 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 95086b78a8..d5acf0df8f 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.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.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 */; }; @@ -527,8 +527,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.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.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 = ""; }; @@ -680,8 +680,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -762,8 +762,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.3-2g9X994zhsoKwinyJQVzkZ.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */, ); path = Libraries; sourceTree = ""; @@ -1947,7 +1947,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1996,7 +1996,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2037,7 +2037,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2057,7 +2057,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2082,7 +2082,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2119,7 +2119,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2156,7 +2156,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2207,7 +2207,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2258,7 +2258,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2292,7 +2292,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 262; + CURRENT_PROJECT_VERSION = 263; 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 276bb8127f..2b53258fc8 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-beta.3 -android.version_code=272 +android.version_name=6.3-beta.4 +android.version_code=273 -desktop.version_name=6.3-beta.3 -desktop.version_code=90 +desktop.version_name=6.3-beta.4 +desktop.version_code=91 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From a90f255df5c314edd5c79c472278f197af4ce46d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 15 Feb 2025 00:12:32 +0400 Subject: [PATCH 059/289] core: adapt simplexmq api for shared msg body (via MsgReq markers) (#5626) * core: shared msg body 2 * WIP * compiles * refactor * refactor * refactor * format * simplexmq * refactor * refactor ChatMsgReq * agent query plans * simpler * test * test * fix test * agent plans * simplexmq --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 20 ++-- src/Simplex/Chat/Library/Internal.hs | 80 +++++++------ src/Simplex/Chat/Library/Subscriber.hs | 2 +- .../SQLite/Migrations/agent_query_plans.txt | 43 ++++--- src/Simplex/Chat/Util.hs | 6 +- tests/ChatTests/Groups.hs | 108 ++++++++++++++++++ tests/ChatTests/Utils.hs | 5 + 9 files changed, 209 insertions(+), 59 deletions(-) diff --git a/cabal.project b/cabal.project index dcd465c658..70f66604de 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: bd97cb04495b90412c1300fd1a4862f488db85cb + tag: 7ac80bffcb51e2461ff8d0f54094943c56f1c4e6 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d440afb61f..0975e772b9 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."bd97cb04495b90412c1300fd1a4862f488db85cb" = "19i0r2b4kfkq2zlbmq134a0hk0vszhm6wdlfyp58d35zqrc0xadf"; + "https://github.com/simplex-chat/simplexmq.git"."7ac80bffcb51e2461ff8d0f54094943c56f1c4e6" = "1qzv3vs1f0h5df5i0fi2hqiagkkwqghpzjgh9bnjrgmhkhvkl2iq"; "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/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4c5beb9cee..ebc63e131b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -80,7 +80,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (liftIOEither) +import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent @@ -1893,6 +1893,12 @@ processChatCommand' vr = \case pure CRBroadcastSent {user, msgContent = mc, successes = 0, failures = 0, timestamp} Just (ctConns :: NonEmpty (Contact, Connection)) -> do let idsEvts = L.map ctSndEvent ctConns + -- TODO Broadcast rework + -- In createNewSndMessage and encodeChatMessage we could use Nothing for sharedMsgId, + -- then we could reuse message body across broadcast. + -- Encoding different sharedMsgId and reusing body is meaningless as referencing will not work anyway. + -- As an improvement, single message record with its sharedMsgId could be created for new "broadcast" entity. + -- Then all recipients could refer to broadcast message using same sharedMsgId. sndMsgs <- lift $ createSndMessages idsEvts let msgReqs_ :: NonEmpty (Either ChatError ChatMsgReq) = L.zipWith (fmap . ctMsgReq) ctConns sndMsgs (errs, ctSndMsgs :: [(Contact, SndMessage)]) <- @@ -1909,9 +1915,7 @@ processChatCommand' vr = \case ctSndEvent :: (Contact, Connection) -> (ConnOrGroupId, ChatMsgEvent 'Json) ctSndEvent (_, Connection {connId}) = (ConnectionId connId, XMsgNew $ MCSimple (extMsgContent mc Nothing)) ctMsgReq :: (Contact, Connection) -> SndMessage -> ChatMsgReq - ctMsgReq (_, conn) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgNew_}, msgBody, [msgId]) - zipWith3' :: (a -> b -> c -> d) -> NonEmpty a -> NonEmpty b -> NonEmpty c -> NonEmpty d - zipWith3' f ~(x :| xs) ~(y :| ys) ~(z :| zs) = f x y z :| zipWith3 f xs ys zs + ctMsgReq (_, conn) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgNew_}, (vrValue msgBody, [msgId])) combineResults :: (Contact, Connection) -> Either ChatError SndMessage -> Either ChatError ([Int64], PQEncryption) -> Either ChatError (Contact, SndMessage) combineResults (ct, _) (Right msg') (Right _) = Right (ct, msg') combineResults _ (Left e) _ = Left e @@ -2662,7 +2666,7 @@ processChatCommand' vr = \case ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> - (conn, MsgFlags {notification = hasNotification XInfo_}, msgBody, [msgId]) + (conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId])) updateContactPrefs :: User -> Contact -> Preferences -> CM ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -2713,7 +2717,7 @@ 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 -> GroupInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM ChatResponse delGroupChatItemsForMembers user gInfo ms items = do assertDeletable gInfo items assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate @@ -2723,8 +2727,8 @@ processChatCommand' vr = \case delGroupChatItems user gInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () - assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items = - unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete + assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items' = + unless (all itemDeletable items') $ throwChatError CEInvalidChatItemDelete where itemDeletable :: CChatItem 'CTGroup -> Bool itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ea838206fe..3e96e1e496 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -274,7 +274,7 @@ uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 go acc seen n (name : rest) | n >= maxMentions = acc | otherwise = case M.lookup name mentions of - Just mm@MsgMention {memberId} | S.notMember memberId seen -> + Just mm@MsgMention {memberId} | S.notMember memberId seen -> go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest _ -> go acc seen n rest @@ -1312,7 +1312,7 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do let batched_ = batchSndMessagesJSON msgs_ case L.nonEmpty batched_ of Just batched' -> do - let msgReqs = L.map (fmap (msgBatchReq conn msgFlags)) batched' + let msgReqs = L.map (fmap msgBatchReq_) batched' delivered <- deliverMessagesB msgReqs let msgs' = concat $ L.zipWith flattenMsgs batched' delivered pqEnc = findLastPQEnc delivered @@ -1320,6 +1320,9 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do pure (msgs', pqEnc) Nothing -> pure ([], Nothing) where + msgBatchReq_ :: MsgBatch -> ChatMsgReq + msgBatchReq_ (MsgBatch batchBody sndMsgs) = + (conn, msgFlags, (vrValue batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs)) flattenMsgs :: Either ChatError MsgBatch -> Either ChatError ([Int64], PQEncryption) -> [Either ChatError SndMessage] flattenMsgs (Right (MsgBatch _ sndMsgs)) (Right _) = map Right sndMsgs flattenMsgs (Right (MsgBatch _ sndMsgs)) (Left ce) = replicate (length sndMsgs) (Left ce) @@ -1330,9 +1333,6 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do batchSndMessagesJSON :: NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch] batchSndMessagesJSON = batchMessages maxEncodedMsgLength . L.toList -msgBatchReq :: Connection -> MsgFlags -> MsgBatch -> ChatMsgReq -msgBatchReq conn msgFlags (MsgBatch batchBody sndMsgs) = (conn, msgFlags, batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs) - encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo chatMsgEvent = do vr <- chatVersionRange @@ -1358,7 +1358,7 @@ deliverMessage conn cmEventTag msgBody msgId = do deliverMessage' :: Connection -> MsgFlags -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage' conn msgFlags msgBody msgId = - deliverMessages ((conn, msgFlags, msgBody, [msgId]) :| []) >>= \case + deliverMessages ((conn, msgFlags, (vrValue msgBody, [msgId])) :| []) >>= \case r :| [] -> case r of Right ([deliveryId], pqEnc) -> pure (deliveryId, pqEnc) Right (deliveryIds, _) -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 delivery id, got " <> show (length deliveryIds) @@ -1366,45 +1366,45 @@ deliverMessage' conn msgFlags msgBody msgId = rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) -- [MessageId] - SndMessage ids inside MsgBatch, or single message id -type ChatMsgReq = (Connection, MsgFlags, MsgBody, [MessageId]) +type ChatMsgReq = (Connection, MsgFlags, (ValueOrRef MsgBody, [MessageId])) deliverMessages :: NonEmpty ChatMsgReq -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption))) deliverMessages msgs = deliverMessagesB $ L.map Right msgs deliverMessagesB :: NonEmpty (Either ChatError ChatMsgReq) -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption))) deliverMessagesB msgReqs = do - msgReqs' <- liftIO compressBodies + msgReqs' <- if any connSupportsPQ msgReqs then liftIO compressBodies else pure msgReqs sent <- L.zipWith prepareBatch msgReqs' <$> withAgent (`sendMessagesB` snd (mapAccumL toAgent Nothing msgReqs')) lift . void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) lift . withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where + connSupportsPQ = \case + Right (Connection {pqSupport = PQSupportOn, connChatVersion = v}, _, _) -> v >= pqEncryptionCompressionVersion + _ -> False compressBodies = - forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion = v}, msgFlags, msgBody, msgIds) -> - runExceptT $ case pqSupport of - -- we only compress messages when: - -- 1) PQ support is enabled - -- 2) version is compatible with compression - -- 3) message is longer than max compressed size (as this function is not used for batched messages anyway) - PQSupportOn | v >= pqEncryptionCompressionVersion && B.length msgBody > maxCompressedMsgLength -> do + forME msgReqs $ \(conn, msgFlags, (mbr, msgIds)) -> runExceptT $ do + mbr' <- case mbr of + VRValue i msgBody | B.length msgBody > maxCompressedMsgLength -> do let msgBody' = compressedBatchMsgBody_ msgBody when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message" - pure (conn, msgFlags, msgBody', msgIds) - _ -> pure mr + pure $ VRValue i msgBody' + v -> pure v + pure (conn, msgFlags, (mbr', msgIds)) toAgent prev = \case - Right (conn@Connection {connId, pqEncryption}, msgFlags, msgBody, _msgIds) -> + Right (conn@Connection {connId, pqEncryption}, msgFlags, (mbr, _msgIds)) -> let cId = case prev of Just prevId | prevId == connId -> "" _ -> aConnId conn - in (Just connId, Right (cId, pqEncryption, msgFlags, msgBody)) + in (Just connId, Right (cId, pqEncryption, msgFlags, mbr)) Left _ce -> (prev, Left (AP.INTERNAL "ChatError, skip")) -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing createDelivery :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError ([Int64], PQEncryption)) - createDelivery db ((Connection {connId}, _, _, msgIds), (agentMsgId, pqEnc')) = do + createDelivery db ((Connection {connId}, _, (_, msgIds)), (agentMsgId, pqEnc')) = do Right . (,pqEnc') <$> mapM (createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId})) msgIds updatePQSndEnabled :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO () - updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _, _), (_, pqSndEnabled')) = + updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _), (_, pqSndEnabled')) = case (pqSndEnabled, pqSndEnabled') of (Just b, b') | b' /= b -> updatePQ (Nothing, PQEncOn) -> updatePQ @@ -1471,7 +1471,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do stored <- lift $ withStoreBatch (\db -> map (bindRight $ createPendingMsg db) pendingReqs) when (length stored /= length pendingMemIds) $ logError "sendGroupMessages_: pendingMemIds and stored length mismatch" -- Zip for easier access to results - let sentTo = zipWith3 (\mId mReq r -> (mId, fmap (\(_, _, _, msgIds) -> msgIds) mReq, r)) sendToMemIds msgReqs delivered + let sentTo = zipWith3 (\mId mReq r -> (mId, fmap (\(_, _, (_, msgIds)) -> msgIds) mReq, r)) sendToMemIds msgReqs delivered pending = zipWith3 (\mId pReq r -> (mId, fmap snd pReq, r)) pendingMemIds pendingReqs stored pure (sndMsgs_, GroupSndResult {sentTo, pending, forwarded}) where @@ -1495,24 +1495,36 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do mId = groupMemberId' m mIds' = S.insert mId mIds prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> [(GroupMember, Connection)] -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq]) - prepareMsgReqs msgFlags msgs_ toSendSeparate toSendBatched = do - let batched_ = batchSndMessagesJSON msgs_ + prepareMsgReqs msgFlags msgs toSendSeparate toSendBatched = do + let batched_ = batchSndMessagesJSON msgs case L.nonEmpty batched_ of Just batched' -> do - let (memsSep, mreqsSep) = foldr' foldMsgs ([], []) toSendSeparate - (memsBtch, mreqsBtch) = foldr' (foldBatches batched') ([], []) toSendBatched + let lenMsgs = length msgs + (memsSep, mreqsSep) = foldMembers lenMsgs sndMessageMBR msgs toSendSeparate + (memsBtch, mreqsBtch) = foldMembers (length batched' + lenMsgs) msgBatchMBR batched' toSendBatched (memsSep <> memsBtch, mreqsSep <> mreqsBtch) Nothing -> ([], []) where - foldMsgs :: (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) - foldMsgs (GroupMember {groupMemberId}, conn) memIdsReqs = - foldr' (\msg_ (memIds, reqs) -> (groupMemberId : memIds, fmap sndMessageReq msg_ : reqs)) memIdsReqs msgs_ + foldMembers :: forall a. Int -> (Maybe Int -> Int -> a -> (ValueOrRef MsgBody, [MessageId])) -> NonEmpty (Either ChatError a) -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq]) + foldMembers lastRef mkMb mbs mems = snd $ foldr' foldMsgBodies (lastMemIdx_, ([], [])) mems where - sndMessageReq :: SndMessage -> ChatMsgReq - sndMessageReq SndMessage {msgId, msgBody} = (conn, msgFlags, msgBody, [msgId]) - foldBatches :: NonEmpty (Either ChatError MsgBatch) -> (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) - foldBatches batched' (GroupMember {groupMemberId}, conn) memIdsReqs = - foldr' (\batch_ (memIds, reqs) -> (groupMemberId : memIds, fmap (msgBatchReq conn msgFlags) batch_ : reqs)) memIdsReqs batched' + lastMemIdx_ = let len = length mems in if len > 1 then Just len else Nothing + foldMsgBodies :: (GroupMember, Connection) -> (Maybe Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Maybe Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) + foldMsgBodies (GroupMember {groupMemberId}, conn) (memIdx_, memIdsReqs) = + (subtract 1 <$> memIdx_,) $ snd $ foldr' addBody (lastRef, memIdsReqs) mbs + where + addBody :: Either ChatError a -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) + addBody mb (i, (memIds, reqs)) = + let req = (conn,msgFlags,) . mkMb memIdx_ i <$> mb + in (i - 1, (groupMemberId : memIds, req : reqs)) + sndMessageMBR :: Maybe Int -> Int -> SndMessage -> (ValueOrRef MsgBody, [MessageId]) + sndMessageMBR memIdx_ i SndMessage {msgId, msgBody} = (vrValue_ memIdx_ i msgBody, [msgId]) + msgBatchMBR :: Maybe Int -> Int -> MsgBatch -> (ValueOrRef MsgBody, [MessageId]) + msgBatchMBR memIdx_ i (MsgBatch batchBody sndMsgs) = (vrValue_ memIdx_ i batchBody, map (\SndMessage {msgId} -> msgId) sndMsgs) + vrValue_ memIdx_ i v = case memIdx_ of + Nothing -> VRValue Nothing v -- sending to one member, do not reference bodies + Just 1 -> VRValue (Just i) v + Just _ -> VRRef i preparePending :: NonEmpty (Either ChatError SndMessage) -> [GroupMember] -> ([GroupMemberId], [Either ChatError (GroupMemberId, MessageId)]) preparePending msgs_ = foldr' foldMsgs ([], []) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 902c19ed63..1530f8e5c4 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -274,7 +274,7 @@ processAgentMsgSndFile _corrId aFileId msg = do map (\fileDescr -> (conn, (connOrGroupId, XMsgFileDescr {msgId = sharedMsgId, fileDescr}))) (L.toList $ splitFileDescr partSize rfdText) toMsgReq :: (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq toMsgReq (conn, _) SndMessage {msgId, msgBody} = - (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, msgBody, [msgId]) + (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId])) sendFileError :: FileError -> Text -> VersionRangeChat -> FileTransferMeta -> CM () sendFileError ferr err vr ft = do logError $ "Sent file error: " <> err 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 c4f5007040..f470121f87 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -15,6 +15,20 @@ SEARCH s USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INTEGER PRIMARY KEY (rowid=?) SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT + m.msg_type, m.msg_flags, m.msg_body, m.pq_encryption, m.internal_ts, m.internal_snd_id, s.previous_msg_hash, + s.retry_int_slow, s.retry_int_fast, s.msg_encrypt_key, s.padded_msg_len, sb.agent_msg + FROM messages m + JOIN snd_messages s ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id + LEFT JOIN snd_message_bodies sb ON sb.snd_message_body_id = s.snd_message_body_id + WHERE m.conn_id = ? AND m.internal_id = ? + +Plan: +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) +SEARCH sb USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + Query: SELECT r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries, @@ -45,16 +59,6 @@ Query: Plan: SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?) -Query: - SELECT m.msg_type, m.msg_flags, m.msg_body, m.pq_encryption, m.internal_ts, s.retry_int_slow, s.retry_int_fast - FROM messages m - JOIN snd_messages s ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id - WHERE m.conn_id = ? AND m.internal_id = ? - -Plan: -SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) -SEARCH s USING PRIMARY KEY (conn_id=?) - Query: SELECT rcv_file_chunk_id, chunk_no, chunk_size, digest, tmp_path FROM rcv_file_chunks @@ -512,9 +516,9 @@ Plan: Query: INSERT INTO snd_messages - ( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash) + ( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash, msg_encrypt_key, padded_msg_len, snd_message_body_id) VALUES - (?,?,?,?,?) + (?,?,?,?,?,?,?,?) Plan: SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_id=? AND internal_snd_id=?) @@ -805,6 +809,11 @@ Plan: SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_file_chunks USING COVERING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) +Query: DELETE FROM snd_message_bodies WHERE snd_message_body_id = ? +Plan: +SEARCH snd_message_bodies USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) + Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? Plan: SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries (conn_id=? AND snd_queue_id=?) @@ -861,6 +870,10 @@ Plan: Query: INSERT INTO snd_files (snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, prefix_path, key, nonce, status, redirect_size, redirect_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: +Query: INSERT INTO snd_message_bodies (agent_msg) VALUES (?) RETURNING snd_message_body_id +Plan: +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) + Query: INSERT INTO snd_message_deliveries (conn_id, snd_queue_id, internal_id) VALUES (?, ?, ?) Plan: @@ -897,6 +910,10 @@ Query: SELECT count(*) FROM snd_message_deliveries WHERE conn_id = ? AND interna Plan: SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) +Query: SELECT count(1) FROM snd_message_bodies +Plan: +SCAN snd_message_bodies + Query: SELECT deleted FROM snd_files WHERE snd_file_id = ? Plan: SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) @@ -921,7 +938,7 @@ Query: SELECT ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem FROM rat Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: SELECT rcpt_internal_id, rcpt_status FROM snd_messages WHERE conn_id = ? AND internal_id = ? +Query: SELECT rcpt_internal_id, rcpt_status, snd_message_body_id FROM snd_messages WHERE conn_id = ? AND internal_id = ? Plan: SEARCH snd_messages USING PRIMARY KEY (conn_id=?) diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 3f7d19fd6d..796b32778b 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -4,7 +4,7 @@ {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-orphans #-} -module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle) where +module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle, zipWith3') where import Control.Exception (Exception) import Control.Monad @@ -15,6 +15,7 @@ import Control.Monad.Reader import Data.Bifunctor (first) import qualified Data.ByteString.Lazy as LB import Data.List (sortBy) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Ord (comparing) import Data.Time (NominalDiffTime) import Data.Word (Word16) @@ -52,6 +53,9 @@ shuffle xs = map snd . sortBy (comparing fst) <$> mapM (\x -> (,x) <$> random) x random :: IO Word16 random = randomRIO (0, 65535) +zipWith3' :: (a -> b -> c -> d) -> NonEmpty a -> NonEmpty b -> NonEmpty c -> NonEmpty d +zipWith3' f ~(x :| xs) ~(y :| ys) ~(z :| zs) = f x y z :| zipWith3 f xs ys zs + liftIOEither :: (MonadIO m, MonadError e m) => IO (Either e a) -> m a liftIOEither a = liftIO a >>= liftEither {-# INLINE liftIOEither #-} diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index ca74e5e1bc..d039fb05ff 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -83,6 +83,8 @@ chatGroupTests = do it "send multiple messages api" testSendMulti it "send multiple timed messages" testSendMultiTimed it "send multiple messages (many chat batches)" testSendMultiManyBatches + xit'' "shared message body is reused" testSharedMessageBody + xit'' "shared batch body is reused" testSharedBatchBody describe "async group connections" $ do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do @@ -1883,6 +1885,112 @@ testSendMultiManyBatches = DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdCath) :: IO [[Int]] cathItemsCount `shouldBe` [[300]] +testSharedMessageBody :: HasCallStack => TestParams -> IO () +testSharedMessageBody ps = + withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> do + withSmpServer' serverCfg' $ + withNewTestChatOpts ps opts' "bob" bobProfile $ \bob -> + withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + + alice <##. "server disconnected localhost" + alice #> "#team hello" + bodiesCount1 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount1 `shouldBe` [[1]] + + withSmpServer' serverCfg' $ + withTestChatOpts ps opts' "bob" $ \bob -> + withTestChatOpts ps opts' "cath" $ \cath -> do + concurrentlyN_ + [ alice <##. "server connected localhost", + do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "#team: connected to server(s)", + do + cath <## "1 contacts connected (use /cs for the list)" + cath <## "#team: connected to server(s)" + ] + bob <# "#team alice> hello" + cath <# "#team alice> hello" + bodiesCount2 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount2 `shouldBe` [[0]] + + alice <##. "server disconnected localhost" + where + tmp = tmpPath ps + serverCfg' = + smpServerCfg + { transports = [("7003", transport @TLS, False)], + storeLogFile = Just $ tmp <> "/smp-server-store.log", + storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testSharedBatchBody :: HasCallStack => TestParams -> IO () +testSharedBatchBody ps = + withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> do + withSmpServer' serverCfg' $ + withNewTestChatOpts ps opts' "bob" bobProfile $ \bob -> + withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + + alice <##. "server disconnected localhost" + + let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}" + cms = intercalate ", " (map cm [1 .. 300 :: Int]) + alice `send` ("/_send #1 json [" <> cms <> "]") + _ <- getTermLine alice + alice <## "300 messages sent" + + bodiesCount1 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount1 `shouldBe` [[3]] + + withSmpServer' serverCfg' $ + withTestChatOpts ps opts' "bob" $ \bob -> + withTestChatOpts ps opts' "cath" $ \cath -> do + concurrentlyN_ + [ alice <##. "server connected localhost", + do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "#team: connected to server(s)", + do + cath <## "1 contacts connected (use /cs for the list)" + cath <## "#team: connected to server(s)" + ] + forM_ [(1 :: Int) .. 300] $ \i -> do + concurrently_ + (bob <# ("#team alice> message " <> show i)) + (cath <# ("#team alice> message " <> show i)) + bodiesCount2 <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] + bodiesCount2 `shouldBe` [[0]] + + alice <##. "server disconnected localhost" + where + tmp = tmpPath ps + serverCfg' = + smpServerCfg + { transports = [("7003", transport @TLS, False)], + storeLogFile = Just $ tmp <> "/smp-server-store.log", + storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + testGroupAsync :: HasCallStack => TestParams -> IO () testGroupAsync ps = do withNewTestChat ps "alice" aliceProfile $ \alice -> do diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 7bb25dbe9b..9a3b560b29 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -33,6 +33,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Client.Main (xftpClientCLI) +import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C @@ -556,6 +557,10 @@ withCCTransaction :: TestCC -> (DB.Connection -> IO a) -> IO a withCCTransaction cc action = withTransaction (chatStore $ chatController cc) $ \db -> action db +withCCAgentTransaction :: TestCC -> (DB.Connection -> IO a) -> IO a +withCCAgentTransaction TestCC {chatController = ChatController {smpAgent}} action = + withTransaction (agentClientStore smpAgent) $ \db -> action db + createCCNoteFolder :: TestCC -> IO () createCCNoteFolder cc = withCCTransaction cc $ \db -> From dfe5a4464b7ae129d355d0f09333266ccc308520 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 14 Feb 2025 23:37:06 +0000 Subject: [PATCH 060/289] desktop, android: fix parser for reactions (#5629) * desktop, android: fix parser for reactions * core: restrict API to known reactions --- apps/ios/SimpleXChat/ChatTypes.swift | 8 ++++++-- .../kotlin/chat/simplex/common/model/ChatModel.kt | 11 ++++++++--- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Protocol.hs | 7 +++++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f64a1076a5..01ea2f48af 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3391,8 +3391,12 @@ extension MsgReaction: Decodable { let type = try container.decode(String.self, forKey: CodingKeys.type) switch type { case "emoji": - let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji) - self = .emoji(emoji: emoji) + do { + let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji) + self = .emoji(emoji: emoji) + } catch { + self = .unknown(type: "emoji") + } default: self = .unknown(type: type) } 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 d051fc3a53..fcd2c83d60 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 @@ -2981,7 +2981,7 @@ sealed class MsgReaction { MREmojiChar.Heart -> "❤️" else -> emoji.value } - is Unknown -> "" + is Unknown -> "?" } companion object { @@ -3003,8 +3003,13 @@ object MsgReactionSerializer : KSerializer { return if (json is JsonObject && "type" in json) { when(val t = json["type"]?.jsonPrimitive?.content ?: "") { "emoji" -> { - val emoji = Json.decodeFromString(json["emoji"].toString()) - if (emoji == null) MsgReaction.Unknown(t, json) else MsgReaction.Emoji(emoji) + val msgReaction = try { + val emoji = Json.decodeFromString(json["emoji"].toString()) + MsgReaction.Emoji(emoji) + } catch (e: Throwable) { + MsgReaction.Unknown(t, json) + } + msgReaction } else -> MsgReaction.Unknown(t, json) } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e09ed71e57..4b217e57f8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -8463,8 +8463,8 @@ chatCommandP = "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), - "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), - "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), + "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> (knownReaction <$?> jsonP)), + "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> (knownReaction <$?> jsonP)), "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), "/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP), "/_read user " *> (APIUserRead <$> A.decimal), diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index bc6b083964..bfe1c8f02c 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -407,6 +407,13 @@ data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json emojiTag :: IsString a => a emojiTag = "emoji" +knownReaction :: MsgReaction -> Either String MsgReaction +knownReaction = \case + r@MREmoji {} -> Right r + MRUnknown {} -> Left "unknown MsgReaction" + +-- parseJSON for MsgReaction parses unknown emoji reactions as MRUnknown with type "emoji", +-- allowing to add new emojis in a backwards compatible way - UI shows them as ? instance FromJSON MsgReaction where parseJSON (J.Object v) = do tag <- v .: "type" From 87569e379a9d93ccc4fb74c249eff094004d0b80 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 14 Feb 2025 23:39:24 +0000 Subject: [PATCH 061/289] core: 6.2.5.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index f8c75a0eb7..370d31cf1d 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 6.2.4.0 +version: 6.2.5.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index fc8cab293c..b8faf90f6e 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.2.4.0 +version: 6.2.5.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 1f8755f94139b35cc06821d41de957cae9541349 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 15 Feb 2025 16:18:34 +0000 Subject: [PATCH 062/289] core: update simplexmq (avoid deleting shared message bodies) (#5630) * core: update simplexmq (avoid deleting shared message bodies) * simplexmq, plans * simplexmq * output in failing test * stabilize test --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- .../SQLite/Migrations/agent_query_plans.txt | 38 ++++++++++++------- tests/ChatClient.hs | 22 +++++------ tests/ChatTests/DBUtils/SQLite.hs | 1 + tests/ChatTests/Groups.hs | 2 +- tests/ChatTests/Profiles.hs | 9 +++-- tests/Test.hs | 5 ++- 8 files changed, 49 insertions(+), 32 deletions(-) diff --git a/cabal.project b/cabal.project index 70f66604de..13697d6caf 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: 7ac80bffcb51e2461ff8d0f54094943c56f1c4e6 + tag: fa67d128d1d1c3edb3c49886f096c36e3f4da0d0 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0975e772b9..13cb32469d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."7ac80bffcb51e2461ff8d0f54094943c56f1c4e6" = "1qzv3vs1f0h5df5i0fi2hqiagkkwqghpzjgh9bnjrgmhkhvkl2iq"; + "https://github.com/simplex-chat/simplexmq.git"."fa67d128d1d1c3edb3c49886f096c36e3f4da0d0" = "13fl760rbw75rqa1zs4f130w7a75dfqw43w52jjnfpxgqis3dd2z"; "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/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index f470121f87..31bc2feb81 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -75,6 +75,17 @@ Query: Plan: SEARCH snd_file_chunks USING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) +Query: + DELETE FROM snd_message_bodies + WHERE NOT EXISTS (SELECT 1 FROM snd_messages WHERE snd_message_body_id = ?) + AND snd_message_body_id = ? + +Plan: +SEARCH snd_message_bodies USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 1 +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) + Query: SELECT f.rcv_file_id, f.rcv_file_entity_id, f.user_id, c.rcv_file_chunk_id, c.chunk_no, c.chunk_size, c.digest, f.tmp_path, c.tmp_path, @@ -188,6 +199,16 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) SEARCH f USING INTEGER PRIMARY KEY (rowid=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT rcpt_status, snd_message_body_id FROM snd_messages + WHERE NOT EXISTS (SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND internal_id = ? AND failed = 0) + AND conn_id = ? AND internal_id = ? + +Plan: +SEARCH snd_messages USING PRIMARY KEY (conn_id=?) +SCALAR SUBQUERY 1 +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) + Query: SELECT rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, deleted, redirect_id, redirect_entity_id, redirect_size, redirect_digest FROM rcv_files @@ -809,11 +830,6 @@ Plan: SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_file_chunks USING COVERING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) -Query: DELETE FROM snd_message_bodies WHERE snd_message_body_id = ? -Plan: -SEARCH snd_message_bodies USING INTEGER PRIMARY KEY (rowid=?) -SEARCH snd_messages USING COVERING INDEX idx_snd_messages_snd_message_body_id (snd_message_body_id=?) - Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? Plan: SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries (conn_id=? AND snd_queue_id=?) @@ -906,10 +922,6 @@ Query: SELECT conn_id FROM connections WHERE user_id = ? Plan: SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) -Query: SELECT count(*) FROM snd_message_deliveries WHERE conn_id = ? AND internal_id = ? AND failed = 0 -Plan: -SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) - Query: SELECT count(1) FROM snd_message_bodies Plan: SCAN snd_message_bodies @@ -938,10 +950,6 @@ Query: SELECT ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem FROM rat Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: SELECT rcpt_internal_id, rcpt_status, snd_message_body_id FROM snd_messages WHERE conn_id = ? AND internal_id = ? -Plan: -SEARCH snd_messages USING PRIMARY KEY (conn_id=?) - Query: SELECT rcv_file_id FROM rcv_files WHERE rcv_file_entity_id = ? Plan: SEARCH rcv_files USING COVERING INDEX sqlite_autoindex_rcv_files_1 (rcv_file_entity_id=?) @@ -1106,6 +1114,10 @@ Query: UPDATE snd_messages SET retry_int_slow = ?, retry_int_fast = ? WHERE conn Plan: SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) +Query: UPDATE snd_messages SET snd_message_body_id = NULL WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + Query: UPDATE snd_queues SET snd_primary = ? WHERE conn_id = ? Plan: SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 3376204f14..124818765f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -259,13 +259,13 @@ createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True - startTestChat_ db cfg opts user + startTestChat_ ps db cfg opts user startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do Right db@ChatDatabase {chatStore} <- createDatabase ps coreOptions dbPrefix Just user <- find activeUser <$> withTransaction chatStore getUsers - startTestChat_ db cfg opts user + startTestChat_ ps db cfg opts user createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) #if defined(dbPostgres) @@ -282,8 +282,8 @@ insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") #endif -startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC -startTestChat_ db cfg opts user = do +startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC +startTestChat_ TestParams {printOutput} db cfg opts user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts False @@ -292,7 +292,7 @@ startTestChat_ db cfg opts user = do atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ - pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput = False} + pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput} stopTestChat :: TestParams -> TestCC -> IO () stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do @@ -355,10 +355,10 @@ withTestChatOpts ps = withTestChatCfgOpts ps testCfg withTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatCfgOpts ps cfg opts dbPrefix = bracket (startTestChat ps cfg opts dbPrefix) (\cc -> cc > stopTestChat ps cc) --- enable output for specific chat controller, use like this: --- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... -withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a -withTestOutput cc runTest = runTest cc {printOutput = True} +-- enable output for specific test. +-- usage: withTestOutput $ testChat2 aliceProfile bobProfile $ \alice bob -> do ... +withTestOutput :: HasCallStack => (HasCallStack => TestParams -> IO ()) -> TestParams -> IO () +withTestOutput test ps = test ps {printOutput = True} readTerminalOutput :: VirtualTerminal -> TQueue String -> IO () readTerminalOutput t termQ = do @@ -404,12 +404,12 @@ testChatN cfg opts ps test params = do ( TestCC -> IO String -getTermLine cc = +getTermLine cc@TestCC {printOutput} = 5000000 `timeout` atomically (readTQueue $ termQ cc) >>= \case Just s -> do -- remove condition to always echo virtual terminal -- when True $ do - when (printOutput cc) $ do + when printOutput $ do name <- userName cc putStrLn $ name <> ": " <> s pure s diff --git a/tests/ChatTests/DBUtils/SQLite.hs b/tests/ChatTests/DBUtils/SQLite.hs index b66e5ac851..2de94882cd 100644 --- a/tests/ChatTests/DBUtils/SQLite.hs +++ b/tests/ChatTests/DBUtils/SQLite.hs @@ -6,6 +6,7 @@ import Simplex.Messaging.TMap (TMap) data TestParams = TestParams { tmpPath :: FilePath, + printOutput :: Bool, chatQueryStats :: TMap Query SlowQueryStats, agentQueryStats :: TMap Query SlowQueryStats } diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index d039fb05ff..bdb3b443b3 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1604,7 +1604,7 @@ testGroupModerate = testGroupModerateOwn :: HasCallStack => TestParams -> IO () testGroupModerateOwn = - testChat2 aliceProfile bobProfile $ + withTestOutput $ testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob -- disableFullDeletion2 "team" alice bob diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index ffdd84406c..4ab5e8fbd3 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -742,7 +742,7 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice (biz <# "#bob bob_1> hey there") testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO () -testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $ +testBusinessUpdateProfiles = withTestOutput $ testChat4 businessProfile aliceProfile bobProfile cathProfile $ \biz alice bob cath -> do biz ##> "/ad" cLink <- getContactLink biz True @@ -756,8 +756,11 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c alice <## "#biz: joining the group..." biz <# "#alice Welcome" -- auto reply biz <## "#alice: alice_1 joined the group" - alice <# "#biz biz_1> Welcome" - alice <## "#biz: you joined the group" + alice + <### + [ WithTime "#biz biz_1> Welcome", + "#biz: you joined the group" + ] biz #> "#alice hi" alice <# "#biz biz_1> hi" alice #> "#biz hello" diff --git a/tests/Test.hs b/tests/Test.hs index 42dc5a0524..1d8d45ebb4 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} @@ -75,10 +76,10 @@ main = do #endif where #if defined(dbPostgres) - testBracket test = withSmpServer $ tmpBracket $ test . TestParams + testBracket test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, printOutput = False} #else testBracket chatQueryStats agentQueryStats test = - withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats} + withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats, printOutput = False} #endif tmpBracket test = do t <- getSystemTime From 7c5966df70f8e436e8c8a8bcb37eb95b56e1be4a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 15 Feb 2025 17:09:11 +0000 Subject: [PATCH 063/289] core: fix postgres test (#5631) --- tests/ChatTests/DBUtils/Postgres.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ChatTests/DBUtils/Postgres.hs b/tests/ChatTests/DBUtils/Postgres.hs index 840c3d9a38..2b160379bb 100644 --- a/tests/ChatTests/DBUtils/Postgres.hs +++ b/tests/ChatTests/DBUtils/Postgres.hs @@ -1,5 +1,6 @@ module ChatTests.DBUtils.Postgres where data TestParams = TestParams - { tmpPath :: FilePath + { tmpPath :: FilePath, + printOutput :: Bool } From 88bb387b1b20120782412f9ffbf6a9433ab8d881 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 15 Feb 2025 17:15:25 +0000 Subject: [PATCH 064/289] core: 6.3.0.5 (simplexmq 6.3.0.5) --- 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 13697d6caf..b867c0e93b 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: fa67d128d1d1c3edb3c49886f096c36e3f4da0d0 + tag: c192339af9e9342902731f2d49ff380359be0dec source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 13cb32469d..a54d3a35a2 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."fa67d128d1d1c3edb3c49886f096c36e3f4da0d0" = "13fl760rbw75rqa1zs4f130w7a75dfqw43w52jjnfpxgqis3dd2z"; + "https://github.com/simplex-chat/simplexmq.git"."c192339af9e9342902731f2d49ff380359be0dec" = "1h81kmwllqza7wil7w20ia934b6iny3dch9fln0x95l9q3f4zww7"; "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 7fbe800025..982e00fe37 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.0.4 +version: 6.3.0.5 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From cb3ace5f715fd2b879ca154d3567306f9482a25e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 15 Feb 2025 20:39:43 +0000 Subject: [PATCH 065/289] 6.2.5: ios 264, android 274, desktop 92 --- 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 70ef22cf49..b1ae92f0dc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -517,9 +517,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; 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 = ""; }; @@ -673,9 +673,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -756,8 +756,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.5.0-6pBHcImAm50496ZlXLxM9e.a */, ); path = Libraries; sourceTree = ""; @@ -1935,7 +1935,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1960,7 +1960,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1984,7 +1984,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2009,7 +2009,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2025,11 +2025,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2045,11 +2045,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2070,7 +2070,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2085,7 +2085,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2107,7 +2107,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2122,7 +2122,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2144,7 +2144,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2170,7 +2170,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2195,7 +2195,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2221,7 +2221,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2246,7 +2246,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2261,7 +2261,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2280,7 +2280,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 259; + CURRENT_PROJECT_VERSION = 264; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2295,7 +2295,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.4; + MARKETING_VERSION = 6.2.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 8c172d753f..623ed88728 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.2.4 -android.version_code=268 +android.version_name=6.2.5 +android.version_code=274 -desktop.version_name=6.2.4 -desktop.version_code=87 +desktop.version_name=6.2.5 +desktop.version_code=92 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 5a0821f9fc9727d848d5b24d8dda84160924ad9c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 15 Feb 2025 21:44:38 +0000 Subject: [PATCH 066/289] 6.3-beta.5: ios 265, android 276, desktop 93 --- 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 d5acf0df8f..a87482bc93 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.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.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 */; }; @@ -527,8 +527,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.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.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 = ""; }; @@ -680,8 +680,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -762,8 +762,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */, ); path = Libraries; sourceTree = ""; @@ -1947,7 +1947,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1996,7 +1996,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2037,7 +2037,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2057,7 +2057,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2082,7 +2082,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2119,7 +2119,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2156,7 +2156,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2207,7 +2207,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2258,7 +2258,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2292,7 +2292,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 263; + CURRENT_PROJECT_VERSION = 265; 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 2b53258fc8..02771976c6 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-beta.4 -android.version_code=273 +android.version_name=6.3-beta.5 +android.version_code=276 -desktop.version_name=6.3-beta.4 -desktop.version_code=91 +desktop.version_name=6.3-beta.5 +desktop.version_code=93 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From f2430cc57fc52216e345721d277f1ebb6dfbc3a1 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 17 Feb 2025 11:25:39 +0000 Subject: [PATCH 067/289] flatpak: update metainfo (#5634) --- .../flatpak/chat.simplex.simplex.metainfo.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 8141046d8a..db84b67e63 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,27 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-5:

+
    +
  • change media filenames when forwarding.
  • +
  • fully delete wallpapers when deleting user or chat.
  • +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html From 704bab171deb642a268d07780823a6ea43244974 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:41:27 +0400 Subject: [PATCH 068/289] docs: member limits rfc (#5635) --- docs/rfcs/2025-02-17-member-send-limits.md | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/rfcs/2025-02-17-member-send-limits.md diff --git a/docs/rfcs/2025-02-17-member-send-limits.md b/docs/rfcs/2025-02-17-member-send-limits.md new file mode 100644 index 0000000000..cc98d63e46 --- /dev/null +++ b/docs/rfcs/2025-02-17-member-send-limits.md @@ -0,0 +1,97 @@ +# Member sending limits + +## Problem + +Rate limiting member sending to prevent abuse in groups. + +## Solution + +Each member record to have `rateLimit :: Maybe MemberRateLimit`, Nothing by default. Nothing means default rate limit for regular members (`MRLWindow`), and no limit for owners/admins/moderators (`MRLNone`). Default rate limit is defined in configuration, e.g. limit of 15 messages in 60 second window, or could be defined in group profile. + +Rate limit can be overridden per member by sending `XGrpMemRestrict` with updated `rateLimit` by member of same role or higher, similar to changing roles. `APIRateLimitMember` allows to enable or disable rate limiting for member, we could also provide more granular control in it (pass `WindowLimit`), but it seems unnecessary complex for UI. + +```haskell +data MemberRateLimit + = MRLNone -- default for owners, admins, moderators + | MRLWindow WindowLimit + deriving (Eq, Show) + +data WindowLimit = WindowLimit + { window :: Int, -- seconds + limit :: Int + } + deriving (Eq, Show) + +-- sent in XGrpMemRestrict +data MemberRestrictions = MemberRestrictions + { restriction :: MemberRestrictionStatus, + rateLimit :: Maybe MemberRateLimit -- Nothing means use default + } + deriving (Eq, Show) + +-- new api in ChatCommand +| APIRateLimitMember GroupId GroupMemberId + +-- new response in ChatResponse +| CRMemberRateLimit {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + +-- new field in GroupMember +data GroupMember = GroupMember { + ... + rateLimit :: Maybe MemberRateLimit, + ... +} +``` + +Rate limit overrides to be persisted on group member records. + +```sql +ALTER TABLE group_members ADD COLUMN rate_limit TEXT; -- MemberRateLimit JSON encoded +``` + +Limits can be tracked inside fixed windows both for receiving and sending. + +```haskell +data ChatController = ChatController { + ... + memberLimits :: TMap (GroupId, MemberId) (TVar MemberRateLimitWindow), + ownLimits :: TMap GroupId (TVar MemberRateLimitWindow), + ... +} + +data MemberRateLimitWindow = MemberRateLimitWindow { + startedAt :: UTCTime, + windowLimit :: WindowLimit, + messages :: Int +} +``` + +Client to track limit for each writing member in state - `memberLimits`. If current window's interval has passed, checked against `startedAt` of `MemberRateLimitWindow`, reset `messages` counter. + +Track own limits per group - `ownLimits`. When limit in group is reached, `CRGroupSendingLimited blocked = True` event is sent to UI to block sending in group. Unblock group sending in UI by scheduling background process to send `CRGroupSendingLimited blocked = False` after interval? + +```haskell +-- new event in ChatResponse +| CRGroupSendingLimited {user :: User, groupInfo :: GroupInfo, blocked :: Bool} +``` + +### Receiving messages from limited members + +Receiving message from member that exceeded their limit would fail it as prohibited. We can limit content messages, updates, reactions, etc. Practically only regular members would be limited, so there's no need for limiting service messages. Should we limit deletes? + +Problems: + +- Inconsistencies in window tracking on sending and receiving sides -> track based on item_ts?; +- Subscription produces message surges; +- Server downtime (or network outage) leads to accumulation of scheduled messages on sending side -> item_ts tracking wouldn't help. Issue is similar to subscription, where many messages can be received in short span legitimately. +- This approach doesn't reduce load of retrieving message and of all machinery passing from agent to chat. + +Subscription issue could be solved by not tracking limits during subscription (client "knows" when it has subscribed) and for some time after it. For how long - 30 seconds / 1 minute? - arbitrary, longer absence periods lead to more not yet retrieved messages, freed quotas resulting in more sent pending messages. + +Better solution would be to not drop (prohibit) messages at all, but stop reception per connection for periods of time. Possible approaches: + +- Don't send (delay) ACK - bad idea as it would lead to repeated processing on client restart, and other possible failures in delivery. +- ACK with parameter `wait` for server - server would wait before sending next message. +- Unsubscribe (kill client?)/resubscribe after some time - more expensive. +- Signal agent to hold on next message - similar to delayed ACK but at least we don't process message. (for example, also via ACK but parameter to wait is for agent) +- Rework chat-to-agent communication to communicate via per connection queue (currently it's single `subQ`) - complex - but we get less expensive "unsubscribe"? Essentially agent still holds on to message like in previous approach. From 9d1329498bf6341dd874187eeeb207dfabdd513c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 18 Feb 2025 01:21:40 +0700 Subject: [PATCH 069/289] ios: open chat on first unread, "scroll" to quoted items that were not loaded (#5392) * ios: open chat on first unread, "scroll" to quoted items that were not loaded * more changes * changes * unused * fix reveal logic * debug * changes * test * Revert "test" This reverts commit 553be124d5233c6afad18ef37f81fce659958101. * change * change * changes * changes * changes * commented deceleration logic * changes * fixes * optimized item identifiers to use merged item directly * fixed counters * encreased initial and preload counters * fix initial loading and trimming items * optimize * allow marking read * 10 instead of 5 * performance * one more parameter in hash * disable trimming * performance * performance - in background * optimization * next/prev * changes * markread * finally * less logs * read * change after merge * trimming, edge cases * wait until items loaded * Revert "wait until items loaded" This reverts commit 895218b97802669c10d22028d22b9da8a8c1f01a. * progress indicator * optimization * disable scroll helper * experiment * Revert "experiment" This reverts commit c952c9e6230a05b639ab812431781f1353c94c5d. * jump * no read * layoutIfNeeded * changes * EndlessScrollView * read * changes * changes * changes * reduce time to open a chat (by ~300ms) * open from the first unread when clicking member chat * refactored and removed unused code * handling search emptiness to scroll to correct position * changes * read state maintain * remove protocol * avoid parsing chatId * pass chat * changes * remove reveal * refactor spaghetti * remove ItemsScrollModel * rename * remove setUpdateListener * unused * optimization * scrollToTop * fix * scrollbar working again * scrollToBottom * fix * scrollBar hiding when not many items on screen * small safer change --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 63 +- apps/ios/Shared/Model/SimpleXAPI.swift | 43 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 10 +- .../Views/Chat/ChatItem/CIFileView.swift | 20 +- .../Views/Chat/ChatItem/CIImageView.swift | 3 +- .../Views/Chat/ChatItem/CIVoiceView.swift | 8 +- .../Chat/ChatItem/FramedCIVoiceView.swift | 10 +- .../Views/Chat/ChatItem/FramedItemView.swift | 73 +- .../Chat/ChatItem/FullScreenMediaView.swift | 4 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 37 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 329 ++++++++++ .../Shared/Views/Chat/ChatItemsMerger.swift | 457 +++++++++++++ .../Shared/Views/Chat/ChatScrollHelpers.swift | 163 +++++ apps/ios/Shared/Views/Chat/ChatView.swift | 530 ++++++++++----- .../Shared/Views/Chat/EndlessScrollView.swift | 621 ++++++++++++++++++ .../Chat/Group/GroupMemberInfoView.swift | 12 +- apps/ios/Shared/Views/Chat/ReverseList.swift | 371 ----------- .../Shared/Views/Chat/ScrollViewCells.swift | 52 ++ .../Views/ChatList/ChatPreviewView.swift | 2 - .../Views/LocalAuth/LocalAuthView.swift | 1 + .../UserSettings/AppearanceSettings.swift | 4 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 24 +- apps/ios/SimpleXChat/API.swift | 11 +- apps/ios/SimpleXChat/APITypes.swift | 40 +- 24 files changed, 2177 insertions(+), 711 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ChatItemsLoader.swift create mode 100644 apps/ios/Shared/Views/Chat/ChatItemsMerger.swift create mode 100644 apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift create mode 100644 apps/ios/Shared/Views/Chat/EndlessScrollView.swift delete mode 100644 apps/ios/Shared/Views/Chat/ReverseList.swift create mode 100644 apps/ios/Shared/Views/Chat/ScrollViewCells.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 90c95f4fc5..ae8a6f445e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -53,7 +53,11 @@ class ItemsModel: ObservableObject { var itemAdded = false { willSet { publisher.send() } } - + + // set listener here that will be notified on every add/delete of a chat item + let chatState = ActiveChatState() + var chatItemsChangesListener: RecalculatePositions = RecalculatePositions() + // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @Published var isLoading = false @@ -83,18 +87,16 @@ class ItemsModel: ObservableObject { } catch {} } Task { - if let chat = ChatModel.shared.getChat(chatId) { - await MainActor.run { self.isLoading = true } -// try? await Task.sleep(nanoseconds: 5000_000000) - await loadChat(chat: chat) - navigationTimeout.cancel() - progressTimeout.cancel() - await MainActor.run { - self.isLoading = false - self.showLoadingProgress = false - willNavigate() - ChatModel.shared.chatId = chatId - } + await MainActor.run { self.isLoading = true } + // try? await Task.sleep(nanoseconds: 5000_000000) + await loadChat(chatId: chatId) + navigationTimeout.cancel() + progressTimeout.cancel() + await MainActor.run { + self.isLoading = false + self.showLoadingProgress = false + willNavigate() +// ChatModel.shared.chatId = id } } } @@ -546,6 +548,7 @@ final class ChatModel: ObservableObject { ci.meta.itemStatus = status } im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) + im.chatItemsChangesListener.added((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) im.itemAdded = true ChatItemDummyModel.shared.sendUpdate() return true @@ -591,8 +594,9 @@ final class ChatModel: ObservableObject { // remove from current chat if chatId == cInfo.id { if let i = getChatItemIndex(cItem) { - _ = withAnimation { - im.reversedChatItems.remove(at: i) + withAnimation { + let item = im.reversedChatItems.remove(at: i) + im.chatItemsChangesListener.removed([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) } } } @@ -641,6 +645,7 @@ final class ChatModel: ObservableObject { let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { im.reversedChatItems.insert(cItem, at: 0) + im.chatItemsChangesListener.added((cItem.id, cItem.isRcvNew), 0) im.itemAdded = true } return cItem @@ -664,7 +669,6 @@ final class ChatModel: ObservableObject { // update preview _updateChat(cInfo.id) { chat in self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) - self.updateFloatingButtons(unreadCount: 0) ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() } @@ -682,12 +686,6 @@ final class ChatModel: ObservableObject { } } - private func updateFloatingButtons(unreadCount: Int) { - let fbm = ChatView.FloatingButtonModel.shared - fbm.totalUnread = unreadCount - fbm.objectWillChange.send() - } - func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { if let cItem = aboveItem { if chatId == cInfo.id, let i = getChatItemIndex(cItem) { @@ -716,7 +714,6 @@ final class ChatModel: ObservableObject { ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions ? markedMentionsCount : markedCount self.decreaseUnreadCounter(user: self.currentUser!, by: by) - self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount) } } } @@ -746,16 +743,22 @@ final class ChatModel: ObservableObject { if chatId == cInfo.id { chatItemStatuses = [:] im.reversedChatItems = [] + im.chatItemsChangesListener.cleared() } } func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { if self.chatId == cInfo.id { + var unreadItemIds: Set = [] for itemId in itemIds { if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { + if im.reversedChatItems[i].isRcvNew { + unreadItemIds.insert(itemId) + } markChatItemRead_(i) } } + im.chatItemsChangesListener.read(unreadItemIds, im.reversedChatItems.reversed()) } self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) } @@ -783,9 +786,6 @@ final class ChatModel: ObservableObject { } func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) { - if chatId == ChatModel.shared.chatId { - ChatView.FloatingButtonModel.shared.totalUnread += count - } let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0) self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions) subject.send() @@ -979,12 +979,17 @@ final class ChatModel: ObservableObject { // returns the previous member in the same merge group and the count of members in this group func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange) -> (GroupMember?, Int) { + let items = im.reversedChatItems var prevMember: GroupMember? = nil var memberIds: Set = [] for i in range { - if case let .groupRcv(m) = im.reversedChatItems[i].chatDir { - if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } - memberIds.insert(m.groupMemberId) + if i < items.count { + if case let .groupRcv(m) = items[i].chatDir { + if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } + memberIds.insert(m.groupMemberId) + } + } else { + logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count)") } } return (prevMember, memberIds.count) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3418531d3d..056340dcaf 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -328,38 +328,27 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] { let loadItemsPerPage = 50 -func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat { - let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search)) - if case let .apiChat(_, chat) = r { return Chat.init(chat) } +func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { + let r = await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search)) + if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) } throw r } -func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { - let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) - if case let .apiChat(_, chat) = r { return chat.chatItems } - throw r +func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { + await loadChat(chatId: chat.chatInfo.id) } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async { - do { - let cInfo = chat.chatInfo - let m = ChatModel.shared - let im = ItemsModel.shared - m.chatItemStatuses = [:] - if clearItems { - await MainActor.run { im.reversedChatItems = [] } +func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async { + let m = ChatModel.shared + let im = ItemsModel.shared + m.chatItemStatuses = [:] + if clearItems { + await MainActor.run { + im.reversedChatItems = [] + ItemsModel.shared.chatItemsChangesListener.cleared() } - let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) - await MainActor.run { - im.reversedChatItems = chat.chatItems.reversed() - m.updateChatInfo(chat.chatInfo) - if (replaceChat) { - m.replaceChat(chat.chatInfo.id, chat) - } - } - } catch let error { - logger.error("loadChat error: \(responseError(error))") } + await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 }) } func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { @@ -869,7 +858,7 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? { let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) - + if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection} throw r } @@ -2172,7 +2161,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .groupLinkConnecting(user, groupInfo, hostMember): if !active(user) { return } - + await MainActor.run { m.updateGroup(groupInfo) if let hostConn = hostMember.activeConn { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index a9fd0bf3ce..8fe4260a1e 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -684,17 +684,23 @@ struct ChatTTLOption: View { ) { progressIndicator = true Task { + let m = ChatModel.shared do { try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl) - await loadChat(chat: chat, clearItems: true, replaceChat: true) + await loadChat(chat: chat, clearItems: true) await MainActor.run { progressIndicator = false currentChatItemTTL = chatItemTTL + if ItemsModel.shared.reversedChatItems.isEmpty && m.chatId == chat.id, + let chat = m.getChat(chat.id) { + chat.chatItems = [] + m.replaceChat(chat.id, chat) + } } } catch let error { logger.error("setChatTTL error \(responseError(error))") - await loadChat(chat: chat, clearItems: true, replaceChat: true) + await loadChat(chat: chat, clearItems: true) await MainActor.run { chatItemTTL = currentChatItemTTL progressIndicator = false diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index a785f3e6d8..bc44afdd7f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -293,16 +293,16 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentFile) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample()) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10))) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation)) - 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)) - ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile) + 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 }) } .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 d491563913..4c8221d887 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem + var scrollToItemId: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat var imgWidth: CGFloat? @@ -25,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, image: uiImage, showView: $showFullScreenImage) + FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, 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 ff4378c715..e2fec02ba1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -498,10 +498,10 @@ struct CIVoiceView_Previews: PreviewProvider { duration: 30, allowMenu: Binding.constant(true) ) - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .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)) } .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 47a2cbb6cb..f4e2a4135a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -92,11 +92,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10))) - 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.")) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote) + 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 }) } .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 22adf12a64..e411befbfa 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -12,9 +12,9 @@ import SimpleXChat struct FramedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme - @EnvironmentObject var scrollModel: ReverseListScrollModel @ObservedObject var chat: Chat var chatItem: ChatItem + var scrollToItemId: (ChatItem.ID) -> Void var preview: UIImage? var maxWidth: CGFloat = .infinity @State var msgWidth: CGFloat = 0 @@ -60,8 +60,12 @@ struct FramedItemView: View { .onTapGesture { if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { - scrollModel.scrollToItem(id: ci.id) + scrollToItemId(ci.id) } + } else if let id = qi.itemId { + scrollToItemId(id) + } else { + showQuotedItemDoesNotExistAlert() } } } else if let itemForwarded = chatItem.meta.itemForwarded { @@ -117,7 +121,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, scrollToItemId: scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -338,6 +342,13 @@ struct FramedItemView: View { return videoWidth } } + + private func showQuotedItemDoesNotExistAlert() { + AlertManager.shared.showAlertMsg( + title: "No message", + message: "This message was deleted or not received yet." + ) + } } @ViewBuilder func toggleSecrets(_ ft: [FormattedText]?, _ showSecrets: Binding, _ v: V) -> some View { @@ -385,14 +396,14 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color { struct FramedItemView_Previews: PreviewProvider { static var previews: some View { Group{ - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), 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)), 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)), 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)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), 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 "), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true)) + 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)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -401,16 +412,16 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), 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), 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), 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), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), 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), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), 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), 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), allowMenu: Binding.constant(true)) + 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)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) @@ -420,16 +431,16 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), 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)), 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)), 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)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), 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)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), 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)), 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)), allowMenu: Binding.constant(true)) + 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)) } .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 044ee2a26d..c3aad9490d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -13,8 +13,8 @@ import AVKit struct FullScreenMediaView: View { @EnvironmentObject var m: ChatModel - @EnvironmentObject var scrollModel: ReverseListScrollModel @State var chatItem: ChatItem + var scrollToItemId: ((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 - scrollModel.scrollToItem(id: chatItem.id) + scrollToItemId?(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 ebbc55a932..5d09e153d5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -35,18 +35,21 @@ struct ChatItemView: View { @Environment(\.showTimestamp) var showTimestamp: Bool @Environment(\.revealed) var revealed: Bool var chatItem: ChatItem + var scrollToItemId: (ChatItem.ID) -> Void var maxWidth: CGFloat = .infinity @Binding var allowMenu: Bool init( chat: Chat, chatItem: ChatItem, + scrollToItemId: @escaping (ChatItem.ID) -> Void, showMember: Bool = false, maxWidth: CGFloat = .infinity, allowMenu: Binding = .constant(false) ) { self.chat = chat self.chatItem = chatItem + self.scrollToItemId = scrollToItemId self.maxWidth = maxWidth _allowMenu = allowMenu } @@ -90,6 +93,7 @@ struct ChatItemView: View { return FramedItemView( chat: chat, chatItem: chatItem, + scrollToItemId: scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: adjustedMaxWidth, @@ -244,15 +248,15 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂")) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true) + 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) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 70)) @@ -272,7 +276,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)), quotedItem: nil, file: nil - ) + ), + scrollToItemId: { _ in } ) ChatItemView( chat: Chat.sampleData, @@ -282,7 +287,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2), quotedItem: nil, file: nil - ) + ), + scrollToItemId: { _ in } ) ChatItemView( chat: Chat.sampleData, @@ -292,7 +298,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin), quotedItem: nil, file: nil - ) + ), + scrollToItemId: { _ in } ) ChatItemView( chat: Chat.sampleData, @@ -302,7 +309,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)), quotedItem: nil, file: nil - ) + ), + scrollToItemId: { _ in } ) ChatItemView( chat: Chat.sampleData, @@ -312,7 +320,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { content: ciFeatureContent, quotedItem: nil, file: nil - ) + ), + scrollToItemId: { _ in } ) } .environment(\.revealed, true) diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift new file mode 100644 index 0000000000..5920b8eef7 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -0,0 +1,329 @@ +// +// ChatItemsLoader.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 17.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SimpleXChat +import SwiftUI + +let TRIM_KEEP_COUNT = 200 + +func apiLoadMessages( + _ chatId: ChatId, + _ pagination: ChatPagination, + _ chatState: ActiveChatState, + _ search: String = "", + _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } +) async { + let chat: Chat + let navInfo: NavigationInfo + do { + (chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search) + } catch let error { + logger.error("apiLoadMessages error: \(responseError(error))") + return + } + + let chatModel = ChatModel.shared + + // 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 + let paginationIsInitial = switch pagination { case .initial: true; default: false } + let paginationIsLast = switch pagination { case .last: true; default: false } + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) { + return + } + + let unreadAfterItemId = chatState.unreadAfterItemId + + let oldItems = Array(ItemsModel.shared.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 { + chatModel.addChat(chat) + } + await MainActor.run { + chatModel.chatItemStatuses.removeAll() + ItemsModel.shared.reversedChatItems = chat.chatItems.reversed() + chatModel.updateChatInfo(chat.chatInfo) + chatModel.chatId = chat.chatInfo.id + chatState.splits = newSplits + if !chat.chatItems.isEmpty { + chatState.unreadAfterItemId = chat.chatItems.last!.id + } + chatState.totalAfter = navInfo.afterTotal + chatState.unreadTotal = chat.chatStats.unreadCount + chatState.unreadAfter = navInfo.afterUnread + chatState.unreadAfterNewestLoaded = navInfo.afterUnread + } + case let .before(paginationChatItemId, _): + newItems.append(contentsOf: oldItems) + let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId }) + guard let indexInCurrentItems else { return } + let (newIds, _) = mapItemsToIds(chat.chatItems) + let wasSize = newItems.count + let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() } + let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination( + unreadAfterItemId, &newItems, newIds, 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) + } + case let .after(paginationChatItemId, _): + newItems.append(contentsOf: oldItems) + let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId }) + guard let indexInCurrentItems else { return } + + let mappedItems = mapItemsToIds(chat.chatItems) + let newIds = mappedItems.0 + let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination( + mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits + ) + let indexToAdd = min(indexInCurrentItems + 1, newItems.count) + let indexToAddIsLast = indexToAdd == newItems.count + newItems.insert(contentsOf: chat.chatItems, at: indexToAdd) + 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) + // loading clear bottom area, updating number of unread items after the newest loaded item + if indexToAddIsLast { + chatState.unreadAfterNewestLoaded -= unreadInLoaded + } + } + case .around: + newItems.append(contentsOf: oldItems) + let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + // currently, items will always be added on top, which is index 0 + newItems.insert(contentsOf: chat.chatItems, at: 0) + let newReversed: [ChatItem] = newItems.reversed() + await MainActor.run { + ItemsModel.shared.reversedChatItems = newReversed + chatState.splits = [chat.chatItems.last!.id] + newSplits + chatState.unreadAfterItemId = chat.chatItems.last!.id + chatState.totalAfter = navInfo.afterTotal + chatState.unreadTotal = chat.chatStats.unreadCount + chatState.unreadAfter = navInfo.afterUnread + // no need to set it, count will be wrong + // unreadAfterNewestLoaded.value = navInfo.afterUnread + } + case .last: + newItems.append(contentsOf: oldItems) + removeDuplicates(&newItems, chat) + newItems.append(contentsOf: chat.chatItems) + let items = newItems + await MainActor.run { + ItemsModel.shared.reversedChatItems = items.reversed() + chatModel.updateChatInfo(chat.chatInfo) + chatState.unreadAfterNewestLoaded = 0 + } + } +} + + +private class ModifiedSplits { + let oldUnreadSplitIndex: Int + let newUnreadSplitIndex: Int + let trimmedIds: Set + let newSplits: [Int64] + + init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set, newSplits: [Int64]) { + self.oldUnreadSplitIndex = oldUnreadSplitIndex + self.newUnreadSplitIndex = newUnreadSplitIndex + self.trimmedIds = trimmedIds + self.newSplits = newSplits + } +} + +private func removeDuplicatesAndModifySplitsOnBeforePagination( + _ unreadAfterItemId: Int64, + _ newItems: inout [ChatItem], + _ newIds: Set, + _ splits: [Int64], + _ visibleItemIndexes: ClosedRange +) -> ModifiedSplits { + var oldUnreadSplitIndex: Int = -1 + var newUnreadSplitIndex: Int = -1 + var lastSplitIndexTrimmed: Int? = nil + var allowedTrimming = true + var index = 0 + /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ + let trimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + let trimUpperBound = newItems.count - TRIM_KEEP_COUNT + let trimRange = trimUpperBound >= trimLowerBound ? trimLowerBound ... trimUpperBound : -1 ... -1 + var trimmedIds = Set() + let prevTrimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1 + let prevTrimUpperBound = newItems.count - TRIM_KEEP_COUNT + let prevItemTrimRange = prevTrimUpperBound >= prevTrimLowerBound ? prevTrimLowerBound ... prevTrimUpperBound : -1 ... -1 + var newSplits = splits + + newItems.removeAll(where: { + let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming + let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming + // may disable it after clearing the whole split range + if !splits.isEmpty && $0.id == splits.first { + // trim only in one split range + allowedTrimming = false + } + let indexInSplits = splits.firstIndex(of: $0.id) + if let indexInSplits { + lastSplitIndexTrimmed = indexInSplits + } + if invisibleItemToTrim { + if prevItemWasTrimmed { + trimmedIds.insert($0.id) + } else { + newUnreadSplitIndex = index + // prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead. + // this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction + if let lastSplitIndexTrimmed { + var new = newSplits + new[lastSplitIndexTrimmed] = $0.id + newSplits = new + } else { + newSplits = [$0.id] + newSplits + } + } + } + if unreadAfterItemId == $0.id { + oldUnreadSplitIndex = index + } + index += 1 + return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id) + }) + // will remove any splits that now becomes obsolete because items were merged + newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) } + return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits) +} + +private func removeDuplicatesAndModifySplitsOnAfterPagination( + _ unreadInLoaded: Int, + _ paginationChatItemId: Int64, + _ newItems: inout [ChatItem], + _ newIds: Set, + _ chat: Chat, + _ splits: [Int64] +) -> ([Int64], Int) { + var unreadInLoaded = unreadInLoaded + var firstItemIdBelowAllSplits: Int64? = nil + var splitsToRemove: Set = [] + let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId) + // Currently, it should always load from split range + let loadingFromSplitRange = indexInSplitRanges != nil + var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count { + Array(splits[indexInSplitRanges + 1 ..< splits.count]) + } else { + [] + } + newItems.removeAll(where: { new in + let duplicate = newIds.contains(new.id) + if loadingFromSplitRange && duplicate { + if splitsToMerge.contains(new.id) { + splitsToMerge.removeAll(where: { $0 == new.id }) + splitsToRemove.insert(new.id) + } else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty { + // we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items + firstItemIdBelowAllSplits = new.id + } + } + if duplicate && new.isRcvNew { + unreadInLoaded -= 1 + } + return duplicate + }) + var newSplits: [Int64] = [] + if firstItemIdBelowAllSplits != nil { + // no splits anymore, all were merged with bottom items + newSplits = [] + } else { + if !splitsToRemove.isEmpty { + var new = splits + new.removeAll(where: { splitsToRemove.contains($0) }) + newSplits = new + } + let enlargedSplit = splits.firstIndex(of: paginationChatItemId) + if let enlargedSplit { + // move the split to the end of loaded items + var new = splits + new[enlargedSplit] = chat.chatItems.last!.id + newSplits = new + } + } + return (newSplits, unreadInLoaded) +} + +private func removeDuplicatesAndUpperSplits( + _ newItems: inout [ChatItem], + _ chat: Chat, + _ splits: [Int64], + _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange +) async -> [Int64] { + if splits.isEmpty { + removeDuplicates(&newItems, chat) + return splits + } + + var newSplits = splits + let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() } + let (newIds, _) = mapItemsToIds(chat.chatItems) + var idsToTrim: [BoxedValue>] = [] + idsToTrim.append(BoxedValue(Set())) + var index = 0 + newItems.removeAll(where: { + let duplicate = newIds.contains($0.id) + if (!duplicate && visibleItemIndexes.lowerBound > index) { + idsToTrim.last?.boxedValue.insert($0.id) + } + if visibleItemIndexes.lowerBound > index, let firstIndex = newSplits.firstIndex(of: $0.id) { + newSplits.remove(at: firstIndex) + // closing previous range. All items in idsToTrim that ends with empty set should be deleted. + // Otherwise, the last set should be excluded from trimming because it is in currently visible split range + idsToTrim.append(BoxedValue(Set())) + } + + index += 1 + return duplicate + }) + if !idsToTrim.last!.boxedValue.isEmpty { + // it has some elements to trim from currently visible range which means the items shouldn't be trimmed + // Otherwise, the last set would be empty + idsToTrim.removeLast() + } + let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined() + if !allItemsToDelete.isEmpty { + newItems.removeAll(where: { allItemsToDelete.contains($0.id) }) + } + return newSplits +} + +// ids, number of unread items +private func mapItemsToIds(_ items: [ChatItem]) -> (Set, Int) { + var unreadInLoaded = 0 + var ids: Set = Set() + var i = 0 + while i < items.count { + let item = items[i] + ids.insert(item.id) + if item.isRcvNew { + unreadInLoaded += 1 + } + i += 1 + } + return (ids, unreadInLoaded) +} + +private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) { + let (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll { newIds.contains($0.id) } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift new file mode 100644 index 0000000000..fac359076f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -0,0 +1,457 @@ +// +// ChatItemsMerger.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 02.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MergedItems: Hashable, Equatable { + let items: [MergedItem] + let splits: [SplitRange] + // chat item id, index in list + let indexInParentItems: Dictionary + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine("\(items.hashValue)") + } + + static func create(_ items: [ChatItem], _ unreadCount: Int, _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems { + if items.isEmpty { + return MergedItems(items: [], splits: [], indexInParentItems: [:]) + } + + let unreadAfterItemId = chatState.unreadAfterItemId + let itemSplits = chatState.splits + var mergedItems: [MergedItem] = [] + // Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems + var splitRanges: [SplitRange] = [] + var indexInParentItems = Dictionary() + var index = 0 + var unclosedSplitIndex: Int? = nil + var unclosedSplitIndexInParent: Int? = nil + var visibleItemIndexInParent = -1 + var unreadBefore = unreadCount - 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 + let category = item.mergeCategory + let itemIsSplit = itemSplits.contains(item.id) + + if item.id == unreadAfterItemId { + unreadBefore = unreadCount - chatState.unreadAfter + } + if item.isRcvNew { + unreadBefore -= 1 + } + + let revealed = item.mergeCategory == nil || revealedItems.contains(item.id) + if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _, _) = recent, mergeCategory == category, let first = items.boxedValue.first, !revealedItems.contains(first.item.id) && !itemIsSplit { + let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore) + items.boxedValue.append(listItem) + + if item.isRcvNew { + unreadIds.boxedValue.insert(item.id) + } + if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems { + if revealed { + lastRevealedIdsInMergedItems.boxedValue.append(item.id) + } + lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index + } + } else { + visibleItemIndexInParent += 1 + let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore) + if item.mergeCategory != nil { + if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil { + lastRevealedIdsInMergedItems = BoxedValue(revealedItems.contains(item.id) ? [item.id] : []) + } else if revealed, let lastRevealedIdsInMergedItems { + lastRevealedIdsInMergedItems.boxedValue.append(item.id) + } + lastRangeInReversedForMergedItems = BoxedValue(index ... index) + recent = MergedItem.grouped( + items: BoxedValue([listItem]), + revealed: revealed, + revealedIdsWithinGroup: lastRevealedIdsInMergedItems!, + rangeInReversed: lastRangeInReversedForMergedItems!, + mergeCategory: item.mergeCategory, + unreadIds: BoxedValue(item.isRcvNew ? Set(arrayLiteral: item.id) : Set()), + startIndexInReversedItems: index, + hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1)) + ) + } else { + lastRangeInReversedForMergedItems = nil + recent = MergedItem.single( + item: listItem, + startIndexInReversedItems: index, + hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1)) + ) + } + mergedItems.append(recent!) + } + if itemIsSplit { + // 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)) + } + unclosedSplitIndex = index + unclosedSplitIndexInParent = visibleItemIndexInParent + } else if index + 1 == items.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)) + } + indexInParentItems[item.id] = visibleItemIndexInParent + index += 1 + } + return MergedItems( + items: mergedItems, + splits: splitRanges, + indexInParentItems: indexInParentItems + ) + } + + // 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 + return indexInParentItems.count == im.reversedChatItems.count && + splits.count == im.chatState.splits.count && + // that's just an optimization because most of the time only 1 split exists + ((splits.count == 1 && splits[0].itemId == im.chatState.splits[0]) || splits.map({ split in split.itemId }).sorted() == im.chatState.splits.sorted()) + } +} + + +enum MergedItem: Identifiable, Hashable, Equatable { + // equatable and hashable implementations allows to see the difference and correctly scroll to items we want + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.hash == rhs.hash + } + + var id: Int64 { newest().item.id } + + func hash(into hasher: inout Hasher) { + hasher.combine(hash) + } + + var hash: String { + switch self { + case .single(_, _, let hash): hash + " 1" + case .grouped(let items, _, _, _, _, _, _, let hash): hash + " \(items.boxedValue.count)" + } + } + + // the item that is always single, cannot be grouped and always revealed + case single( + item: ListItem, + startIndexInReversedItems: Int, + hash: String + ) + + /** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed, + * there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance + * of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of + * visible items in ChatView's EndlessScrollView */ + case grouped ( + items: BoxedValue<[ListItem]>, + revealed: Bool, + // it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action + // it's the same list instance for all Grouped items within revealed group + /** @see reveal */ + revealedIdsWithinGroup: BoxedValue<[Int64]>, + rangeInReversed: BoxedValue>, + mergeCategory: CIMergeCategory?, + unreadIds: BoxedValue>, + startIndexInReversedItems: Int, + hash: String + ) + + func revealItems(_ reveal: Bool, _ revealedItems: Binding>) { + if case .grouped(let items, _, let revealedIdsWithinGroup, _, _, _, _, _) = self { + var newRevealed = revealedItems.wrappedValue + var i = 0 + if reveal { + while i < items.boxedValue.count { + newRevealed.insert(items.boxedValue[i].item.id) + i += 1 + } + } else { + while i < revealedIdsWithinGroup.boxedValue.count { + newRevealed.remove(revealedIdsWithinGroup.boxedValue[i]) + i += 1 + } + revealedIdsWithinGroup.boxedValue.removeAll() + } + revealedItems.wrappedValue = newRevealed + } + } + + var startIndexInReversedItems: Int { + get { + switch self { + case let .single(_, startIndexInReversedItems, _): startIndexInReversedItems + case let .grouped(_, _, _, _, _, _, startIndexInReversedItems, _): startIndexInReversedItems + } + } + } + + func hasUnread() -> Bool { + switch self { + case let .single(item, _, _): item.item.isRcvNew + case let .grouped(_, _, _, _, _, unreadIds, _, _): !unreadIds.boxedValue.isEmpty + } + } + + func newest() -> ListItem { + switch self { + case let .single(item, _, _): item + case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[0] + } + } + + func oldest() -> ListItem { + switch self { + case let .single(item, _, _): item + case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[items.boxedValue.count - 1] + } + } + + func lastIndexInReversed() -> Int { + switch self { + case .single: startIndexInReversedItems + case let .grouped(items, _, _, _, _, _, _, _): startIndexInReversedItems + items.boxedValue.count - 1 + } + } +} + +struct SplitRange { + let itemId: Int64 + /** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first]) + * so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance + * (3, 4 indexes of the splitRange with the split itself at index 3) + * */ + let indexRangeInReversed: ClosedRange + /** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */ + let indexRangeInParentItems: ClosedRange +} + +struct ListItem: Hashable { + let item: ChatItem + let prevItem: ChatItem? + let nextItem: ChatItem? + // how many unread items before (older than) this one (excluding this one) + let unreadBefore: Int + + private func chatDirHash(_ chatDir: CIDirection?) -> Int { + guard let chatDir else { return 0 } + return switch chatDir { + case .directSnd: 0 + case .directRcv: 1 + case .groupSnd: 2 + case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash + case .localSnd: 4 + case .localRcv: 5 + } + } + + // using meta.hashValue instead of parts takes much more time so better to use partial meta here + func genHash(_ prevRevealed: Bool, _ nextRevealed: Bool) -> String { + "\(item.meta.itemId) \(item.meta.updatedAt.hashValue) \(item.meta.itemEdited) \(item.meta.itemDeleted?.hashValue ?? 0) \(item.meta.itemTimed?.hashValue ?? 0) \(item.meta.itemStatus.hashValue) \(item.meta.sentViaProxy ?? false) \(item.mergeCategory?.hashValue ?? 0) \(chatDirHash(item.chatDir)) \(item.reactions.hashValue) \(item.meta.isRcvNew) \(item.text.hash) \(item.file?.hashValue ?? 0) \(item.quotedItem?.itemId ?? 0) \(unreadBefore) \(prevItem?.id ?? 0) \(chatDirHash(prevItem?.chatDir)) \(prevItem?.mergeCategory?.hashValue ?? 0) \(prevRevealed) \(nextItem?.id ?? 0) \(chatDirHash(nextItem?.chatDir)) \(nextItem?.mergeCategory?.hashValue ?? 0) \(nextRevealed)" + } +} + +class ActiveChatState { + var splits: [Int64] = [] + var unreadAfterItemId: Int64 = -1 + // total items after unread after item (exclusive) + var totalAfter: Int = 0 + var unreadTotal: Int = 0 + // exclusive + var unreadAfter: Int = 0 + // exclusive + var unreadAfterNewestLoaded: Int = 0 + + func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) { + guard let toItemId else { return } + let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId }) + let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId }) + guard let currentIndex, let newIndex else { + return + } + unreadAfterItemId = toItemId + let unreadDiff = newIndex > currentIndex + ? -nonReversedItems[currentIndex + 1.. fromIndex + ? -nonReversedItems[fromIndex + 1..: Equatable, Hashable { + static func == (lhs: BoxedValue, rhs: BoxedValue) -> Bool { + lhs.boxedValue == rhs.boxedValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine("\(self)") + } + + var boxedValue : T + init(_ value: T) { + self.boxedValue = value + } +} + +@MainActor +func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange { + let zero = 0 ... 0 + let items = mergedItems.items + if items.isEmpty { + return zero + } + let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil + let oldest = items.count > listState.lastVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil + guard let newest, let oldest else { + return zero + } + let size = ItemsModel.shared.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() + return range +} + +class RecalculatePositions { + private var chatState: ActiveChatState { get { ItemsModel.shared.chatState } } + + func read(_ itemIds: Set?, _ newItems: [ChatItem]) { + guard let itemIds else { + // special case when the whole chat became read + chatState.unreadTotal = 0 + chatState.unreadAfter = 0 + return + } + var unreadAfterItemIndex: Int = -1 + // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster + var i = newItems.count - 1 + var ids = itemIds + // intermediate variables to prevent re-setting state value a lot of times without reason + var newUnreadTotal = chatState.unreadTotal + var newUnreadAfter = chatState.unreadAfter + while i >= 0 { + let item = newItems[i] + if item.id == chatState.unreadAfterItemId { + unreadAfterItemIndex = i + } + if ids.contains(item.id) { + // was unread, now this item is read + if (unreadAfterItemIndex == -1) { + newUnreadAfter -= 1 + } + newUnreadTotal -= 1 + ids.remove(item.id) + if ids.isEmpty { + break + } + } + i -= 1 + } + chatState.unreadTotal = newUnreadTotal + chatState.unreadAfter = newUnreadAfter + } + func added(_ item: (Int64, Bool), _ index: Int) { + if item.1 { + chatState.unreadAfter += 1 + chatState.unreadTotal += 1 + } + } + func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) { + var newSplits: [Int64] = [] + for split in chatState.splits { + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split }) + // deleted the item that was right before the split between items, find newer item so it will act like the split + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + // it the whole section is gone and splits overlap, don't add it at all + if let newSplit, !newSplits.contains(newSplit) { + newSplits.append(newSplit) + } + } else { + newSplits.append(split) + } + } + chatState.splits = newSplits + + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId }) + // unread after item was removed + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil + if newUnreadAfterItemId == nil { + // everything on top (including unread after item) were deleted, take top item as unread after id + newUnreadAfterItemId = newItems.first?.id + } + if let newUnreadAfterItemId { + chatState.unreadAfterItemId = newUnreadAfterItemId + chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count + chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count + chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count + if newUnreadAfterItemWasNull { + // since the unread after item was moved one item after initial position, adjust counters accordingly + if newItems.first?.isRcvNew == true { + chatState.unreadTotal += 1 + chatState.unreadAfter -= 1 + } + } + } else { + // all items were deleted, 0 items in chatItems + chatState.unreadAfterItemId = -1 + chatState.totalAfter = 0 + chatState.unreadTotal = 0 + chatState.unreadAfter = 0 + } + } else { + chatState.totalAfter -= itemIds.count + } + } + func cleared() { chatState.clear() } +} diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift new file mode 100644 index 0000000000..6e5e25e5d8 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -0,0 +1,163 @@ +// +// ChatScrollHelpers.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 20.12.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +func loadLastItems(_ loadingMoreItems: Binding, _ chat: Chat) { + if ItemsModel.shared.chatState.totalAfter == 0 { + return + } + loadingMoreItems.wrappedValue = true + Task { + try? await Task.sleep(nanoseconds: 1500_000000) + if ChatModel.shared.chatId != chat.chatInfo.id { + await MainActor.run { + loadingMoreItems.wrappedValue = false + } + return + } + await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState) + await MainActor.run { + loadingMoreItems.wrappedValue = false + } + } +} + +class PreloadState { + static let shared = PreloadState() + var prevFirstVisible: Int64 = Int64.min + var prevItemsCount: Int = 0 + var preloading: Bool = false +} + +func preloadIfNeeded( + _ allowLoadMoreItems: Binding, + _ ignoreLoadingRequests: Binding, + _ listState: EndlessScrollView.ListState, + _ mergedItems: BoxedValue, + loadItems: @escaping (Bool, ChatPagination) async -> Bool +) { + let state = PreloadState.shared + guard !listState.isScrolling, + state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count, + !state.preloading, + listState.totalItemsCount > 0 + else { + return + } + state.prevFirstVisible = listState.firstVisibleItemId as! Int64 + state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count + state.preloading = true + let allowLoadMore = allowLoadMoreItems.wrappedValue + Task { + defer { + state.preloading = false + } + await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in + await loadItems(false, pagination) + } + } +} + +func preloadItems( + _ mergedItems: MergedItems, + _ allowLoadMoreItems: Bool, + _ listState: EndlessScrollView.ListState, + _ ignoreLoadingRequests: Binding, + _ loadItems: @escaping (ChatPagination) async -> Bool) +async { + let allowLoad = allowLoadMoreItems || mergedItems.items.count == listState.lastVisibleItemIndex + 1 + let remaining = ChatPagination.UNTIL_PRELOAD_COUNT + let firstVisibleIndex = listState.firstVisibleItemIndex + + if !(await preloadItemsBefore()) { + await preloadItemsAfter() + } + + func preloadItemsBefore() async -> Bool { + let splits = mergedItems.splits + let lastVisibleIndex = listState.lastVisibleItemIndex + var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) + let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed() + if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining { + lastIndexToLoadFrom = items.count - 1 + } + let loadFromItemId: Int64? + if allowLoad, let lastIndexToLoadFrom { + let index = items.count - 1 - lastIndexToLoadFrom + loadFromItemId = index >= 0 ? items[index].id : nil + } else { + loadFromItemId = nil + } + guard let loadFromItemId, ignoreLoadingRequests.wrappedValue != loadFromItemId else { + return false + } + 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 { + ignoreLoadingRequests.wrappedValue = loadFromItemId + } + return triedToLoad + } + + func preloadItemsAfter() 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 + if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex { + let index = split.indexRangeInReversed.lowerBound + if index >= 0 { + let loadFromItemId = reversedItems[index].id + _ = await loadItems(ChatPagination.after(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT)) + } + } + } +} + +func oldestPartiallyVisibleListItemInListStateOrNull(_ listState: EndlessScrollView.ListState) -> ListItem? { + if listState.lastVisibleItemIndex < listState.items.count { + return listState.items[listState.lastVisibleItemIndex].oldest() + } else { + return listState.items.last?.oldest() + } +} + +private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? { + for split in splits { + // before any split + if split.indexRangeInParentItems.lowerBound > firstVisibleIndex { + if lastVisibleIndex > (split.indexRangeInParentItems.lowerBound - remaining) { + return split.indexRangeInReversed.lowerBound - 1 + } + break + } + let containsInRange = split.indexRangeInParentItems.contains(firstVisibleIndex) + if containsInRange { + if lastVisibleIndex > (split.indexRangeInParentItems.upperBound - remaining) { + return split.indexRangeInReversed.upperBound + } + break + } + } + return nil +} + +/// Disable animation on iOS 15 +func withConditionalAnimation( + _ animation: Animation? = .default, + _ body: () throws -> Result +) rethrows -> Result { + if #available(iOS 16.0, *) { + try withAnimation(animation, body) + } else { + try body() + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index e83391e386..a621df3b45 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -16,13 +16,14 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var im = ItemsModel.shared + @State var mergedItems: BoxedValue = BoxedValue(MergedItems(items: [], splits: [], indexInParentItems: [:])) + @State var revealedItems: Set = Set() @State var theme: AppTheme = buildTheme() @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @Environment(\.presentationMode) var presentationMode @Environment(\.scenePhase) var scenePhase @State @ObservedObject var chat: Chat - @StateObject private var scrollModel = ReverseListScrollModel() @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -31,9 +32,7 @@ struct ChatView: View { @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? - @State private var loadingItems = false - @State private var firstPage = false - @State private var revealedChatItem: ChatItem? + @State private var loadingMoreItems = false @State private var searchMode = false @State private var searchText: String = "" @FocusState private var searchFocussed @@ -48,7 +47,15 @@ struct ChatView: View { @State private var showDeleteSelectedMessages: Bool = false @State private var showArchiveSelectedReports: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false + @State private var allowLoadMoreItems: Bool = false + @State private var ignoreLoadingRequests: Int64? = nil + @State private var updateMergedItemsTask: Task? = nil + @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() + private let useItemsUpdateTask = false + + @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial var body: some View { @@ -81,7 +88,7 @@ struct ChatView: View { if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) } - FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) + FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, listState: scrollView.listState, model: floatingButtonModel) } connectingText() if selectedChatItems == nil { @@ -190,35 +197,32 @@ struct ChatView: View { } } .onAppear { + scrollView.listState.onUpdateListener = onChatItemsUpdated selectedChatItems = nil + revealedItems = Set() initChatView() } .onChange(of: chatModel.chatId) { cId in showChatInfoSheet = false selectedChatItems = nil - scrollModel.scrollToBottom() + revealedItems = Set() stopAudioPlayer() if let cId { if let c = chatModel.getChat(cId) { chat = c } + scrollView.listState.onUpdateListener = onChatItemsUpdated initChatView() theme = buildTheme() + Task { + if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { + await scrollView.scrollToItem(unreadIndex, animated: false) + } + } } else { dismiss() } } - .onChange(of: revealedChatItem) { _ in - NotificationCenter.postReverseListNeedsLayout() - } - .onChange(of: im.isLoading) { isLoading in - if !isLoading, - im.reversedChatItems.count <= loadItemsPerPage, - filtered(im.reversedChatItems).count < 10 { - loadChatItems(chat.chatInfo) - } - } - .environmentObject(scrollModel) .onDisappear { VideoPlayerView.players.removeAll() stopAudioPlayer() @@ -227,6 +231,7 @@ struct ChatView: View { if chatModel.chatId == nil { chatModel.chatItemStatuses = [:] ItemsModel.shared.reversedChatItems = [] + ItemsModel.shared.chatItemsChangesListener.cleared() chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false @@ -393,9 +398,36 @@ struct ChatView: View { await markChatUnread(chat, unreadChat: false) } } - ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount + floatingButtonModel.updateOnListChange(scrollView.listState) } + private func scrollToItemId(_ itemId: ChatItem.ID) { + Task { + do { + 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 triedToLoad = await loadChatItems(chat, pagination) + if !triedToLoad { + return + } + var repeatsLeft = 50 + while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 { + try await Task.sleep(nanoseconds: 20_000000) + repeatsLeft -= 1 + } + index = mergedItems.boxedValue.indexInParentItems[itemId] + } + if let index { + await scrollView.scrollToItem(min(ItemsModel.shared.reversedChatItems.count - 1, index), animated: true) + } + } catch { + logger.error("Error scrolling to item: \(error)") + } + } + } + private func searchToolbar() -> some View { HStack(spacing: 12) { HStack(spacing: 4) { @@ -444,46 +476,123 @@ struct ChatView: View { .map { $0.element } } - private func chatItemsList() -> some View { let cInfo = chat.chatInfo - let mergedItems = filtered(im.reversedChatItems) return GeometryReader { g in - ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in + //let _ = logger.debug("Reloading chatItemsList with number of itmes: \(im.reversedChatItems.count)") + ScrollRepresentable(scrollView: scrollView) { (index: Int, mergedItem: MergedItem) in + let ci = switch mergedItem { + case let .single(item, _, _): item.item + case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue.last!.item + } let voiceNoFrame = voiceWithoutFrame(ci) let maxWidth = cInfo.chatType == .group - ? voiceNoFrame - ? (g.size.width - 28) - 42 - : (g.size.width - 28) * 0.84 - 42 - : voiceNoFrame - ? (g.size.width - 32) - : (g.size.width - 32) * 0.84 + ? voiceNoFrame + ? (g.size.width - 28) - 42 + : (g.size.width - 28) * 0.84 - 42 + : voiceNoFrame + ? (g.size.width - 32) + : (g.size.width - 32) * 0.84 return ChatItemWithMenu( chat: $chat, + index: index, + isLastItem: index == mergedItems.boxedValue.items.count - 1, chatItem: ci, + scrollToItemId: scrollToItemId, + merged: mergedItem, maxWidth: maxWidth, composeState: $composeState, selectedMember: $selectedMember, showChatInfoSheet: $showChatInfoSheet, - revealedChatItem: $revealedChatItem, + revealedItems: $revealedItems, selectedChatItems: $selectedChatItems, forwardedChatItems: $forwardedChatItems ) + // crashes on Cell size calculation without this line + .environmentObject(ChatModel.shared) + .environmentObject(theme) // crashes without this line when scrolling to the first unread in EndlessScrollVIew .id(ci.id) // Required to trigger `onAppear` on iOS15 - } loadPage: { - loadChatItems(cInfo) + } + .onAppear { + Task { + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState) + let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) + let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } + await MainActor.run { + // this helps to speed up initial process of setting scroll position and reduce time needed + // to layout items on screen + if let unreadIndex, let unreadItemId { + scrollView.setScrollPosition(unreadIndex, unreadItemId) + } + scrollView.updateItems(mergedItems.boxedValue.items) + } + if let unreadIndex { + await scrollView.scrollToItem(unreadIndex, animated: false) + } + } + loadLastItems($loadingMoreItems, chat) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } + .onChange(of: im.reversedChatItems) { items in + updateMergedItemsTask?.cancel() + if useItemsUpdateTask { + updateMergedItemsTask = Task { + let start = Date.now.timeIntervalSince1970 + let items = MergedItems.create(items, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState) + if Task.isCancelled { + return + } + await MainActor.run { + mergedItems.boxedValue = items + scrollView.updateItems(mergedItems.boxedValue.items) + } + } + } else { + mergedItems.boxedValue = MergedItems.create(items, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + } + } + .onChange(of: revealedItems) { revealed in + updateMergedItemsTask?.cancel() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, chat.chatStats.unreadCount, revealed, ItemsModel.shared.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + } + .onChange(of: chat.chatStats.unreadCount) { unreadCount in + updateMergedItemsTask?.cancel() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, unreadCount, revealedItems, ItemsModel.shared.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + } + .onChange(of: chat.id) { _ in + loadLastItems($loadingMoreItems, chat) + allowLoadMoreItems = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } } .opacity(ItemsModel.shared.isLoading ? 0 : 1) - .padding(.vertical, -InvertedTableView.inset) + .padding(.vertical, -100) .onTapGesture { hideKeyboard() } - .onChange(of: searchText) { _ in - Task { await loadChat(chat: chat, search: searchText) } + .onChange(of: searchText) { s in + Task { + await loadChat(chat: chat, search: s) + if s.isEmpty { + await scrollView.scrollToItem(0, animated: false, top: false) + loadLastItems($loadingMoreItems, chat) + } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { + // scroll to the top unread item + await scrollView.scrollToItem(index, animated: false) + } + } } .onChange(of: im.itemAdded) { added in if added { im.itemAdded = false - if FloatingButtonModel.shared.isReallyNearBottom { - scrollModel.scrollToBottom() + if scrollView.listState.firstVisibleItemIndex < 2 { + scrollView.scrollToBottom() + } else { + scrollView.scroll(by: 34) } } } @@ -505,30 +614,22 @@ struct ChatView: View { } class FloatingButtonModel: ObservableObject { - static let shared = FloatingButtonModel() @Published var unreadBelow: Int = 0 @Published var isNearBottom: Bool = true - @Published var date: Date? + @Published var date: Date? = nil @Published var isDateVisible: Bool = false - var totalUnread: Int = 0 - var isReallyNearBottom: Bool = true - var hideDateWorkItem: DispatchWorkItem? + var hideDateWorkItem: DispatchWorkItem? = nil - func updateOnListChange(_ listState: ListState) { - let im = ItemsModel.shared - let unreadBelow = - if let id = listState.bottomItemId, - let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) - { - im.reversedChatItems[...ListState) { + let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState) + let unreadBelow = if let lastVisibleItem { + max(0, ItemsModel.shared.chatState.unreadTotal - lastVisibleItem.unreadBefore) + } else { + 0 + } let date: Date? = - if let topItemDate = listState.topItemDate { - Calendar.current.startOfDay(for: topItemDate) + if let lastVisible = listState.visibleItems.last { + Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs) } else { nil } @@ -539,11 +640,10 @@ struct ChatView: View { it.setDate(visibility: true) it.unreadBelow = unreadBelow it.date = date - it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500 } // set floating button indication mode - let nearBottom = listState.scrollOffset < 800 + let nearBottom = listState.firstVisibleItemIndex < 4 if nearBottom != self.isNearBottom { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.isNearBottom = nearBottom @@ -584,9 +684,11 @@ struct ChatView: View { private struct FloatingButtons: View { let theme: AppTheme - let scrollModel: ReverseListScrollModel + let scrollView: EndlessScrollView let chat: Chat - @ObservedObject var model = FloatingButtonModel.shared + @Binding var loadingMoreItems: Bool + let listState: EndlessScrollView.ListState + @ObservedObject var model: FloatingButtonModel var body: some View { ZStack(alignment: .top) { @@ -596,46 +698,64 @@ struct ChatView: View { .background(.thinMaterial) .clipShape(Capsule()) .opacity(model.isDateVisible ? 1 : 0) + .padding(.vertical, 4) } VStack { - let unreadAbove = model.totalUnread - model.unreadBelow + let unreadAbove = ItemsModel.shared.chatState.unreadTotal - model.unreadBelow if unreadAbove > 0 { - circleButton { - unreadCountText(unreadAbove) - .font(.callout) - .foregroundColor(theme.colors.primary) - } - .onTapGesture { - scrollModel.scrollToNextPage() - } - .contextMenu { - Button { - Task { - await markChatRead(chat) + if loadingMoreItems { + circleButton { ProgressView() } + } else { + circleButton { + unreadCountText(unreadAbove) + .font(.callout) + .foregroundColor(theme.colors.primary) + } + .onTapGesture { + if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { + // scroll to the top unread item + Task { await scrollView.scrollToItem(index, animated: true) } + } else { + logger.debug("No more unread items, total: \(listState.items.count)") + } + } + .contextMenu { + Button { + Task { + await markChatRead(chat) + } + } label: { + Label("Mark read", systemImage: "checkmark") } - } label: { - Label("Mark read", systemImage: "checkmark") } } } Spacer() if model.unreadBelow > 0 { - circleButton { - unreadCountText(model.unreadBelow) - .font(.callout) - .foregroundColor(theme.colors.primary) - } - .onTapGesture { - scrollModel.scrollToBottom() + if loadingMoreItems { + circleButton { ProgressView() } + } else { + circleButton { + unreadCountText(model.unreadBelow) + .font(.callout) + .foregroundColor(theme.colors.primary) + } + .onTapGesture { + scrollView.scrollToBottom() + } } } else if !model.isNearBottom { - circleButton { - Image(systemName: "chevron.down") - .foregroundColor(theme.colors.primary) + if loadingMoreItems { + circleButton { ProgressView() } + } else { + circleButton { + Image(systemName: "chevron.down").foregroundColor(theme.colors.primary) + } + .onTapGesture { scrollView.scrollToBottom() } } - .onTapGesture { scrollModel.scrollToBottom() } } } + .disabled(loadingMoreItems) .padding() .frame(maxWidth: .infinity, alignment: .trailing) } @@ -871,43 +991,27 @@ struct ChatView: View { } } - private func loadChatItems(_ cInfo: ChatInfo) { - Task { - if loadingItems || firstPage { return } - loadingItems = true - do { - var reversedPage = Array() - var chatItemsAvailable = true - // Load additional items until the page is +50 large after merging - while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { - let pagination: ChatPagination = - if let lastItem = reversedPage.last ?? im.reversedChatItems.last { - .before(chatItemId: lastItem.id, count: loadItemsPerPage) - } else { - .last(count: loadItemsPerPage) - } - let chatItems = try await apiGetChatItems( - type: cInfo.chatType, - id: cInfo.apiId, - pagination: pagination, - search: searchText - ) - chatItemsAvailable = !chatItems.isEmpty - reversedPage.append(contentsOf: chatItems.reversed()) - } - await MainActor.run { - if reversedPage.count == 0 { - firstPage = true - } else { - im.reversedChatItems.append(contentsOf: reversedPage) - } - loadingItems = false - } - } catch let error { - logger.error("apiGetChat error: \(responseError(error))") - await MainActor.run { loadingItems = false } - } + private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { + if loadingMoreItems { return false } + await MainActor.run { + loadingMoreItems = true } + let triedToLoad = await loadChatItemsUnchecked(chat, pagination) + await MainActor.run { + loadingMoreItems = false + } + return triedToLoad + } + + private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { + await apiLoadMessages( + chat.chatInfo.id, + pagination, + im.chatState, + searchText, + { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) } + ) + return true } func stopAudioPlayer() { @@ -915,18 +1019,43 @@ struct ChatView: View { VoiceItemState.chatView = [:] } + 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)") + return + } + floatingButtonModel.updateOnListChange(scrollView.listState) + preloadIfNeeded( + $allowLoadMoreItems, + $ignoreLoadingRequests, + scrollView.listState, + mergedItems, + loadItems: { unchecked, pagination in + if unchecked { + await loadChatItemsUnchecked(chat, pagination) + } else { + await loadChatItems(chat, pagination) + } + } + ) + } + private struct ChatItemWithMenu: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner @Binding @ObservedObject var chat: Chat @ObservedObject var dummyModel: ChatItemDummyModel = .shared + let index: Int + let isLastItem: Bool let chatItem: ChatItem + let scrollToItemId: (ChatItem.ID) -> Void + let merged: MergedItem let maxWidth: CGFloat @Binding var composeState: ComposeState @Binding var selectedMember: GMember? @Binding var showChatInfoSheet: Bool - @Binding var revealedChatItem: ChatItem? + @Binding var revealedItems: Set @State private var deletingItem: ChatItem? = nil @State private var showDeleteMessage = false @@ -943,60 +1072,83 @@ struct ChatView: View { @State private var allowMenu: Bool = true @State private var markedRead = false + @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil - var revealed: Bool { chatItem == revealedChatItem } + var revealed: Bool { revealedItems.contains(chatItem.id) } typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?) - func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation { - let im = ItemsModel.shared - if let i, i > 0 && im.reversedChatItems.count >= i { - let nextItem = im.reversedChatItems[i - 1] - let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60 - return ( - timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs), - largeGap: largeGap, - date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs - ) + private func reveal(_ yes: Bool) -> Void { + merged.revealItems(yes, $revealedItems) + } + + func getItemSeparation(_ chatItem: ChatItem, _ prevItem: ChatItem?) -> ItemSeparation { + guard let prevItem else { + return ItemSeparation(timestamp: true, largeGap: true, date: nil) + } + + let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir { + groupMember.groupMemberId == prevGroupMember.groupMemberId } else { - return (timestamp: true, largeGap: true, date: nil) + chatItem.chatDir.sent == prevItem.chatDir.sent + } + let largeGap = !sameMemberAndDirection || prevItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60 + + return ItemSeparation( + timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(prevItem.meta.itemTs), + largeGap: largeGap, + date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: prevItem.meta.itemTs) ? nil : prevItem.meta.itemTs + ) + } + + func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool { + let oldIsGroupRcv = switch older?.chatDir { + case .groupRcv: true + default: false + } + let sameMember = switch (older?.chatDir, current.chatDir) { + case (.groupRcv(let oldMember), .groupRcv(let member)): + oldMember.memberId == member.memberId + default: + false + } + if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { + return true + } else { + return false } } var body: some View { - let currIndex = m.getChatItemIndex(chatItem) - let ciCategory = chatItem.mergeCategory - let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) - let range = itemsRange(currIndex, prevHidden) - let timeSeparation = getItemSeparation(chatItem, at: currIndex) let im = ItemsModel.shared - Group { - if revealed, let range = range { - let items = Array(zip(Array(range), im.reversedChatItems[range])) - VStack(spacing: 0) { - ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in - let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] - chatItemView(ci, nil, prev, getItemSeparation(ci, at: i)) - .overlay { - if let selected = selectedChatItems, ci.canBeDeletedForSelf { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - let checked = selected.contains(ci.id) - selectUnselectChatItem(select: !checked, ci) - } - } - } - } - } - } else { - VStack(spacing: 0) { - chatItemView(chatItem, range, prevItem, timeSeparation) - if let date = timeSeparation.date { - DateSeparator(date: date).padding(8) - } - } + + let last = isLastItem ? im.reversedChatItems.last : nil + let listItem = merged.newest() + let item = listItem.item + let range: ClosedRange? = if case let .grouped(_, _, _, rangeInReversed, _, _, _, _) = merged { + rangeInReversed.boxedValue + } else { + nil + } + let showAvatar = shouldShowAvatar(item, listItem.nextItem) + let itemSeparation: ItemSeparation + let single = switch merged { + case .single: true + default: false + } + if single || revealed { + let prev = listItem.prevItem + itemSeparation = getItemSeparation(item, prev) + let nextForGap = (item.mergeCategory != nil && item.mergeCategory == prev?.mergeCategory) || isLastItem ? nil : listItem.nextItem + } else { + itemSeparation = getItemSeparation(item, nil) + } + return VStack(spacing: 0) { + if let last { + DateSeparator(date: last.meta.itemTs).padding(8) + } + chatItemListView(range, showAvatar, item, itemSeparation) .overlay { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { Color.clear @@ -1007,6 +1159,8 @@ struct ChatView: View { } } } + if let date = itemSeparation.date { + DateSeparator(date: date).padding(8) } } .onAppear { @@ -1028,6 +1182,10 @@ struct ChatView: View { } } } + .onDisappear { + markReadTask?.cancel() + markedRead = false + } .actionSheet(item: $actionSheet) { $0.actionSheet } } @@ -1050,10 +1208,14 @@ struct ChatView: View { } private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { - Task { - _ = try? await Task.sleep(nanoseconds: 600_000000) - if m.chatId == chat.chatInfo.id { - await op() + markReadTask = Task { + do { + _ = try await Task.sleep(nanoseconds: 600_000000) + if m.chatId == chat.chatInfo.id { + await op() + } + } catch { + // task was cancelled } } } @@ -1103,20 +1265,25 @@ struct ChatView: View { } } - @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View { + @ViewBuilder func chatItemListView( + _ range: ClosedRange?, + _ showAvatar: Bool, + _ ci: ChatItem, + _ itemSeparation: ItemSeparation + ) -> some View { let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2 if case let .groupRcv(member) = ci.chatDir, - case let .group(groupInfo) = chat.chatInfo { - let (prevMember, memCount): (GroupMember?, Int) = - if let range = range { - m.getPrevHiddenMember(member, range) - } else { - (nil, 1) - } - if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { + case .group = chat.chatInfo { + if showAvatar { VStack(alignment: .leading, spacing: 4) { if ci.content.showMemberName { Group { + let (prevMember, memCount): (GroupMember?, Int) = + if let range = range { + m.getPrevHiddenMember(member, range) + } else { + (nil, 1) + } if memCount == 1 && member.memberRole > .member { Group { if #available(iOS 16.0, *) { @@ -1225,6 +1392,7 @@ struct ChatView: View { ChatItemView( chat: chat, chatItem: ci, + scrollToItemId: scrollToItemId, maxWidth: maxWidth, allowMenu: $allowMenu ) @@ -1660,7 +1828,7 @@ struct ChatView: View { private func hideButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = nil + reveal(false) } } label: { Label( @@ -1741,7 +1909,7 @@ struct ChatView: View { private func revealButton(_ ci: ChatItem) -> Button { Button { withConditionalAnimation { - revealedChatItem = ci + reveal(true) } } label: { Label( @@ -1754,7 +1922,7 @@ struct ChatView: View { private func expandButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = chatItem + reveal(true) } } label: { Label( @@ -1767,7 +1935,7 @@ struct ChatView: View { private func shrinkButton() -> Button { Button { withConditionalAnimation { - revealedChatItem = nil + reveal(false) } } label: { Label ( diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift new file mode 100644 index 0000000000..7509a1fb6a --- /dev/null +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -0,0 +1,621 @@ +// +// EndlessScrollView.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 25.01.2025. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ScrollRepresentable: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable { + + let scrollView: EndlessScrollView + let content: (Int, ScrollItem) -> Content + + func makeUIViewController(context: Context) -> ScrollController { + ScrollController.init(scrollView: scrollView, content: content) + } + + func updateUIViewController(_ controller: ScrollController, context: Context) {} + + class ScrollController: UIViewController { + let scrollView: EndlessScrollView + fileprivate var items: [ScrollItem] = [] + fileprivate var content: ((Int, ScrollItem) -> Content)! + + fileprivate init(scrollView: EndlessScrollView, content: @escaping (Int, ScrollItem) -> Content) { + self.scrollView = scrollView + self.content = content + super.init(nibName: nil, bundle: nil) + self.view = scrollView + scrollView.createCell = createCell + scrollView.updateCell = updateCell + } + + required init?(coder: NSCoder) { fatalError() } + + private func createCell(_ index: Int, _ items: [ScrollItem], _ cellsToReuse: inout [UIView]) -> UIView { + let item: ScrollItem? = index >= 0 && index < items.count ? items[index] : nil + let cell: UIView + if #available(iOS 16.0, *), false { + let c: UITableViewCell = cellsToReuse.isEmpty ? UITableViewCell() : cellsToReuse.removeLast() as! UITableViewCell + if let item { + c.contentConfiguration = UIHostingConfiguration { self.content(index, item) } + .margins(.all, 0) + .minSize(height: 1) // Passing zero will result in system default of 44 points being used + } + cell = c + } else { + let c = cellsToReuse.isEmpty ? HostingCell() : cellsToReuse.removeLast() as! HostingCell + if let item { + c.set(content: self.content(index, item), parent: self) + } + cell = c + } + cell.isHidden = false + cell.backgroundColor = .clear + let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude)) + cell.frame.size.width = scrollView.bounds.width + cell.frame.size.height = size.height + return cell + } + + private func updateCell(cell: UIView, _ index: Int, _ items: [ScrollItem]) { + let item = items[index] + if #available(iOS 16.0, *), false { + (cell as! UITableViewCell).contentConfiguration = UIHostingConfiguration { self.content(index, item) } + .margins(.all, 0) + .minSize(height: 1) // Passing zero will result in system default of 44 points being used + } else { + if let cell = cell as? HostingCell { + cell.set(content: self.content(index, item), parent: self) + } else { + fatalError("Unexpected Cell Type for: \(item)") + } + } + let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude)) + cell.frame.size.width = scrollView.bounds.width + cell.frame.size.height = size.height + cell.setNeedsLayout() + } + } +} + +class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate where ScrollItem : Identifiable, ScrollItem: Hashable { + + /// Stores actual state of the scroll view and all elements drawn on the screen + let listState: ListState = ListState() + + /// Just some random big number that will probably be enough to scrolling down and up without reaching the end + var initialOffset: CGFloat = 100000000 + + /// Default item id when no items in the visible items list. Something that will never be in real data + fileprivate static var DEFAULT_ITEM_ID: any Hashable { get { Int64.min } } + + /// Storing an offset that was already used for laying down content to be able to see the difference + var prevProcessedOffset: CGFloat = 0 + + /// When screen is being rotated, it's important to track the view size and adjust scroll offset accordingly because the view doesn't know that the content + /// starts from bottom and ends at top, not vice versa as usual + var oldScreenHeight: CGFloat = 0 + + /// Not 100% correct height of the content since the items loaded lazily and their dimensions are unkown until they are on screen + var estimatedContentHeight: ContentHeight = ContentHeight() + + /// Specify here the value that is small enough to NOT see any weird animation when you scroll to items. Minimum expected item size is ok. Scroll speed depends on it too + var averageItemHeight: CGFloat = 30 + + /// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed + var scrollStepMultiplier: CGFloat = 0.6 + + /// Adds content padding to top + var insetTop: CGFloat = 100 + + /// Adds content padding to bottom + var insetBottom: CGFloat = 100 + + /// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight] + let scrollBarView: UIScrollView = UIScrollView(frame: .zero) + + /// Stores views that can be used to hold new content so it will be faster to replace something than to create the whole view from scratch + var cellsToReuse: [UIView] = [] + + /// Enable debug to see hundreds of logs + var debug: Bool = false + + var createCell: (Int, [ScrollItem], inout [UIView]) -> UIView? = { _, _, _ in nil } + var updateCell: (UIView, Int, [ScrollItem]) -> Void = { cell, _, _ in } + + + override init(frame: CGRect) { + super.init(frame: frame) + self.delegate = self + } + + required init?(coder: NSCoder) { fatalError() } + + class ListState: NSObject { + + /// Will be called on every change of the items array, visible items, and scroll position + var onUpdateListener: () -> Void = {} + + /// Items that were used to lay out the screen + var items: [ScrollItem] = [] { + didSet { + onUpdateListener() + } + } + + /// It is equai to the number of [items] + var totalItemsCount: Int { + items.count + } + + /// The items with their positions and other useful information. Only those that are visible on screen + var visibleItems: [EndlessScrollView.VisibleItem] = [] + + /// Index in [items] of the first item on screen. This is intentiallty not derived from visible items because it's is used as a starting point for laying out the screen + var firstVisibleItemIndex: Int = 0 + + /// Unique item id of the first visible item on screen + var firstVisibleItemId: any Hashable = EndlessScrollView.DEFAULT_ITEM_ID + + /// Item offset of the first item on screen. Most of the time it's non-positive but it can be positive as well when a user produce overscroll effect on top/bottom of the scroll view + var firstVisibleItemOffset: CGFloat = -100 + + /// Index of the last visible item on screen + var lastVisibleItemIndex: Int { + visibleItems.last?.index ?? 0 + } + + /// Whether there is scroll to item in progress or not + var isScrolling: Bool = false + + override init() { + super.init() + } + } + + class VisibleItem { + let index: Int + let item: ScrollItem + let view: UIView + var offset: CGFloat + + init(index: Int, item: ScrollItem, view: UIView, offset: CGFloat) { + self.index = index + self.item = item + self.view = view + self.offset = offset + } + } + + class ContentHeight { + /// After that you should see overscroll effect. When scroll positon is far from + /// top/bottom items, these values are estimated based on items count multiplied by averageItemHeight or real item height (from visible items). Example: + /// [ 10, 9, 8, 7, (6, 5, 4, 3), 2, 1, 0] - 6, 5, 4, 3 are visible and have know heights but others have unknown height and for them averageItemHeight will be used to calculate the whole content height + var topOffsetY: CGFloat = 0 + var bottomOffsetY: CGFloat = 0 + + var virtualScrollOffsetY: CGFloat = 0 + + /// How much distance were overscolled on top which often means to show sticky scrolling that should scroll back to real position after a users finishes dragging the scrollView + var overscrolledTop: CGFloat = 0 + + /// Adds content padding to bottom and top + var inset: CGFloat = 100 + + /// Estimated height of the contents of scroll view + var height: CGFloat { + get { bottomOffsetY - topOffsetY } + } + + /// Estimated height of the contents of scroll view + distance of overscrolled effect. It's only updated when number of item changes to prevent jumping of scroll bar + var virtualOverscrolledHeight: CGFloat { + get { + bottomOffsetY - topOffsetY + overscrolledTop - inset * 2 + } + } + + func update( + _ contentOffset: CGPoint, + _ listState: ListState, + _ averageItemHeight: CGFloat, + _ updateStaleHeight: Bool + ) { + let lastVisible = listState.visibleItems.last + let firstVisible = listState.visibleItems.first + guard let last = lastVisible, let first = firstVisible else { + topOffsetY = contentOffset.y + bottomOffsetY = contentOffset.y + virtualScrollOffsetY = 0 + overscrolledTop = 0 + return + } + topOffsetY = last.view.frame.origin.y - CGFloat(listState.totalItemsCount - last.index - 1) * averageItemHeight - self.inset + bottomOffsetY = first.view.frame.origin.y + first.view.bounds.height + CGFloat(first.index) * averageItemHeight + self.inset + virtualScrollOffsetY = contentOffset.y - topOffsetY + overscrolledTop = max(0, last.index == listState.totalItemsCount - 1 ? last.view.frame.origin.y - contentOffset.y : 0) + } + } + + var topY: CGFloat { + get { contentOffset.y } + } + + var bottomY: CGFloat { + get { contentOffset.y + bounds.height } + } + + override func layoutSubviews() { + super.layoutSubviews() + if contentSize.height == 0 { + setup() + } + let newScreenHeight = bounds.height + if newScreenHeight != oldScreenHeight && oldScreenHeight != 0 { + contentOffset.y += oldScreenHeight - newScreenHeight + scrollBarView.frame = CGRectMake(frame.width - 10, self.insetTop, 10, frame.height - self.insetTop - self.insetBottom) + } + oldScreenHeight = newScreenHeight + adaptItems(listState.items, false) + } + + private func setup() { + contentSize = CGSizeMake(frame.size.width, initialOffset * 2) + prevProcessedOffset = initialOffset + contentOffset = CGPointMake(0, initialOffset) + + showsVerticalScrollIndicator = false + scrollBarView.showsHorizontalScrollIndicator = false + panGestureRecognizer.delegate = self + addGestureRecognizer(scrollBarView.panGestureRecognizer) + superview!.addSubview(scrollBarView) + } + + func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) { + if !Thread.isMainThread { + fatalError("Use main thread to update items") + } + if bounds.height == 0 { + self.listState.items = items + // this function requires to have valid bounds and it will be called again once it has them + return + } + adaptItems(items, forceReloadVisible) + snapToContent() + } + + /// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes + private func adaptItems(_ items: [ScrollItem], _ forceReloadVisible: Bool, overridenOffset: CGFloat? = nil) { + let start = Date.now + // special case when everything was removed + if items.isEmpty { + listState.visibleItems.forEach { item in item.view.removeFromSuperview() } + listState.visibleItems = [] + listState.firstVisibleItemId = EndlessScrollView.DEFAULT_ITEM_ID + listState.firstVisibleItemIndex = 0 + listState.firstVisibleItemOffset = -insetTop + + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) + scrollBarView.contentSize = .zero + scrollBarView.contentOffset = .zero + + prevProcessedOffset = contentOffset.y + // this check is just to prevent didSet listener from firing on the same empty array, no use for this + if !self.listState.items.isEmpty { + self.listState.items = items + } + return + } + + let contentOffsetY = overridenOffset ?? contentOffset.y + + var oldVisible = listState.visibleItems + var newVisible: [VisibleItem] = [] + let offsetsDiff = contentOffsetY - prevProcessedOffset + + var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0 + + var allowOneMore = false + var nextOffsetY: CGFloat = 0 + var i = shouldBeFirstVisible + // building list of visible items starting from the first one that should be visible + while i >= 0 && i < items.count { + let item = items[i] + let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id }) + let visible: VisibleItem? + if let visibleIndex { + let v = oldVisible.remove(at: visibleIndex) + if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue { + updateCell(v.view, i, items) + } + visible = v + } else { + visible = nil + } + if shouldBeFirstVisible == i { + if let vis = visible { + let oldHeight = vis.view.frame.height + vis.view.frame.origin.y += oldHeight - vis.view.frame.height + // the fist visible item previously is hidden now, remove it and move on + if !isVisible(vis.view) { + let newIndex: Int + if listState.isScrolling { + // skip many items to make the scrolling take less time + var indexDiff = Int(ceil(abs(offsetsDiff / averageItemHeight))) + indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff + newIndex = max(0, min(items.count - 1, i + indexDiff)) + } else { + // don't skip multiple items if it's manual scrolling gesture + newIndex = i + (offsetsDiff <= 0 ? 1 : -1) + } + shouldBeFirstVisible = newIndex + i = newIndex + + cellsToReuse.append(vis.view) + hideAndRemoveFromSuperviewIfNeeded(vis.view) + continue + } + } + let vis: VisibleItem + if let visible { + vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view)) + } else { + let cell = createCell(i, items, &cellsToReuse)! + cell.frame.origin.y = bottomY + listState.firstVisibleItemOffset - cell.frame.height + vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell)) + } + if vis.view.superview == nil { + addSubview(vis.view) + } + newVisible.append(vis) + nextOffsetY = vis.view.frame.origin.y + } else { + let vis: VisibleItem + if let visible { + vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view)) + nextOffsetY -= vis.view.frame.height + vis.view.frame.origin.y = nextOffsetY + } else { + let cell = createCell(i, items, &cellsToReuse)! + nextOffsetY -= cell.frame.height + cell.frame.origin.y = nextOffsetY + vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell)) + } + if vis.view.superview == nil { + addSubview(vis.view) + } + newVisible.append(vis) + } + if abs(nextOffsetY) < contentOffsetY && !allowOneMore { + break + } else if abs(nextOffsetY) < contentOffsetY { + allowOneMore = false + } + i += 1 + } + if let firstVisible = newVisible.first, firstVisible.view.frame.origin.y + firstVisible.view.frame.height < contentOffsetY + bounds.height, firstVisible.index > 0 { + var offset: CGFloat = firstVisible.view.frame.origin.y + firstVisible.view.frame.height + let index = firstVisible.index + for i in stride(from: index - 1, through: 0, by: -1) { + let item = items[i] + let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id }) + let vis: VisibleItem + if let visibleIndex { + let visible = oldVisible.remove(at: visibleIndex) + visible.view.frame.origin.y = offset + vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view)) + } else { + let cell = createCell(i, items, &cellsToReuse)! + cell.frame.origin.y = offset + vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell)) + } + if vis.view.superview == nil { + addSubview(vis.view) + } + offset += vis.view.frame.height + newVisible.insert(vis, at: 0) + if offset >= contentOffsetY + bounds.height { + break + } + } + } + + // removing already unneeded visible items + oldVisible.forEach { vis in + cellsToReuse.append(vis.view) + hideAndRemoveFromSuperviewIfNeeded(vis.view) + } + let itemsCountChanged = listState.items.count != items.count + prevProcessedOffset = contentOffsetY + + listState.visibleItems = newVisible + listState.items = items + + listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView.DEFAULT_ITEM_ID + listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0 + listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop + + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged) + scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight) + scrollBarView.contentOffset = CGPointMake(0, estimatedContentHeight.virtualScrollOffsetY) + scrollBarView.isHidden = listState.visibleItems.count == listState.items.count && (listState.visibleItems.isEmpty || -listState.firstVisibleItemOffset + (listState.visibleItems.last?.offset ?? 0) + insetTop < bounds.height) + + if debug { + println("time spent \((-start.timeIntervalSinceNow).description.prefix(5).replacingOccurrences(of: "0.000", with: "<0").replacingOccurrences(of: "0.", with: ""))") + } + } + + func setScrollPosition(_ index: Int, _ id: Int64, _ offset: CGFloat = 0) { + listState.firstVisibleItemIndex = index + listState.firstVisibleItemId = id + listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset + } + + func scrollToItem(_ index: Int, animated: Bool, top: Bool = true) async { + if index >= listState.items.count || listState.isScrolling { + return + } + listState.isScrolling = true + defer { + listState.isScrolling = false + } + if !animated { + // just a faster way to set top item as requested index + listState.firstVisibleItemIndex = index + listState.firstVisibleItemId = listState.items[index].id + listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom + scrollBarView.flashScrollIndicators() + adaptItems(listState.items, false) + } + var adjustedOffset = self.contentOffset.y + var i = 0 + + var upPrev = index > listState.firstVisibleItemIndex + //let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) + + var stepSlowdownMultiplier: CGFloat = 1 + while true { + let up = index > listState.firstVisibleItemIndex + if upPrev != up { + stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 + upPrev = up + } + + // these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index + let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier + + let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step * stepSlowdownMultiplier + adjustedOffset += offsetToScroll + if let item = listState.visibleItems.first(where: { $0.index == index }) { + let y = if top { + min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) + } else { + max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) + } + setContentOffset(CGPointMake(contentOffset.x, y), animated: animated) + scrollBarView.flashScrollIndicators() + break + } + contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + if animated { + // skipping unneded relayout if this offset is already processed + if prevProcessedOffset - contentOffset.y != 0 { + adaptItems(listState.items, false) + snapToContent(animated: false) + } + // let UI time to update to see the animated position change + await MainActor.run {} + } + i += 1 + } + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) + } + + func scrollToBottom(animated: Bool = true) { + Task { + await scrollToItem(0, animated: animated, top: false) + } + } + + func scroll(by: CGFloat, animated: Bool = true) { + setContentOffset(CGPointMake(contentOffset.x, contentOffset.y + by), animated: animated) + } + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + if !listState.items.isEmpty { + scrollToBottom() + } + return false + } + + private func snapToContent(animated: Bool = true) { + let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0 + if topY < estimatedContentHeight.topOffsetY - topBlankSpace { + setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated) + } else if bottomY > estimatedContentHeight.bottomOffsetY { + setContentOffset(CGPointMake(0, estimatedContentHeight.bottomOffsetY - bounds.height), animated: animated) + } + } + + func offsetToBottom(_ view: UIView) -> CGFloat { + bottomY - (view.frame.origin.y + view.frame.height) + } + + /// If I try to .removeFromSuperview() right when I need to remove the view, it is possible to crash the app when the view was hidden in result of + /// pressing Hide in menu on top of the revealed item within the group. So at that point the item should still be attached to the view + func hideAndRemoveFromSuperviewIfNeeded(_ view: UIView) { + if view.isHidden { + // already passed this function + return + } + (view as? ReusableView)?.prepareForReuse() + view.isHidden = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + if view.isHidden { view.removeFromSuperview() } + } + } + + /// Synchronizing both scrollViews + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + true + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + snapToContent() + } + } + + override var contentOffset: CGPoint { + get { super.contentOffset } + set { + var newOffset = newValue + let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0 + if contentOffset.y > 0 && newOffset.y < estimatedContentHeight.topOffsetY - topBlankSpace && contentOffset.y > newOffset.y { + if !isDecelerating { + newOffset.y = min(contentOffset.y, newOffset.y + abs(newOffset.y - estimatedContentHeight.topOffsetY + topBlankSpace) / 1.8) + } else { + DispatchQueue.main.async { + self.setContentOffset(newValue, animated: false) + self.snapToContent() + } + } + } else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y { + if !isDecelerating { + newOffset.y = max(contentOffset.y, newOffset.y - abs(newOffset.y + bounds.height - estimatedContentHeight.bottomOffsetY) / 1.8) + } else { + DispatchQueue.main.async { + self.setContentOffset(newValue, animated: false) + self.snapToContent() + } + } + } + super.contentOffset = newOffset + } + } + + private func stopScrolling() { + let offsetYToStopAt = if abs(contentOffset.y - estimatedContentHeight.topOffsetY) < abs(bottomY - estimatedContentHeight.bottomOffsetY) { + estimatedContentHeight.topOffsetY + } else { + estimatedContentHeight.bottomOffsetY - bounds.height + } + setContentOffset(CGPointMake(contentOffset.x, offsetYToStopAt), animated: false) + } + + func isVisible(_ view: UIView) -> Bool { + if view.superview == nil { + return false + } + return view.frame.intersects(CGRectMake(0, contentOffset.y, bounds.width, bounds.height)) + } +} + +private func println(_ text: String) { + print("\(Date.now.timeIntervalSince1970): \(text)") +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 102f0333be..f985356289 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -366,14 +366,8 @@ struct GroupMemberInfoView: View { func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View { InfoViewButton(image: "message.fill", title: "message", width: width) { Task { - do { - let chat = try await apiGetChat(type: .direct, id: contactId) - chatModel.addChat(chat) - ItemsModel.shared.loadOpenChat(chat.id) { - dismissAllSheets(animated: true) - } - } catch let error { - logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") + ItemsModel.shared.loadOpenChat("@\(contactId)") { + dismissAllSheets(animated: true) } } } @@ -398,7 +392,7 @@ struct GroupMemberInfoView: View { await MainActor.run { progressIndicator = false chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) - ItemsModel.shared.loadOpenChat(memberContact.id) { + ItemsModel.shared.loadOpenChat("@\(memberContact.id)") { dismissAllSheets(animated: true) } NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift deleted file mode 100644 index e33adcef58..0000000000 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ /dev/null @@ -1,371 +0,0 @@ -// -// ReverseList.swift -// SimpleX (iOS) -// -// Created by Levitating Pineapple on 11/06/2024. -// Copyright © 2024 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import Combine -import SimpleXChat - -/// A List, which displays it's items in reverse order - from bottom to top -struct ReverseList: UIViewControllerRepresentable { - let items: Array - - @Binding var scrollState: ReverseListScrollModel.State - - /// Closure, that returns user interface for a given item - let content: (ChatItem) -> Content - - let loadPage: () -> Void - - func makeUIViewController(context: Context) -> Controller { - Controller(representer: self) - } - - func updateUIViewController(_ controller: Controller, context: Context) { - controller.representer = self - if case let .scrollingTo(destination) = scrollState, !items.isEmpty { - controller.view.layer.removeAllAnimations() - switch destination { - case .nextPage: - controller.scrollToNextPage() - case let .item(id): - controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) - case .bottom: - controller.scroll(to: 0, position: .top) - } - } else { - controller.update(items: items) - } - } - - /// Controller, which hosts SwiftUI cells - class Controller: UITableViewController { - private enum Section { case main } - var representer: ReverseList - private var dataSource: UITableViewDiffableDataSource! - private var itemCount: Int = 0 - private let updateFloatingButtons = PassthroughSubject() - private var bag = Set() - - init(representer: ReverseList) { - self.representer = representer - super.init(style: .plain) - - // 1. Style - tableView = InvertedTableView() - tableView.separatorStyle = .none - tableView.transform = .verticalFlip - tableView.backgroundColor = .clear - - // 2. Register cells - if #available(iOS 16.0, *) { - tableView.register( - UITableViewCell.self, - forCellReuseIdentifier: cellReuseId - ) - } else { - tableView.register( - HostingCell.self, - forCellReuseIdentifier: cellReuseId - ) - } - - // 3. Configure data source - self.dataSource = UITableViewDiffableDataSource( - tableView: tableView - ) { (tableView, indexPath, item) -> UITableViewCell? in - if indexPath.item > self.itemCount - 8 { - self.representer.loadPage() - } - let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) - if #available(iOS 16.0, *) { - cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) } - .margins(.all, 0) - .minSize(height: 1) // Passing zero will result in system default of 44 points being used - } else { - if let cell = cell as? HostingCell { - cell.set(content: self.representer.content(item), parent: self) - } else { - fatalError("Unexpected Cell Type for: \(item)") - } - } - cell.transform = .verticalFlip - cell.selectionStyle = .none - cell.backgroundColor = .clear - return cell - } - - // 4. External state changes will require manual layout updates - NotificationCenter.default - .addObserver( - self, - selector: #selector(updateLayout), - name: notificationName, - object: nil - ) - - updateFloatingButtons - .throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true) - .sink { - if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) { - ChatView.FloatingButtonModel.shared.updateOnListChange(listState) - } - } - .store(in: &bag) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - deinit { NotificationCenter.default.removeObserver(self) } - - @objc private func updateLayout() { - if #available(iOS 16.0, *) { - tableView.setNeedsLayout() - tableView.layoutIfNeeded() - } else { - tableView.reloadData() - } - } - - /// Hides keyboard, when user begins to scroll. - /// Equivalent to `.scrollDismissesKeyboard(.immediately)` - override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - UIApplication.shared - .sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil) - } - - override func viewDidAppear(_ animated: Bool) { - tableView.clipsToBounds = false - parent?.viewIfLoaded?.clipsToBounds = false - } - - /// Scrolls up - func scrollToNextPage() { - tableView.setContentOffset( - CGPoint( - x: tableView.contentOffset.x, - y: tableView.contentOffset.y + tableView.bounds.height - ), - animated: true - ) - Task { representer.scrollState = .atDestination } - } - - /// Scrolls to Item at index path - /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil` - func scroll(to index: Int?, position: UITableView.ScrollPosition) { - var animated = false - if #available(iOS 16.0, *) { - animated = true - } - if let index, tableView.numberOfRows(inSection: 0) != 0 { - tableView.scrollToRow( - at: IndexPath(row: index, section: 0), - at: position, - animated: animated - ) - } else { - tableView.setContentOffset( - CGPoint(x: .zero, y: -InvertedTableView.inset), - animated: animated - ) - } - Task { representer.scrollState = .atDestination } - } - - func update(items: [ChatItem]) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(items) - dataSource.defaultRowAnimation = .none - dataSource.apply( - snapshot, - animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1 - ) - // Sets content offset on initial load - if itemCount == 0 { - tableView.setContentOffset( - CGPoint(x: 0, y: -InvertedTableView.inset), - animated: false - ) - } - itemCount = items.count - updateFloatingButtons.send() - } - - override func scrollViewDidScroll(_ scrollView: UIScrollView) { - updateFloatingButtons.send() - } - - func getListState() -> ListState? { - if let visibleRows = tableView.indexPathsForVisibleRows, - visibleRows.last?.item ?? 0 < representer.items.count { - let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset - let topItemDate: Date? = - if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) { - representer.items[lastVisible.item].meta.itemTs - } else { - nil - } - let bottomItemId: ChatItem.ID? = - if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) { - representer.items[firstVisible.item].id - } else { - nil - } - return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId) - } - return nil - } - - private func isVisible(indexPath: IndexPath) -> Bool { - if let relativeFrame = tableView.superview?.convert( - tableView.rectForRow(at: indexPath), - from: tableView - ) { - relativeFrame.maxY > InvertedTableView.inset && - relativeFrame.minY < tableView.frame.height - InvertedTableView.inset - } else { false } - } - } - - /// `UIHostingConfiguration` back-port for iOS14 and iOS15 - /// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController` - private final class HostingCell: UITableViewCell { - private let hostingController = UIHostingController(rootView: nil) - - /// Updates content of the cell - /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/ - func set(content: Hosted, parent: UIViewController) { - hostingController.view.backgroundColor = .clear - hostingController.rootView = content - if let hostingView = hostingController.view { - hostingView.invalidateIntrinsicContentSize() - if hostingController.parent != parent { parent.addChild(hostingController) } - if !contentView.subviews.contains(hostingController.view) { - contentView.addSubview(hostingController.view) - hostingView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingView.leadingAnchor - .constraint(equalTo: contentView.leadingAnchor), - hostingView.trailingAnchor - .constraint(equalTo: contentView.trailingAnchor), - hostingView.topAnchor - .constraint(equalTo: contentView.topAnchor), - hostingView.bottomAnchor - .constraint(equalTo: contentView.bottomAnchor) - ]) - } - if hostingController.parent != parent { hostingController.didMove(toParent: parent) } - } else { - fatalError("Hosting View not loaded \(hostingController)") - } - } - - override func prepareForReuse() { - super.prepareForReuse() - hostingController.rootView = nil - } - } -} - -typealias ListState = ( - scrollOffset: Double, - topItemDate: Date?, - bottomItemId: ChatItem.ID? -) - -/// Manages ``ReverseList`` scrolling -class ReverseListScrollModel: ObservableObject { - /// Represents Scroll State of ``ReverseList`` - enum State: Equatable { - enum Destination: Equatable { - case nextPage - case item(ChatItem.ID) - case bottom - } - - case scrollingTo(Destination) - case atDestination - } - - @Published var state: State = .atDestination - - func scrollToNextPage() { - state = .scrollingTo(.nextPage) - } - - func scrollToBottom() { - state = .scrollingTo(.bottom) - } - - func scrollToItem(id: ChatItem.ID) { - state = .scrollingTo(.item(id)) - } -} - -fileprivate let cellReuseId = "hostingCell" - -fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout") - -fileprivate extension CGAffineTransform { - /// Transform that vertically flips the view, preserving it's location - static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1) -} - -extension NotificationCenter { - static func postReverseListNeedsLayout() { - NotificationCenter.default.post( - name: notificationName, - object: nil - ) - } -} - -/// Disable animation on iOS 15 -func withConditionalAnimation( - _ animation: Animation? = .default, - _ body: () throws -> Result -) rethrows -> Result { - if #available(iOS 16.0, *) { - try withAnimation(animation, body) - } else { - try body() - } -} - -class InvertedTableView: UITableView { - static let inset = CGFloat(100) - - static let insets = UIEdgeInsets( - top: inset, - left: .zero, - bottom: inset, - right: .zero - ) - - override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior { - get { .never } - set { } - } - - override var contentInset: UIEdgeInsets { - get { Self.insets } - set { } - } - - override var adjustedContentInset: UIEdgeInsets { - Self.insets - } -} diff --git a/apps/ios/Shared/Views/Chat/ScrollViewCells.swift b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift new file mode 100644 index 0000000000..d062627d5b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ScrollViewCells.swift @@ -0,0 +1,52 @@ +// +// ScrollViewCells.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 27.01.2025. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +protocol ReusableView { + func prepareForReuse() +} + +/// `UIHostingConfiguration` back-port for iOS14 and iOS15 +/// Implemented as a `UIView` that wraps and manages a generic `UIHostingController` +final class HostingCell: UIView, ReusableView { + private let hostingController = UIHostingController(rootView: nil) + + /// Updates content of the cell + /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/ + func set(content: Hosted, parent: UIViewController) { + hostingController.view.backgroundColor = .clear + hostingController.rootView = content + if let hostingView = hostingController.view { + hostingView.invalidateIntrinsicContentSize() + if hostingController.parent != parent { parent.addChild(hostingController) } + if !subviews.contains(hostingController.view) { + addSubview(hostingController.view) + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.leadingAnchor + .constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor + .constraint(equalTo: trailingAnchor), + hostingView.topAnchor + .constraint(equalTo: topAnchor), + hostingView.bottomAnchor + .constraint(equalTo: bottomAnchor) + ]) + } + if hostingController.parent != parent { hostingController.didMove(toParent: parent) } + } else { + fatalError("Hosting View not loaded \(hostingController)") + } + } + + func prepareForReuse() { + //super.prepareForReuse() + hostingController.rootView = nil + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index f8c7061077..6266395905 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -384,12 +384,10 @@ struct ChatPreviewView: View { case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) - .environmentObject(ReverseListScrollModel()) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) - .environmentObject(ReverseListScrollModel()) } case let .voice(_, duration): smallContentPreviewVoice(size: dynamicMediaSize) { diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 27bb95b599..7c16e22571 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -65,6 +65,7 @@ struct LocalAuthView: View { // Clear sensitive data on screen just in case app fails to hide its views while new database is created m.chatId = nil ItemsModel.shared.reversedChatItems = [] + ItemsModel.shared.chatItemsChangesListener.cleared() m.updateChats([]) m.users = [] _ = kcAppPassword.set(password) diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 4c61d592ac..c6d0e27289 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) + ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in }) .modifier(ChatItemClipped(alice, tailVisible: true)) Spacer() } HStack { Spacer() - ChatItemView(chat: Chat.sampleData, chatItem: bob) + ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in }) .modifier(ChatItemClipped(bob, tailVisible: true)) .frame(alignment: .trailing) } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a87482bc93..8e9c47850a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -198,9 +198,14 @@ 8C8118722C220B5B00E6FC94 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8C8118712C220B5B00E6FC94 /* Yams */; }; 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; }; 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; }; + 8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */; }; + 8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */; }; + 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; }; 8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; }; 8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; }; 8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBC14852D357CDB00BBD901 /* StorageView.swift */; }; + 8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */; }; + 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; @@ -225,7 +230,6 @@ CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; }; CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; }; - CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; }; CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; @@ -551,8 +555,13 @@ 8C852B072C1086D100BA61E8 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; + 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollHelpers.swift; sourceTree = ""; }; + 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = ""; }; + 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.swift; sourceTree = ""; }; 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = ""; }; 8CBC14852D357CDB00BBD901 /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; + 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndlessScrollView.swift; sourceTree = ""; }; + 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewCells.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; @@ -576,7 +585,6 @@ CEE723D42C3C21F50009AE93 /* SimpleX SE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX SE.entitlements"; sourceTree = ""; }; CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = ""; }; - CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = ""; }; CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetRepresentable.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -737,6 +745,8 @@ 5C5F4AC227A5E9AF00B51EF1 /* Chat */ = { isa = PBXGroup; children = ( + 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */, + 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */, 6440CA01288AEC770062C672 /* Group */, 5CE4407427ADB657007B033A /* ChatItem */, 5CEACCE527DE977C000BD591 /* ComposeMessage */, @@ -747,11 +757,13 @@ 5CE4407127ADB1D0007B033A /* Emoji.swift */, 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */, 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */, - CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */, 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, + 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */, + 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */, + 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */, ); path = Chat; sourceTree = ""; @@ -1408,8 +1420,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */, 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, + 8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */, 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, @@ -1427,6 +1439,7 @@ 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, + 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, 8C74C3EC2C1B92A900039E77 /* Theme.swift in Sources */, @@ -1516,6 +1529,7 @@ 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */, + 8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */, 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */, @@ -1527,6 +1541,7 @@ 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, + 8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */, @@ -1566,6 +1581,7 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, + 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 987f7f3d41..5e5f047611 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -203,7 +203,7 @@ public func chatResponse(_ s: String) -> ChatResponse { let jChats = jApiChats["chats"] as? NSArray { let chats = jChats.map { jChat in if let chatData = try? parseChatData(jChat) { - return chatData + return chatData.0 } return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "") } @@ -213,8 +213,8 @@ public func chatResponse(_ s: String) -> ChatResponse { if let jApiChat = jResp["apiChat"] as? NSDictionary, let user: UserRef = try? decodeObject(jApiChat["user"] as Any), let jChat = jApiChat["chat"] as? NSDictionary, - let chat = try? parseChatData(jChat) { - return .apiChat(user: user, chat: chat) + let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) { + return .apiChat(user: user, chat: chat, navInfo: navInfo) } } else if type == "chatCmdError" { if let jError = jResp["chatCmdError"] as? NSDictionary { @@ -247,10 +247,11 @@ private func errorJson(_ jDict: NSDictionary) -> String? { } } -func parseChatData(_ jChat: Any) throws -> ChatData { +func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) { let jChatDict = jChat as! NSDictionary let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!) let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!) + let navInfo: NavigationInfo = jNavInfo == nil ? NavigationInfo() : try decodeObject((jNavInfo as! NSDictionary)["navInfo"]!) let jChatItems = jChatDict["chatItems"] as! NSArray let chatItems = jChatItems.map { jCI in if let ci: ChatItem = try? decodeObject(jCI) { @@ -262,7 +263,7 @@ func parseChatData(_ jChat: Any) throws -> ChatData { json: serializeJSON(jCI, options: .prettyPrinted) ?? "" ) } - return ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats) + return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo) } func decodeObject(_ obj: Any) throws -> T { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index bd022165f0..f331f98a67 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -42,7 +42,7 @@ public enum ChatCommand { case apiGetSettings(settings: AppSettings) case apiGetChatTags(userId: Int64) case apiGetChats(userId: Int64) - case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) + 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 apiCreateChatTag(tag: ChatTagData) @@ -212,7 +212,7 @@ public enum ChatCommand { 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(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + + 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): @@ -600,7 +600,7 @@ public enum ChatResponse: Decodable, Error { case chatStopped case chatSuspended case apiChats(user: UserRef, chats: [ChatData]) - case apiChat(user: UserRef, chat: ChatData) + case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?) case chatTags(user: UserRef, userTags: [ChatTag]) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) @@ -958,7 +958,7 @@ public enum ChatResponse: Decodable, Error { case .chatStopped: return noDetails case .chatSuspended: return noDetails case let .apiChats(u, chats): return withUser(u, String(describing: chats)) - case let .apiChat(u, chat): return withUser(u, String(describing: chat)) + case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") @@ -1209,10 +1209,15 @@ struct NewUser: Encodable { } public enum ChatPagination { + public static let INITIAL_COUNT = 75 + public static let PRELOAD_COUNT = 100 + public static let UNTIL_PRELOAD_COUNT = 50 + case last(count: Int) case after(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int) case around(chatItemId: Int64, count: Int) + case initial(count: Int) var cmdString: String { switch self { @@ -1220,6 +1225,7 @@ public enum ChatPagination { case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" + case let .initial(count): return "initial=\(count)" } } } @@ -1227,7 +1233,7 @@ public enum ChatPagination { public struct ChatTagData: Encodable { public var emoji: String? public var text: String - + public init(emoji: String?, text: String) { self.emoji = emoji self.text = text @@ -1483,7 +1489,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable { } set { `operator` = newValue } } - + public static var sampleData1 = UserOperatorServers( operator: ServerOperator.sampleData1, smpServers: [UserServer.sampleData.preset], @@ -1794,7 +1800,7 @@ public struct NetCfg: Codable, Equatable { rcvConcurrency: 8, smpPingInterval: 1200_000_000 ) - + public var withProxyTimeouts: NetCfg { var cfg = self cfg.tcpConnectTimeout = NetCfg.proxyDefaults.tcpConnectTimeout @@ -1804,7 +1810,7 @@ public struct NetCfg: Codable, Equatable { cfg.smpPingInterval = NetCfg.proxyDefaults.smpPingInterval return cfg } - + public var hasProxyTimeouts: Bool { tcpConnectTimeout == NetCfg.proxyDefaults.tcpConnectTimeout && tcpTimeout == NetCfg.proxyDefaults.tcpTimeout && @@ -1937,7 +1943,7 @@ public struct NetworkProxy: Equatable, Codable { public static var def: NetworkProxy { NetworkProxy() } - + public var valid: Bool { let hostOk = switch NWEndpoint.Host(host) { case .ipv4: true @@ -1948,7 +1954,7 @@ public struct NetworkProxy: Equatable, Codable { port > 0 && port <= 65535 && NetworkProxy.validCredential(username) && NetworkProxy.validCredential(password) } - + public static func validCredential(_ s: String) -> Bool { !s.contains(":") && !s.contains("@") } @@ -2048,6 +2054,16 @@ public struct ChatSettings: Codable, Hashable { public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false) } +public struct NavigationInfo: Decodable { + public var afterUnread: Int = 0 + public var afterTotal: Int = 0 + + public init(afterUnread: Int = 0, afterTotal: Int = 0) { + self.afterUnread = afterUnread + self.afterTotal = afterTotal + } +} + public enum MsgFilter: String, Codable, Hashable { case none case all @@ -2254,7 +2270,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem { case .instant: "Instant" } } - + public var icon: String { switch self { case .off: return "arrow.clockwise" @@ -2769,7 +2785,7 @@ public struct AppSettings: Codable, Equatable { public var uiThemes: [ThemeOverrides]? = nil public var oneHandUI: Bool? = nil public var chatBottomBar: Bool? = nil - + public func prepareForExport() -> AppSettings { var empty = AppSettings() let def = AppSettings.defaults From ec519afb3f91d8a04119d2a67008fa7c3b64a8e9 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Feb 2025 03:44:24 +0700 Subject: [PATCH 070/289] ios: fixed unread counters (#5640) --- apps/ios/Shared/Model/ChatModel.swift | 65 ++++--------------- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- .../Shared/Views/Chat/ChatItemsMerger.swift | 4 +- apps/ios/Shared/Views/Chat/ChatView.swift | 22 +++---- 4 files changed, 27 insertions(+), 66 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ae8a6f445e..52500595ca 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -665,7 +665,7 @@ final class ChatModel: ObservableObject { im.reversedChatItems.first?.isLiveDummy == true } - func markChatItemsRead(_ cInfo: ChatInfo) { + func markAllChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) @@ -674,54 +674,14 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == cInfo.id { - markCurrentChatRead() - } - } - - private func markCurrentChatRead(fromIndex i: Int = 0) { - var j = i - while j < im.reversedChatItems.count { - markChatItemRead_(j) - j += 1 - } - } - - func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { - if let cItem = aboveItem { - if chatId == cInfo.id, let i = getChatItemIndex(cItem) { - markCurrentChatRead(fromIndex: i) - _updateChat(cInfo.id) { chat in - var unreadBelow = 0 - var unreadMentionsBelow = 0 - var j = i - 1 - while j >= 0 { - let meta = self.im.reversedChatItems[j].meta - if case .rcvNew = meta.itemStatus { - unreadBelow += 1 - if meta.userMention { - unreadMentionsBelow += 1 - } - } - j -= 1 - } - // update preview - let markedCount = chat.chatStats.unreadCount - unreadBelow - let markedMentionsCount = chat.chatStats.unreadMentions - unreadMentionsBelow - if markedCount > 0 || markedMentionsCount > 0 { - let wasUnread = chat.unreadTag - chat.chatStats.unreadCount -= markedCount - chat.chatStats.unreadMentions -= markedMentionsCount - ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) - let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions ? markedMentionsCount : markedCount - self.decreaseUnreadCounter(user: self.currentUser!, by: by) - } - } + var i = 0 + while i < im.reversedChatItems.count { + markChatItemRead_(i) + i += 1 } - } else { - markChatItemsRead(cInfo) + im.chatItemsChangesListener.read(nil, im.reversedChatItems.reversed()) } } - func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in let wasUnread = chat.unreadTag @@ -750,13 +710,16 @@ final class ChatModel: ObservableObject { func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { if self.chatId == cInfo.id { var unreadItemIds: Set = [] - for itemId in itemIds { - if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { - if im.reversedChatItems[i].isRcvNew { - unreadItemIds.insert(itemId) - } + var i = 0 + var ids = Set(itemIds) + while i < im.reversedChatItems.count && !ids.isEmpty { + let item = im.reversedChatItems[i] + if ids.contains(item.id) && item.isRcvNew { markChatItemRead_(i) + unreadItemIds.insert(item.id) + ids.remove(item.id) } + i += 1 } im.chatItemsChangesListener.read(unreadItemIds, im.reversedChatItems.reversed()) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 056340dcaf..51d2a8444d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1513,7 +1513,7 @@ func markChatRead(_ chat: Chat) async { let cInfo = chat.chatInfo try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) await MainActor.run { - withAnimation { ChatModel.shared.markChatItemsRead(cInfo) } + withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) } } } if chat.chatStats.unreadChat { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index fac359076f..2e9dac166a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -23,11 +23,13 @@ struct MergedItems: Hashable, Equatable { hasher.combine("\(items.hashValue)") } - static func create(_ items: [ChatItem], _ unreadCount: Int, _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems { + static func create(_ items: [ChatItem], _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems { if items.isEmpty { return MergedItems(items: [], splits: [], indexInParentItems: [:]) } + let unreadCount = chatState.unreadTotal + let unreadAfterItemId = chatState.unreadAfterItemId let itemSplits = chatState.splits var mergedItems: [MergedItem] = [] diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index a621df3b45..396493d6c5 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -515,7 +515,7 @@ struct ChatView: View { } .onAppear { Task { - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState) + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } await MainActor.run { @@ -539,8 +539,7 @@ struct ChatView: View { updateMergedItemsTask?.cancel() if useItemsUpdateTask { updateMergedItemsTask = Task { - let start = Date.now.timeIntervalSince1970 - let items = MergedItems.create(items, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState) + let items = MergedItems.create(items, revealedItems, im.chatState) if Task.isCancelled { return } @@ -550,18 +549,13 @@ struct ChatView: View { } } } else { - mergedItems.boxedValue = MergedItems.create(items, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState) + mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) scrollView.updateItems(mergedItems.boxedValue.items) } } .onChange(of: revealedItems) { revealed in updateMergedItemsTask?.cancel() - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, chat.chatStats.unreadCount, revealed, ItemsModel.shared.chatState) - scrollView.updateItems(mergedItems.boxedValue.items) - } - .onChange(of: chat.chatStats.unreadCount) { unreadCount in - updateMergedItemsTask?.cancel() - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, unreadCount, revealedItems, ItemsModel.shared.chatState) + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: chat.id) { _ in @@ -614,6 +608,7 @@ 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 @@ -627,6 +622,7 @@ struct ChatView: View { } 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) @@ -638,6 +634,7 @@ struct ChatView: View { 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 } @@ -701,13 +698,12 @@ struct ChatView: View { .padding(.vertical, 4) } VStack { - let unreadAbove = ItemsModel.shared.chatState.unreadTotal - model.unreadBelow - if unreadAbove > 0 { + if model.unreadAbove > 0 { if loadingMoreItems { circleButton { ProgressView() } } else { circleButton { - unreadCountText(unreadAbove) + unreadCountText(model.unreadAbove) .font(.callout) .foregroundColor(theme.colors.primary) } From dc980ae88f83ac88b6129b4e240a29156f9fc4ab Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Feb 2025 23:13:20 +0700 Subject: [PATCH 071/289] ios: loading progress moved to chat list (#5639) * ios: loading progress moved to chat list * place * changes * large spinner, smaller timeout --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 35 ++-- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 3 +- apps/ios/Shared/Views/Chat/ChatView.swift | 161 ++++++++++-------- .../ComposeMessage/NativeTextEditor.swift | 6 +- .../Chat/ComposeMessage/SendMessageView.swift | 2 +- .../Shared/Views/Chat/EndlessScrollView.swift | 109 +++++++++--- 7 files changed, 196 insertions(+), 122 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 52500595ca..56fe727e5f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -61,7 +61,10 @@ class ItemsModel: ObservableObject { // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @Published var isLoading = false - @Published var showLoadingProgress = false + @Published var showLoadingProgress: ChatId? = nil + + private var navigationTimeoutTask: Task? = nil + private var loadChatTask: Task? = nil init() { publisher @@ -71,32 +74,26 @@ class ItemsModel: ObservableObject { } func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { - let navigationTimeout = Task { + navigationTimeoutTask?.cancel() + loadChatTask?.cancel() + navigationTimeoutTask = Task { do { - try await Task.sleep(nanoseconds: 250_000000) + try await Task.sleep(nanoseconds: 100_000000) await MainActor.run { - willNavigate() ChatModel.shared.chatId = chatId + willNavigate() } } catch {} } - let progressTimeout = Task { - do { - try await Task.sleep(nanoseconds: 1500_000000) - await MainActor.run { showLoadingProgress = true } - } catch {} - } - Task { + loadChatTask = Task { await MainActor.run { self.isLoading = true } - // try? await Task.sleep(nanoseconds: 5000_000000) +// try? await Task.sleep(nanoseconds: 1000_000000) await loadChat(chatId: chatId) - navigationTimeout.cancel() - progressTimeout.cancel() - await MainActor.run { - self.isLoading = false - self.showLoadingProgress = false - willNavigate() -// ChatModel.shared.chatId = id + if !Task.isCancelled { + await MainActor.run { + self.isLoading = false + self.showLoadingProgress = nil + } } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51d2a8444d..0ef2a87aa7 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -335,7 +335,7 @@ func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") } func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { - await loadChat(chatId: chat.chatInfo.id) + await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) } func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 5920b8eef7..4253a4f1e4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -32,7 +32,7 @@ func apiLoadMessages( // 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 let paginationIsInitial = switch pagination { case .initial: true; default: false } let paginationIsLast = switch pagination { case .last: true; default: false } - if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) { + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled { return } @@ -50,7 +50,6 @@ func apiLoadMessages( chatModel.chatItemStatuses.removeAll() ItemsModel.shared.reversedChatItems = chat.chatItems.reversed() chatModel.updateChatInfo(chat.chatInfo) - chatModel.chatId = chat.chatInfo.id chatState.splits = newSplits if !chat.chatItems.isEmpty { chatState.unreadAfterItemId = chat.chatItems.last!.id diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 396493d6c5..a8d19733b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -16,7 +16,7 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var im = ItemsModel.shared - @State var mergedItems: BoxedValue = BoxedValue(MergedItems(items: [], splits: [], indexInParentItems: [:])) + @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 @@ -55,7 +55,7 @@ struct ChatView: View { private let useItemsUpdateTask = false @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) - + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial var body: some View { @@ -120,6 +120,9 @@ struct ChatView: View { ) } } + if im.showLoadingProgress == chat.id { + ProgressView().scaleEffect(2) + } } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { @@ -201,6 +204,16 @@ struct ChatView: View { selectedChatItems = nil revealedItems = Set() initChatView() + if im.isLoading { + Task { + try? await Task.sleep(nanoseconds: 500_000000) + await MainActor.run { + if im.isLoading { + im.showLoadingProgress = chat.id + } + } + } + } } .onChange(of: chatModel.chatId) { cId in showChatInfoSheet = false @@ -214,10 +227,10 @@ struct ChatView: View { scrollView.listState.onUpdateListener = onChatItemsUpdated initChatView() theme = buildTheme() - Task { - if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { - await scrollView.scrollToItem(unreadIndex, animated: false) - } + if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { + scrollView.scrollToItem(unreadIndex) + } else { + scrollView.scrollToBottom() } } else { dismiss() @@ -290,7 +303,6 @@ struct ChatView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - let isLoading = im.isLoading && im.showLoadingProgress if selectedChatItems != nil { Button { withAnimation { @@ -313,23 +325,19 @@ struct ChatView: View { } } Menu { - if !isLoading { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") - } - .disabled(!contact.ready || !contact.active) + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) + .disabled(!contact.ready || !contact.active) } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) } label: { Image(systemName: "ellipsis") - .tint(isLoading ? Color.clear : nil) - .overlay { if isLoading { ProgressView() } } } } case let .group(groupInfo): @@ -351,14 +359,10 @@ struct ChatView: View { } } Menu { - if !isLoading { - searchButton() - ToggleNtfsButton(chat: chat) - } + searchButton() + ToggleNtfsButton(chat: chat) } label: { Image(systemName: "ellipsis") - .tint(isLoading ? Color.clear : nil) - .overlay { if isLoading { ProgressView() } } } } case .local: @@ -370,7 +374,7 @@ struct ChatView: View { } } } - + private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. @@ -420,14 +424,14 @@ struct ChatView: View { index = mergedItems.boxedValue.indexInParentItems[itemId] } if let index { - await scrollView.scrollToItem(min(ItemsModel.shared.reversedChatItems.count - 1, index), animated: true) + await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) } } catch { logger.error("Error scrolling to item: \(error)") } } } - + private func searchToolbar() -> some View { HStack(spacing: 12) { HStack(spacing: 4) { @@ -458,7 +462,7 @@ struct ChatView: View { .padding(.horizontal) .padding(.vertical, 8) } - + private func voiceWithoutFrame(_ ci: ChatItem) -> Bool { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } @@ -514,25 +518,13 @@ struct ChatView: View { .id(ci.id) // Required to trigger `onAppear` on iOS15 } .onAppear { - Task { - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) - let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) - let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } - await MainActor.run { - // this helps to speed up initial process of setting scroll position and reduce time needed - // to layout items on screen - if let unreadIndex, let unreadItemId { - scrollView.setScrollPosition(unreadIndex, unreadItemId) - } - scrollView.updateItems(mergedItems.boxedValue.items) - } - if let unreadIndex { - await scrollView.scrollToItem(unreadIndex, animated: false) - } + if !im.isLoading { + updateWithInitiallyLoadedItems() } - loadLastItems($loadingMoreItems, chat) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - allowLoadMoreItems = true + } + .onChange(of: im.isLoading) { loading in + if !loading { + updateWithInitiallyLoadedItems() } } .onChange(of: im.reversedChatItems) { items in @@ -565,18 +557,24 @@ struct ChatView: View { allowLoadMoreItems = true } } - .opacity(ItemsModel.shared.isLoading ? 0 : 1) .padding(.vertical, -100) .onTapGesture { hideKeyboard() } .onChange(of: searchText) { s in Task { await loadChat(chat: chat, search: s) - if s.isEmpty { - await scrollView.scrollToItem(0, animated: false, top: false) - loadLastItems($loadingMoreItems, chat) + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + await MainActor.run { + scrollView.updateItems(mergedItems.boxedValue.items) + } + if !s.isEmpty { + scrollView.scrollToBottom() } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { // scroll to the top unread item - await scrollView.scrollToItem(index, animated: false) + scrollView.scrollToItem(index) + loadLastItems($loadingMoreItems, chat) + } else { + scrollView.scrollToBottom() + loadLastItems($loadingMoreItems, chat) } } } @@ -584,7 +582,7 @@ struct ChatView: View { if added { im.itemAdded = false if scrollView.listState.firstVisibleItemIndex < 2 { - scrollView.scrollToBottom() + scrollView.scrollToBottomAnimated() } else { scrollView.scroll(by: 34) } @@ -607,6 +605,27 @@ struct ChatView: View { } } + private func updateWithInitiallyLoadedItems() { + if mergedItems.boxedValue.items.isEmpty { + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, ItemsModel.shared.chatState) + } + let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) + let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } + // this helps to speed up initial process of setting scroll position and reduce time needed + // to layout items on screen + if let unreadIndex, let unreadItemId { + scrollView.setScrollPosition(unreadIndex, unreadItemId) + } + scrollView.updateItems(mergedItems.boxedValue.items) + if let unreadIndex { + scrollView.scrollToItem(unreadIndex) + } + loadLastItems($loadingMoreItems, chat) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } + class FloatingButtonModel: ObservableObject { @Published var unreadAbove: Int = 0 @Published var unreadBelow: Int = 0 @@ -638,7 +657,7 @@ struct ChatView: View { it.unreadBelow = unreadBelow it.date = date } - + // set floating button indication mode let nearBottom = listState.firstVisibleItemIndex < 4 if nearBottom != self.isNearBottom { @@ -646,7 +665,7 @@ struct ChatView: View { self?.isNearBottom = nearBottom } } - + // hide Date indicator after 1 second of no scrolling hideDateWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -710,7 +729,7 @@ struct ChatView: View { .onTapGesture { if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { // scroll to the top unread item - Task { await scrollView.scrollToItem(index, animated: true) } + Task { await scrollView.scrollToItemAnimated(index) } } else { logger.debug("No more unread items, total: \(listState.items.count)") } @@ -737,7 +756,7 @@ struct ChatView: View { .foregroundColor(theme.colors.primary) } .onTapGesture { - scrollView.scrollToBottom() + scrollView.scrollToBottomAnimated() } } } else if !model.isNearBottom { @@ -747,7 +766,7 @@ struct ChatView: View { circleButton { Image(systemName: "chevron.down").foregroundColor(theme.colors.primary) } - .onTapGesture { scrollView.scrollToBottom() } + .onTapGesture { scrollView.scrollToBottomAnimated() } } } } @@ -949,7 +968,7 @@ struct ChatView: View { ) } } - + func forwardAction(_ items: [Int64]) -> UIAlertAction { UIAlertAction( title: NSLocalizedString("Forward messages", comment: "alert action"), @@ -1078,7 +1097,7 @@ struct ChatView: View { private func reveal(_ yes: Bool) -> Void { merged.revealItems(yes, $revealedItems) } - + func getItemSeparation(_ chatItem: ChatItem, _ prevItem: ChatItem?) -> ItemSeparation { guard let prevItem else { return ItemSeparation(timestamp: true, largeGap: true, date: nil) @@ -1189,7 +1208,7 @@ struct ChatView: View { let im = ItemsModel.shared var unreadItems: [ChatItem.ID] = [] var unreadMentions: Int = 0 - + for i in range { let ci = im.reversedChatItems[i] if ci.isRcvNew { @@ -1199,10 +1218,10 @@ struct ChatView: View { } } } - + return (unreadItems, unreadMentions) } - + private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { markReadTask = Task { do { @@ -1215,7 +1234,7 @@ struct ChatView: View { } } } - + @available(iOS 16.0, *) struct MemberLayout: Layout { @@ -1892,7 +1911,7 @@ struct ChatView: View { ) } } - + private func archiveReportButton(_ cItem: ChatItem) -> Button { Button { archivingReports = [cItem.id] @@ -1940,7 +1959,7 @@ struct ChatView: View { ) } } - + private func reportButton(_ ci: ChatItem) -> Button { Button(role: .destructive) { var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in @@ -1954,9 +1973,9 @@ struct ChatView: View { } } } - + buttons.append(.cancel()) - + actionSheet = SomeActionSheet( actionSheet: ActionSheet( title: Text("Report reason?"), @@ -1971,7 +1990,7 @@ struct ChatView: View { ) } } - + var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" @@ -2044,7 +2063,7 @@ struct ChatView: View { } } } - + @ViewBuilder private func contactReactionMenu(_ contact: Contact, _ r: CIReactionCount) -> some View { if !r.userReacted || r.totalReacted > 1 { Button { showChatInfoSheet = true } label: { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 0d79566d8b..676d9c43d2 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -20,12 +20,12 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void - private let minHeight: CGFloat = 37 + static let minHeight: CGFloat = 39 private let defaultHeight: CGFloat = { let field = CustomUITextField(parent: nil, height: Binding.constant(0)) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) - return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) + return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), 360).rounded(.down) }() func makeUIView(context: Context) -> UITextView { @@ -81,7 +81,7 @@ struct NativeTextEditor: UIViewRepresentable { // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size let newHeight = field.text == "" ? defaultHeight - : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down) + : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down) if field.frame.size.height != newHeight { field.frame.size = CGSizeMake(field.frame.size.width, newHeight) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 9554772721..64cfe3d260 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -33,7 +33,7 @@ struct SendMessageView: View { @Namespace var namespace @Binding var keyboardVisible: Bool var sendButtonColor = Color.accentColor - @State private var teHeight: CGFloat = 42 + @State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teFont: Font = .body @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 7509a1fb6a..c19a1f00a2 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -115,6 +115,8 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu /// Adds content padding to bottom var insetBottom: CGFloat = 100 + var scrollToItemIndexDelayed: Int? = nil + /// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight] let scrollBarView: UIScrollView = UIScrollView(frame: .zero) @@ -260,6 +262,10 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } oldScreenHeight = newScreenHeight adaptItems(listState.items, false) + if let index = scrollToItemIndexDelayed { + scrollToItem(index) + scrollToItemIndexDelayed = nil + } } private func setup() { @@ -284,7 +290,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu return } adaptItems(items, forceReloadVisible) - snapToContent() + snapToContent(animated: false) } /// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes @@ -454,21 +460,70 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset } - func scrollToItem(_ index: Int, animated: Bool, top: Bool = true) async { + func scrollToItem(_ index: Int, top: Bool = true) { if index >= listState.items.count || listState.isScrolling { return } + if bounds.height == 0 { + scrollToItemIndexDelayed = index + return + } listState.isScrolling = true defer { listState.isScrolling = false } - if !animated { - // just a faster way to set top item as requested index - listState.firstVisibleItemIndex = index - listState.firstVisibleItemId = listState.items[index].id - listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom - scrollBarView.flashScrollIndicators() - adaptItems(listState.items, false) + + // just a faster way to set top item as requested index + listState.firstVisibleItemIndex = index + listState.firstVisibleItemId = listState.items[index].id + listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom + scrollBarView.flashScrollIndicators() + adaptItems(listState.items, false) + + var adjustedOffset = self.contentOffset.y + var i = 0 + + var upPrev = index > listState.firstVisibleItemIndex + //let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) + + var stepSlowdownMultiplier: CGFloat = 1 + while true { + let up = index > listState.firstVisibleItemIndex + if upPrev != up { + stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 + upPrev = up + } + + // these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index + let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier + + let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step * stepSlowdownMultiplier + adjustedOffset += offsetToScroll + if let item = listState.visibleItems.first(where: { $0.index == index }) { + let y = if top { + min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) + } else { + max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) + } + setContentOffset(CGPointMake(contentOffset.x, y), animated: false) + scrollBarView.flashScrollIndicators() + break + } + contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + i += 1 + } + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) + } + + func scrollToItemAnimated(_ index: Int, top: Bool = true) async { + if index >= listState.items.count || listState.isScrolling { + return + } + listState.isScrolling = true + defer { + listState.isScrolling = false } var adjustedOffset = self.contentOffset.y var i = 0 @@ -497,28 +552,32 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } else { max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) } - setContentOffset(CGPointMake(contentOffset.x, y), animated: animated) + setContentOffset(CGPointMake(contentOffset.x, y), animated: true) scrollBarView.flashScrollIndicators() break } contentOffset = CGPointMake(contentOffset.x, adjustedOffset) - if animated { - // skipping unneded relayout if this offset is already processed - if prevProcessedOffset - contentOffset.y != 0 { - adaptItems(listState.items, false) - snapToContent(animated: false) - } - // let UI time to update to see the animated position change - await MainActor.run {} + + // skipping unneded relayout if this offset is already processed + if prevProcessedOffset - contentOffset.y != 0 { + adaptItems(listState.items, false) + snapToContent(animated: false) } + // let UI time to update to see the animated position change + await MainActor.run {} + i += 1 } estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) } - func scrollToBottom(animated: Bool = true) { + func scrollToBottom() { + scrollToItem(0, top: false) + } + + func scrollToBottomAnimated() { Task { - await scrollToItem(0, animated: animated, top: false) + await scrollToItemAnimated(0, top: false) } } @@ -528,12 +587,12 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { if !listState.items.isEmpty { - scrollToBottom() + scrollToBottomAnimated() } return false } - private func snapToContent(animated: Bool = true) { + private func snapToContent(animated: Bool) { let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0 if topY < estimatedContentHeight.topOffsetY - topBlankSpace { setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated) @@ -567,7 +626,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - snapToContent() + snapToContent(animated: true) } } @@ -582,7 +641,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } else { DispatchQueue.main.async { self.setContentOffset(newValue, animated: false) - self.snapToContent() + self.snapToContent(animated: true) } } } else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y { @@ -591,7 +650,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } else { DispatchQueue.main.async { self.setContentOffset(newValue, animated: false) - self.snapToContent() + self.snapToContent(animated: true) } } } From 676583d3c30a14e70aee282efb51f80ba9ab25f8 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:39:04 +0700 Subject: [PATCH 072/289] ios: enhancements to floating buttons (#5644) * ios: enhancements to floating buttons * nearBottom * timeout * changes --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 4 +- .../Shared/Views/Chat/ChatScrollHelpers.swift | 6 +- apps/ios/Shared/Views/Chat/ChatView.swift | 113 +++++++++++++----- .../ComposeMessage/NativeTextEditor.swift | 7 +- .../xcschemes/SimpleX SE.xcscheme | 1 - 5 files changed, 95 insertions(+), 36 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 56fe727e5f..0be8436320 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -78,7 +78,7 @@ class ItemsModel: ObservableObject { loadChatTask?.cancel() navigationTimeoutTask = Task { do { - try await Task.sleep(nanoseconds: 100_000000) + try await Task.sleep(nanoseconds: 250_000000) await MainActor.run { ChatModel.shared.chatId = chatId willNavigate() @@ -949,7 +949,7 @@ final class ChatModel: ObservableObject { memberIds.insert(m.groupMemberId) } } else { - logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count)") + logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))") } } return (prevMember, memberIds.count) diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index 6e5e25e5d8..094c6e13e1 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -9,13 +9,14 @@ import SwiftUI import SimpleXChat -func loadLastItems(_ loadingMoreItems: Binding, _ chat: Chat) { +func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat) { if ItemsModel.shared.chatState.totalAfter == 0 { return } loadingMoreItems.wrappedValue = true + loadingBottomItems.wrappedValue = true Task { - try? await Task.sleep(nanoseconds: 1500_000000) + try? await Task.sleep(nanoseconds: 500_000000) if ChatModel.shared.chatId != chat.chatInfo.id { await MainActor.run { loadingMoreItems.wrappedValue = false @@ -25,6 +26,7 @@ func loadLastItems(_ loadingMoreItems: Binding, _ chat: Chat) { await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState) await MainActor.run { loadingMoreItems.wrappedValue = false + loadingBottomItems.wrappedValue = false } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index a8d19733b5..f3bfa294b4 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -33,6 +33,10 @@ struct ChatView: View { @State private var customUserProfile: Profile? @State private var connectionCode: String? @State private var loadingMoreItems = false + @State private var loadingTopItems = false + @State private var requestedTopScroll = false + @State private var loadingBottomItems = false + @State private var requestedBottomScroll = false @State private var searchMode = false @State private var searchText: String = "" @FocusState private var searchFocussed @@ -49,6 +53,7 @@ struct ChatView: View { @State private var allowToDeleteSelectedMessagesForAll: Bool = false @State private var allowLoadMoreItems: Bool = false @State private var ignoreLoadingRequests: Int64? = nil + @State private var animatedScrollingInProgress: Bool = false @State private var updateMergedItemsTask: Task? = nil @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() @@ -88,7 +93,7 @@ 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, listState: scrollView.listState, model: floatingButtonModel) + FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel) } connectingText() if selectedChatItems == nil { @@ -424,7 +429,9 @@ struct ChatView: View { index = mergedItems.boxedValue.indexInParentItems[itemId] } if let index { + await MainActor.run { animatedScrollingInProgress = true } await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) + await MainActor.run { animatedScrollingInProgress = false } } } catch { logger.error("Error scrolling to item: \(error)") @@ -551,7 +558,7 @@ struct ChatView: View { scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: chat.id) { _ in - loadLastItems($loadingMoreItems, chat) + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) allowLoadMoreItems = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { allowLoadMoreItems = true @@ -571,10 +578,10 @@ struct ChatView: View { } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { // scroll to the top unread item scrollView.scrollToItem(index) - loadLastItems($loadingMoreItems, chat) + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) } else { scrollView.scrollToBottom() - loadLastItems($loadingMoreItems, chat) + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) } } } @@ -620,7 +627,7 @@ struct ChatView: View { if let unreadIndex { scrollView.scrollToItem(unreadIndex) } - loadLastItems($loadingMoreItems, chat) + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { allowLoadMoreItems = true } @@ -659,7 +666,7 @@ struct ChatView: View { } // set floating button indication mode - let nearBottom = listState.firstVisibleItemIndex < 4 + let nearBottom = listState.firstVisibleItemIndex < 1 if nearBottom != self.isNearBottom { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.isNearBottom = nearBottom @@ -702,7 +709,11 @@ struct ChatView: View { let theme: AppTheme let scrollView: EndlessScrollView let chat: Chat - @Binding var loadingMoreItems: Bool + @Binding var loadingTopItems: Bool + @Binding var requestedTopScroll: Bool + @Binding var loadingBottomItems: Bool + @Binding var requestedBottomScroll: Bool + @Binding var animatedScrollingInProgress: Bool let listState: EndlessScrollView.ListState @ObservedObject var model: FloatingButtonModel @@ -717,8 +728,8 @@ struct ChatView: View { .padding(.vertical, 4) } VStack { - if model.unreadAbove > 0 { - if loadingMoreItems { + if model.unreadAbove > 0 && !animatedScrollingInProgress { + if loadingTopItems && requestedTopScroll { circleButton { ProgressView() } } else { circleButton { @@ -727,11 +738,11 @@ struct ChatView: View { .foregroundColor(theme.colors.primary) } .onTapGesture { - if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { - // scroll to the top unread item - Task { await scrollView.scrollToItemAnimated(index) } + if loadingTopItems { + requestedTopScroll = true + requestedBottomScroll = false } else { - logger.debug("No more unread items, total: \(listState.items.count)") + scrollToTopUnread() } } .contextMenu { @@ -746,37 +757,71 @@ struct ChatView: View { } } Spacer() - if model.unreadBelow > 0 { - if loadingMoreItems { + if listState.firstVisibleItemIndex != 0 && !animatedScrollingInProgress { + if loadingBottomItems && requestedBottomScroll { circleButton { ProgressView() } } else { circleButton { - unreadCountText(model.unreadBelow) - .font(.callout) - .foregroundColor(theme.colors.primary) + Group { + if model.unreadBelow > 0 { + unreadCountText(model.unreadBelow) + .font(.callout) + .foregroundColor(theme.colors.primary) + } else { + Image(systemName: "chevron.down").foregroundColor(theme.colors.primary) + } + } } .onTapGesture { - scrollView.scrollToBottomAnimated() + if loadingBottomItems { + requestedTopScroll = false + requestedBottomScroll = true + } else { + scrollToBottom() + } } } - } else if !model.isNearBottom { - if loadingMoreItems { - circleButton { ProgressView() } - } else { - circleButton { - Image(systemName: "chevron.down").foregroundColor(theme.colors.primary) - } - .onTapGesture { scrollView.scrollToBottomAnimated() } - } } } - .disabled(loadingMoreItems) .padding() .frame(maxWidth: .infinity, alignment: .trailing) } + .onChange(of: loadingTopItems) { loading in + if !loading && requestedTopScroll { + requestedTopScroll = false + scrollToTopUnread() + } + } + .onChange(of: loadingBottomItems) { loading in + if !loading && requestedBottomScroll { + requestedBottomScroll = false + scrollToBottom() + } + } .onDisappear(perform: model.resetDate) } + private func scrollToTopUnread() { + if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { + animatedScrollingInProgress = true + // scroll to the top unread item + Task { + await scrollView.scrollToItemAnimated(index) + await MainActor.run { animatedScrollingInProgress = false } + } + } else { + logger.debug("No more unread items, total: \(listState.items.count)") + } + } + + private func scrollToBottom() { + animatedScrollingInProgress = true + Task { + await scrollView.scrollToItemAnimated(0, top: false) + await MainActor.run { animatedScrollingInProgress = false } + } + } + private func circleButton(_ content: @escaping () -> Content) -> some View { ZStack { Circle() @@ -1010,10 +1055,20 @@ struct ChatView: View { if loadingMoreItems { return false } await MainActor.run { loadingMoreItems = true + if case .before = pagination { + loadingTopItems = true + } else if case .after = pagination { + loadingBottomItems = true + } } let triedToLoad = await loadChatItemsUnchecked(chat, pagination) await MainActor.run { loadingMoreItems = false + if case .before = pagination { + loadingTopItems = false + } else if case .after = pagination { + loadingBottomItems = false + } } return triedToLoad } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 676d9c43d2..5995bd30e1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -136,8 +136,11 @@ private class CustomUITextField: UITextView, UITextViewDelegate { } override var intrinsicContentSize: CGSize { - if height.wrappedValue != newHeight { - DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight }) + if height.wrappedValue != newHeight || + // when both heights equal to minHeight, we must update $height, even if it's the same, because only this way + // the swift ui wrapper will redisplay this view with updated height + newHeight == NativeTextEditor.minHeight { + DispatchQueue.main.async { self.height.wrappedValue = self.newHeight } } return CGSizeMake(0, newHeight) } diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme index a2639eb263..1a1fa8b800 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme @@ -74,7 +74,6 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" - askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> From ca3687488fe18d339a24cf82eb17153df2d50c96 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 21 Feb 2025 03:46:22 +0700 Subject: [PATCH 073/289] ios: equal scrolling speed to top/bottom and fix of scroll loop (#5649) --- .../Shared/Views/Chat/EndlessScrollView.swift | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index c19a1f00a2..81345e4a03 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -107,7 +107,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu var averageItemHeight: CGFloat = 30 /// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed - var scrollStepMultiplier: CGFloat = 0.6 + var scrollStepMultiplier: CGFloat = 0.37 /// Adds content padding to top var insetTop: CGFloat = 100 @@ -324,6 +324,8 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0 + var wasFirstVisibleItemOffset = listState.firstVisibleItemOffset + var alreadyChangedIndexWhileScrolling = false var allowOneMore = false var nextOffsetY: CGFloat = 0 var i = shouldBeFirstVisible @@ -343,16 +345,24 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } if shouldBeFirstVisible == i { if let vis = visible { - let oldHeight = vis.view.frame.height - vis.view.frame.origin.y += oldHeight - vis.view.frame.height - // the fist visible item previously is hidden now, remove it and move on - if !isVisible(vis.view) { + + if // there is auto scroll in progress and the first item has a higher offset than bottom part + // of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to + // re-make the first visible item + (listState.isScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || + // the fist visible item previously is hidden now, remove it and move on + !isVisible(vis.view) { let newIndex: Int if listState.isScrolling { // skip many items to make the scrolling take less time - var indexDiff = Int(ceil(abs(offsetsDiff / averageItemHeight))) + var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0 + // if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled + alreadyChangedIndexWhileScrolling = true + indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff newIndex = max(0, min(items.count - 1, i + indexDiff)) + // offset for the first visible item can now be 0 because the previous first visible item doesn't exist anymore + wasFirstVisibleItemOffset = 0 } else { // don't skip multiple items if it's manual scrolling gesture newIndex = i + (offsetsDiff <= 0 ? 1 : -1) @@ -370,7 +380,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view)) } else { let cell = createCell(i, items, &cellsToReuse)! - cell.frame.origin.y = bottomY + listState.firstVisibleItemOffset - cell.frame.height + cell.frame.origin.y = bottomY + wasFirstVisibleItemOffset - cell.frame.height vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell)) } if vis.view.superview == nil { @@ -499,7 +509,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier - let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step * stepSlowdownMultiplier + let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step adjustedOffset += offsetToScroll if let item = listState.visibleItems.first(where: { $0.index == index }) { let y = if top { @@ -544,7 +554,9 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier - let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step * stepSlowdownMultiplier + //println("Scrolling step \(step) \(stepSlowdownMultiplier) index \(index) \(firstOrLastIndex) \(index - firstOrLastIndex) \(adjustedOffset), up \(up), i \(i)") + + let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step adjustedOffset += offsetToScroll if let item = listState.visibleItems.first(where: { $0.index == index }) { let y = if top { From b27e964d0c8124d16f27e629fd7197a9543e84c8 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:20:02 +0700 Subject: [PATCH 074/289] desktop: closing modals when connected to remote host (#5650) --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 1 + 1 file changed, 1 insertion(+) 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 7205bcc89b..208a5557a5 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 @@ -2907,6 +2907,7 @@ object ChatController { is CR.RemoteHostConnected -> { // TODO needs to update it instead in sessions chatModel.currentRemoteHost.value = r.remoteHost + ModalManager.start.closeModals() switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.ContactDisabled -> { From e59967b0d6e10a4038246794e5da86236fb03a5f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:50:04 +0700 Subject: [PATCH 075/289] android, desktop: link previews with posters (#5652) * android, desktop: link previews with posters * slash --- .../simplex/common/views/helpers/LinkPreviews.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index cce7cf17a5..3ce3bfe6da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -40,11 +40,21 @@ suspend fun getLinkPreview(url: String): LinkPreview? { url } else -> { - val response = Jsoup.connect(url) + val connection = Jsoup.connect(url) .ignoreContentType(true) .timeout(10000) .followRedirects(true) - .execute() + + val response = if (url.lowercase().startsWith("https://x.com/")) { + // Apple sends request with special user-agent which handled differently by X.com. + // Different response that includes video poster from post + connection + .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0") + .execute() + } else { + connection + .execute() + } val doc = response.parse() val ogTags = doc.select(OG_SELECT_QUERY) title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title() From dd134504435f6a95010fe87f426b3ce57cab23ae Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:46:40 +0400 Subject: [PATCH 076/289] ios: what's new for v6.3 (#5651) * ios: what's new for v6.3 * update * space --------- Co-authored-by: Evgeny Poberezkin --- .../Views/Onboarding/WhatsNewView.swift | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 182c5652d7..a1cf3004dc 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -539,7 +539,46 @@ private let versionDescriptions: [VersionDescription] = [ description: "Delivered even when Apple drops them." )), ] - ) + ), + VersionDescription( + version: "v6.3", + // post: URL(string: "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"), + features: [ + .feature(Description( + icon: "at", + title: "Mention members 👋", + description: "Get notified when mentioned." + )), + .feature(Description( + icon: "flag", + title: "Send private reports", + description: "Help admins moderating their groups." + )), + .feature(Description( + icon: "list.bullet.clipboard", + title: "Organize chats into lists", + description: "Don't miss important messages." + )), + .feature(Description( + icon: nil, + title: "Better privacy and security", + description: nil, + subfeatures: [ + ("eye.slash", "Private media file names."), + ("trash", "Set message expiration in chats.") + ] + )), + .feature(Description( + icon: nil, + title: "Better groups performance", + description: nil, + subfeatures: [ + ("bolt", "Faster sending messages."), + ("person.2.slash", "Faster deletion of groups.") + ] + )), + ] + ), ] private let lastVersion = versionDescriptions.last!.version From bc9885675dbeeb08528768b338248031a26c1a30 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:09:22 +0400 Subject: [PATCH 077/289] android, desktop: what's new for v6.3 (#5654) --- .../Views/Onboarding/WhatsNewView.swift | 2 +- .../common/views/onboarding/WhatsNewView.kt | 39 +++++++++++++++++++ .../commonMain/resources/MR/base/strings.xml | 12 ++++++ .../commonMain/resources/MR/images/ic_at.svg | 1 + .../resources/MR/images/ic_group_off.svg | 1 + 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index a1cf3004dc..f2b4dd7928 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -555,7 +555,7 @@ private let versionDescriptions: [VersionDescription] = [ description: "Help admins moderating their groups." )), .feature(Description( - icon: "list.bullet.clipboard", + icon: "list.bullet", title: "Organize chats into lists", description: "Don't miss important messages." )), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index a66ef9ff7a..678e6e9b78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -762,6 +762,45 @@ private val versionDescriptions: List = listOf( descrId = MR.strings.v6_2_improved_chat_navigation_descr ), ), + ), + VersionDescription( + version = "v6.3", + // post = "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html", + features = listOf( + VersionFeature.FeatureDescription( + icon = MR.images.ic_at, + titleId = MR.strings.v6_3_mentions, + descrId = MR.strings.v6_3_mentions_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_flag, + titleId = MR.strings.v6_3_reports, + descrId = MR.strings.v6_3_reports_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_menu, + titleId = MR.strings.v6_3_organize_chat_lists, + descrId = MR.strings.v6_3_organize_chat_lists_descr + ), + VersionFeature.FeatureDescription( + icon = null, + titleId = MR.strings.v6_3_better_privacy_and_security, + descrId = null, + subfeatures = listOf( + MR.images.ic_visibility_off to MR.strings.v6_3_private_media_file_names, + MR.images.ic_delete to MR.strings.v6_3_set_message_expiration_in_chats + ) + ), + VersionFeature.FeatureDescription( + icon = null, + titleId = MR.strings.v6_3_better_groups_performance, + descrId = null, + subfeatures = listOf( + MR.images.ic_bolt to MR.strings.v6_3_faster_sending_messages, + MR.images.ic_group_off to MR.strings.v6_3_faster_deletion_of_groups + ) + ), + ) ) ) 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 eb4012255c..1d445f710f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2282,6 +2282,18 @@ - Open chat on the first unread message.\n- Jump to quoted messages. Business chats Privacy for your customers. + Mention members 👋 + Get notified when mentioned. + Send private reports + Help admins moderating their groups. + Organize chats into lists + Don\'t miss important messages. + Better privacy and security + Private media file names. + Set message expiration in chats. + Better groups performance + Faster sending messages. + Faster deletion of groups. View updated conditions diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg new file mode 100644 index 0000000000..d0d34555df --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg new file mode 100644 index 0000000000..710213ff55 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg @@ -0,0 +1 @@ + \ No newline at end of file From bf37c0762e1e35cdde8f04138c61d94a62450677 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 22 Feb 2025 19:15:33 +0700 Subject: [PATCH 078/289] ios: fix height of compose view field when having a draft (#5655) * ios: fix height of compose view field when having a draft * changes * simplified layout * changes * button size 29 -> 31 --------- Co-authored-by: Evgeny Poberezkin --- .../Chat/ComposeMessage/ComposeView.swift | 2 +- .../ComposeMessage/NativeTextEditor.swift | 100 +++++++++--------- .../Chat/ComposeMessage/SendMessageView.swift | 100 +++++++++--------- 3 files changed, 100 insertions(+), 102 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index b529919216..80c5973211 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -392,7 +392,7 @@ struct ComposeView: View { } .disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) .frame(width: 25, height: 25) - .padding(.bottom, 12) + .padding(.bottom, 16) .padding(.leading, 12) .tint(theme.colors.primary) if case let .group(g) = chat.chatInfo, diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 5995bd30e1..3eeb7ba7f5 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -22,13 +22,7 @@ struct NativeTextEditor: UIViewRepresentable { static let minHeight: CGFloat = 39 - private let defaultHeight: CGFloat = { - let field = CustomUITextField(parent: nil, height: Binding.constant(0)) - field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) - return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), 360).rounded(.down) - }() - - func makeUIView(context: Context) -> UITextView { + func makeUIView(context: Context) -> CustomUITextField { let field = CustomUITextField(parent: self, height: _height) field.backgroundColor = .clear field.text = text @@ -38,10 +32,9 @@ struct NativeTextEditor: UIViewRepresentable { if !disableEditing { text = newText field.textAlignment = alignment(text) - updateFont(field) + field.updateFont() // Speed up the process of updating layout, reduce jumping content on screen - updateHeight(field) - self.height = field.frame.size.height + field.updateHeight() } else { field.text = text } @@ -53,63 +46,38 @@ struct NativeTextEditor: UIViewRepresentable { field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.setPlaceholderView() - updateFont(field) - updateHeight(field) + field.updateFont() + field.updateHeight(updateBindingNow: false) return field } - func updateUIView(_ field: UITextView, context: Context) { + func updateUIView(_ field: CustomUITextField, context: Context) { if field.markedTextRange == nil && field.text != text { field.text = text field.textAlignment = alignment(text) - updateFont(field) - updateHeight(field) + field.updateFont() + field.updateHeight(updateBindingNow: false) } - - let castedField = field as! CustomUITextField - if castedField.placeholder != placeholder { - castedField.placeholder = placeholder + if field.placeholder != placeholder { + field.placeholder = placeholder } - if field.selectedRange != selectedRange { field.selectedRange = selectedRange } } - - private func updateHeight(_ field: UITextView) { - let maxHeight = min(360, field.font!.lineHeight * 12) - // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size - let newHeight = field.text == "" - ? defaultHeight - : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down) - - if field.frame.size.height != newHeight { - field.frame.size = CGSizeMake(field.frame.size.width, newHeight) - (field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight) - } - } - - private func updateFont(_ field: UITextView) { - let newFont = isShortEmoji(field.text) - ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) - : UIFont.preferredFont(forTextStyle: .body) - if field.font != newFont { - field.font = newFont - } - } } private func alignment(_ text: String) -> NSTextAlignment { isRightToLeft(text) ? .right : .left } -private class CustomUITextField: UITextView, UITextViewDelegate { +class CustomUITextField: UITextView, UITextViewDelegate { var parent: NativeTextEditor? var height: Binding var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } - + private let placeholderLabel: UILabel = UILabel() init(parent: NativeTextEditor?, height: Binding) { @@ -135,14 +103,44 @@ private class CustomUITextField: UITextView, UITextViewDelegate { invalidateIntrinsicContentSize() } - override var intrinsicContentSize: CGSize { - if height.wrappedValue != newHeight || - // when both heights equal to minHeight, we must update $height, even if it's the same, because only this way - // the swift ui wrapper will redisplay this view with updated height - newHeight == NativeTextEditor.minHeight { - DispatchQueue.main.async { self.height.wrappedValue = self.newHeight } + func updateHeight(updateBindingNow: Bool = true) { + let maxHeight = min(360, font!.lineHeight * 12) + let newHeight = min(max(sizeThatFits(CGSizeMake(frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down) + + if self.newHeight != newHeight { + frame.size = CGSizeMake(frame.size.width, newHeight) + invalidateIntrinsicContentHeight(newHeight) + if updateBindingNow { + self.height.wrappedValue = newHeight + } else { + DispatchQueue.main.async { + self.height.wrappedValue = newHeight + } + } } - return CGSizeMake(0, newHeight) + } + + func updateFont() { + let newFont = isShortEmoji(text) + ? (text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) + : UIFont.preferredFont(forTextStyle: .body) + if font != newFont { + font = newFont + // force apply new font because it has problem with doing it when the field had two emojis + if text.count == 0 { + text = " " + text = "" + } + } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateHeight() + } + + override var intrinsicContentSize: CGSize { + CGSizeMake(0, newHeight) } func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 64cfe3d260..9013bb6a88 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -44,53 +44,53 @@ struct SendMessageView: View { @UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { - ZStack { - let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) - HStack(alignment: .bottom) { - ZStack(alignment: .leading) { - if case .voicePreview = composeState.preview { - Text("Voice message…") - .font(teFont.italic()) - .multilineTextAlignment(.leading) - .foregroundColor(theme.colors.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } else { - NativeTextEditor( - text: $composeState.message, - disableEditing: $composeState.inProgress, - height: $teHeight, - focused: $keyboardVisible, - placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), - selectedRange: $selectedRange, - onImagesAdded: onMediaAdded - ) - .allowsTightening(false) - .fixedSize(horizontal: false, vertical: true) - } - } - if progressByTimeout { - ProgressView() - .scaleEffect(1.4) - .frame(width: 31, height: 31, alignment: .center) - .padding([.bottom, .trailing], 3) - } else { - VStack(alignment: .trailing) { - if teHeight > 100 && !composeState.inProgress { - deleteTextButton() - Spacer() - } - composeActionButtons() - } - .frame(height: teHeight, alignment: .bottom) - } + let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) + ZStack(alignment: .leading) { + if case .voicePreview = composeState.preview { + Text("Voice message…") + .font(teFont.italic()) + .multilineTextAlignment(.leading) + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .padding(.trailing, 32) + .frame(maxWidth: .infinity) + } else { + NativeTextEditor( + text: $composeState.message, + disableEditing: $composeState.inProgress, + height: $teHeight, + focused: $keyboardVisible, + placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), + selectedRange: $selectedRange, + onImagesAdded: onMediaAdded + ) + .padding(.trailing, 32) + .allowsTightening(false) + .fixedSize(horizontal: false, vertical: true) } - .padding(.vertical, 1) - .background(theme.colors.background) - .clipShape(composeShape) - .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) } + .overlay(alignment: .topTrailing, content: { + if !progressByTimeout && teHeight > 100 && !composeState.inProgress { + deleteTextButton() + } + }) + .overlay(alignment: .bottomTrailing, content: { + if progressByTimeout { + ProgressView() + .scaleEffect(1.4) + .frame(width: 31, height: 31, alignment: .center) + .padding([.bottom, .trailing], 4) + } else { + composeActionButtons() + // required for intercepting clicks + .background(.white.opacity(0.000001)) + } + }) + .padding(.vertical, 1) + .background(theme.colors.background) + .clipShape(composeShape) + .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) .onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.inProgress) { inProgress in if inProgress { @@ -169,7 +169,7 @@ struct SendMessageView: View { !composeState.sendEnabled || composeState.inProgress ) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) } @@ -192,7 +192,7 @@ struct SendMessageView: View { composeState.endLiveDisabled || disableSendButton ) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .contextMenu{ sendButtonContextMenuItems() } @@ -269,7 +269,7 @@ struct SendMessageView: View { .foregroundColor(theme.colors.primary) } .disabled(disabled) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) ._onButtonGesture { down in if down { @@ -325,7 +325,7 @@ struct SendMessageView: View { .foregroundColor(theme.colors.secondary) } .disabled(composeState.inProgress) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) } @@ -410,7 +410,7 @@ struct SendMessageView: View { .foregroundColor(theme.colors.primary) } .disabled(composeState.inProgress) - .frame(width: 29, height: 29) + .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) } From 41ccb14bfa2011e436afbf99fcdff2d3a03af434 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 23 Feb 2025 22:21:10 +0000 Subject: [PATCH 079/289] core: 6.3.0.6 (simplexmq 6.3.0.6) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- tests/ChatClient.hs | 9 +++++---- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cabal.project b/cabal.project index b867c0e93b..aa96704e62 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: c192339af9e9342902731f2d49ff380359be0dec + tag: f9d7b1eebc7e825423ee0d3b995a69c4006ac99c source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index a54d3a35a2..20b0b781d7 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c192339af9e9342902731f2d49ff380359be0dec" = "1h81kmwllqza7wil7w20ia934b6iny3dch9fln0x95l9q3f4zww7"; + "https://github.com/simplex-chat/simplexmq.git"."f9d7b1eebc7e825423ee0d3b995a69c4006ac99c" = "1yi4igwrgj5n0ljh06fhzzf2yprrwx9nrzp56s305z9g7mgx7gqm"; "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 982e00fe37..2d7172133d 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.0.5 +version: 6.3.0.6 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 124818765f..248d6ead47 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -54,7 +54,7 @@ import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (srvHostnamesSMPClientVersion) import Simplex.Messaging.Server (runSMPServerBlocking) -import Simplex.Messaging.Server.Env.STM +import Simplex.Messaging.Server.Env.STM (ServerConfig (..), StartOptions (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) import Simplex.Messaging.Server.MsgStore.Types (AMSType (..), SMSType (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransportServerConfig) @@ -283,13 +283,13 @@ insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) V #endif startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC -startTestChat_ TestParams {printOutput} db cfg opts user = do +startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {maintenance} user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts False void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts - atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry + atomically . unless maintenance $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput} @@ -517,7 +517,8 @@ smpServerCfg = smpAgentCfg = defaultSMPClientAgentConfig, allowSMPProxy = True, serverClientConcurrency = 16, - information = Nothing + information = Nothing, + startOptions = StartOptions False False } withSmpServer :: IO () -> IO () From c81fa7e6b0e4d47f0d23686b7b738e4ee2832a4b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:50:30 +0700 Subject: [PATCH 080/289] android, desktop: marking chat read fix (#5658) * android, desktop: marking chat read fix * comments --- apps/ios/SimpleXChat/ChatTypes.swift | 1 + .../kotlin/chat/simplex/common/model/ChatModel.kt | 3 ++- .../chat/simplex/common/views/chat/ChatItemsLoader.kt | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 60d5082584..554207e90d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1612,6 +1612,7 @@ public struct ChatStats: Decodable, Hashable { // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 public var minUnreadItemId: Int64 = 0 + // actual only via getChats(), otherwise, false public var unreadChat: Bool = false } 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 04fd6b380d..656937641c 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 @@ -1233,10 +1233,11 @@ data class Chat( @Serializable data class ChatStats( val unreadCount: Int = 0, - // actual only via getChats() and getChat(.initial), otherwise, zero val unreadMentions: Int = 0, + // actual only via getChats() and getChat(.initial), otherwise, zero val reportsCount: 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/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 3613ceaa7c..6a8c277ae1 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 @@ -54,11 +54,13 @@ suspend fun processLoadedChat( if (contentTag == null) { // update main chats, not content tagged withChats { - if (getChat(chat.id) == null) { + val oldChat = getChat(chat.id) + if (oldChat == null) { addChat(chat) } else { updateChatInfo(chat.remoteHostId, chat.chatInfo) - updateChatStats(chat.remoteHostId, chat.id, chat.chatStats) + // unreadChat is currently not actual in getChat query (always false) + updateChatStats(chat.remoteHostId, chat.id, chat.chatStats.copy(unreadChat = oldChat.chatStats.unreadChat)) } } } From 4f00f9efa0c0b991fb4bcaf95e233ba7d0e8387b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 24 Feb 2025 15:12:12 +0700 Subject: [PATCH 081/289] android, desktop: correct width of quoted message with link view (#5659) --- .../kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index 3ce3bfe6da..de4e9fe281 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layoutId import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -19,6 +20,7 @@ import chat.simplex.common.model.LinkPreview import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.chatViewScrollState +import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -133,7 +135,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel @Composable fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongClick: () -> Unit) { - Column(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { + Column(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } Image( base64ToBitmap(linkPreview.image), From b96b6c70d2fac550851e8f4c3b1b2657edee5b01 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:28:46 +0700 Subject: [PATCH 082/289] android, desktop: fix showing big gifs (opens in viewer) (#5660) --- .../chat/simplex/common/views/chat/item/CIImageView.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 401d098bea..0863c28007 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -8,6 +8,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale @@ -122,7 +123,7 @@ fun CIImageView( // IllegalStateException: Recording currently in progress - missing #endRecording() call? // but can display 5000px image. Using even lower value here just to feel safer. // It happens to WebP because it's not compressed while sending since it can be animated. - if (painter.intrinsicSize.width <= 4320 && painter.intrinsicSize.height <= 4320) { + if (painter.intrinsicSize != Size.Unspecified && painter.intrinsicSize.width <= 4320 && painter.intrinsicSize.height <= 4320) { Image( painter, contentDescription = stringResource(MR.strings.image_descr), @@ -140,7 +141,7 @@ fun CIImageView( ) } else { Box(Modifier - .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) + .width(if (painter.intrinsicSize != Size.Unspecified && painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) .combinedClickable( onLongClick = { showMenu.value = true }, onClick = {} From c972298dd213b32b41798100be96655257652c31 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:31:41 +0700 Subject: [PATCH 083/289] ui: drop filter when no such tags exist (#5662) * ui: drop filter when no such tags exist * ios * android/desktop * change * change * ios * change * change * ios * android * ios --- apps/ios/Shared/Model/ChatModel.swift | 21 ++++++++++------ .../chat/simplex/common/model/ChatModel.kt | 24 ++++++++++++------- .../common/views/chatlist/TagListView.kt | 5 ++-- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 0be8436320..9171bf5073 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -123,11 +123,9 @@ class ChatTagsModel: ObservableObject { } } } - if case let .presetTag(tag) = tm.activeFilter, (newPresetTags[tag] ?? 0) == 0 { - activeFilter = nil - } presetTags = newPresetTags unreadTags = newUnreadTags + clearActiveChatFilterIfNeeded() } func updateChatFavorite(favorite: Bool, wasFavorite: Bool) { @@ -136,9 +134,7 @@ class ChatTagsModel: ObservableObject { presetTags[.favorites] = (count ?? 0) + 1 } else if !favorite && wasFavorite, let count { presetTags[.favorites] = max(0, count - 1) - if case .presetTag(.favorites) = activeFilter, (presetTags[.favorites] ?? 0) == 0 { - activeFilter = nil - } + clearActiveChatFilterIfNeeded() } } @@ -162,6 +158,7 @@ class ChatTagsModel: ObservableObject { } } } + clearActiveChatFilterIfNeeded() } func markChatTagRead(_ chat: Chat) -> Void { @@ -192,7 +189,17 @@ class ChatTagsModel: ObservableObject { func changeGroupReportsTag(_ by: Int = 0) { if by == 0 { return } - presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by + presetTags[.groupReports] = max(0, (presetTags[.groupReports] ?? 0) + by) + clearActiveChatFilterIfNeeded() + } + + func clearActiveChatFilterIfNeeded() { + let clear = switch activeFilter { + case let .presetTag(tag): (presetTags[tag] ?? 0) == 0 + case let .userTag(tag): !userTags.contains(tag) + case .unread, nil: false + } + if clear { activeFilter = 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 656937641c..6424397531 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 @@ -247,15 +247,12 @@ object ChatModel { } } - if (activeChatTagFilter.value is ActiveFilter.PresetTag && - (newPresetTags[(activeChatTagFilter.value as ActiveFilter.PresetTag).tag] ?: 0) == 0) { - activeChatTagFilter.value = null - } - presetTags.clear() presetTags.putAll(newPresetTags) unreadTags.clear() unreadTags.putAll(newUnreadTags) + + clearActiveChatFilterIfNeeded() } fun updateChatFavorite(favorite: Boolean, wasFavorite: Boolean) { @@ -265,9 +262,7 @@ object ChatModel { presetTags[PresetTagKind.FAVORITES] = (count ?: 0) + 1 } else if (!favorite && wasFavorite && count != null) { presetTags[PresetTagKind.FAVORITES] = maxOf(0, count - 1) - if (activeChatTagFilter.value == ActiveFilter.PresetTag(PresetTagKind.FAVORITES) && (presetTags[PresetTagKind.FAVORITES] ?: 0) == 0) { - activeChatTagFilter.value = null - } + clearActiveChatFilterIfNeeded() } } @@ -288,6 +283,7 @@ object ChatModel { } } } + clearActiveChatFilterIfNeeded() } fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { @@ -875,10 +871,20 @@ object ChatModel { private fun changeGroupReportsTagNoContentTag(by: Int = 0) { if (by == 0 || contentTag != null) return - presetTags[PresetTagKind.GROUP_REPORTS] = (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by + presetTags[PresetTagKind.GROUP_REPORTS] = kotlin.math.max(0, (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by) + clearActiveChatFilterIfNeeded() } } + fun clearActiveChatFilterIfNeeded() { + val clear = when(val f = activeChatTagFilter.value) { + is ActiveFilter.PresetTag -> (presetTags[f.tag] ?: 0) == 0 + is ActiveFilter.UserTag -> userTags.value.none { it.chatTagId == f.tag.chatTagId } + is ActiveFilter.Unread, null -> false + } + if (clear) activeChatTagFilter.value = null + } + fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 1b563e6d02..6c88162f8a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -31,6 +31,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.apiDeleteChatTag import chat.simplex.common.model.ChatController.apiSetChatTags import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel.clearActiveChatFilterIfNeeded import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* @@ -447,9 +448,7 @@ private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState) val tagId = tag.chatTagId if (apiDeleteChatTag(rhId, tagId)) { chatModel.userTags.value = chatModel.userTags.value.filter { it.chatTagId != tagId } - if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { - chatModel.activeChatTagFilter.value = null - } + clearActiveChatFilterIfNeeded() chatModel.chats.value.forEach { c -> when (val cInfo = c.chatInfo) { is ChatInfo.Direct -> { From b494c43706eafe3b7a253b659670d711e395e1be Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 24 Feb 2025 18:34:27 +0000 Subject: [PATCH 084/289] website: translations (#5664) * Translated using Weblate (Dutch) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ --------- Co-authored-by: M1K4 --- website/langs/nl.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/langs/nl.json b/website/langs/nl.json index c725cc3d3e..4db6126c2c 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -255,5 +255,6 @@ "docs-dropdown-10": "Transparantie", "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Beveiliging", - "hero-overlay-card-3-p-3": "Trail of Bits heeft in juli 2024 het cryptografische ontwerp van SimpleX-netwerkprotocollen beoordeeld. Lees meer." + "hero-overlay-card-3-p-3": "Trail of Bits heeft in juli 2024 het cryptografische ontwerp van SimpleX-netwerkprotocollen beoordeeld. Lees meer.", + "docs-dropdown-14": "SimpleX voor bedrijven" } From a5334b36f8c12afaa6a03c8974f1f32af93505a2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 24 Feb 2025 20:23:20 +0000 Subject: [PATCH 085/289] ui: translations (#5663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Czech) Currently translated at 92.0% (2130 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Polish) Currently translated at 90.3% (1850 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Persian) Currently translated at 77.2% (1788 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fa/ * Translated using Weblate (Romanian) Currently translated at 34.5% (799 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ro/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2047 of 2047 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% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2314 of 2314 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% (2047 of 2047 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Turkish) Currently translated at 90.8% (2102 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 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% (2314 of 2314 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% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2314 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (French) Currently translated at 99.0% (2292 of 2314 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2326 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2326 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 99.8% (2323 of 2326 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% (2326 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2326 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2326 of 2326 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% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2326 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2326 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2326 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Croatian) Currently translated at 60.8% (1415 of 2326 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2326 of 2326 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% (2047 of 2047 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% (2326 of 2326 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% (2047 of 2047 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% (2326 of 2326 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% (2047 of 2047 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% (2326 of 2326 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% (2047 of 2047 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * import/export --------- Co-authored-by: mlanp Co-authored-by: Random Co-authored-by: 大王叫我来巡山 Co-authored-by: No name Co-authored-by: M1K4 Co-authored-by: idk Co-authored-by: jonnysemon Co-authored-by: Jester Hodl Co-authored-by: summoner001 Co-authored-by: Goudarz Jafari Co-authored-by: Cosmin Co-authored-by: Rafi Co-authored-by: fran secs Co-authored-by: Bezruchenko Simon Co-authored-by: Doğaç Tanrıverdi Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: Karlos Sagan Co-authored-by: Nenad --- .../bg.xcloc/Localized Contents/bg.xliff | 56 ++ .../cs.xcloc/Localized Contents/cs.xliff | 56 ++ .../de.xcloc/Localized Contents/de.xliff | 105 ++- .../en.xcloc/Localized Contents/en.xliff | 70 ++ .../es.xcloc/Localized Contents/es.xliff | 109 ++- .../fi.xcloc/Localized Contents/fi.xliff | 56 ++ .../fr.xcloc/Localized Contents/fr.xliff | 56 ++ .../hu.xcloc/Localized Contents/hu.xliff | 645 ++++++++++------- .../it.xcloc/Localized Contents/it.xliff | 99 +++ .../ja.xcloc/Localized Contents/ja.xliff | 56 ++ .../nl.xcloc/Localized Contents/nl.xliff | 101 ++- .../pl.xcloc/Localized Contents/pl.xliff | 73 ++ .../ru.xcloc/Localized Contents/ru.xliff | 56 ++ .../th.xcloc/Localized Contents/th.xliff | 56 ++ .../tr.xcloc/Localized Contents/tr.xliff | 56 ++ .../uk.xcloc/Localized Contents/uk.xliff | 56 ++ .../Localized Contents/zh-Hans.xliff | 56 ++ .../SimpleX NSE/pl.lproj/Localizable.strings | 8 +- .../SimpleX SE/hu.lproj/Localizable.strings | 4 +- apps/ios/de.lproj/Localizable.strings | 138 +++- apps/ios/es.lproj/Localizable.strings | 147 +++- apps/ios/hu.lproj/Localizable.strings | 676 +++++++++++------- apps/ios/it.lproj/Localizable.strings | 132 +++- apps/ios/nl.lproj/Localizable.strings | 134 +++- apps/ios/pl.lproj/Localizable.strings | 48 ++ .../commonMain/resources/MR/ar/strings.xml | 78 +- .../commonMain/resources/MR/ca/strings.xml | 26 + .../commonMain/resources/MR/cs/strings.xml | 169 +++++ .../commonMain/resources/MR/de/strings.xml | 21 +- .../commonMain/resources/MR/es/strings.xml | 42 +- .../commonMain/resources/MR/fa/strings.xml | 1 + .../commonMain/resources/MR/fr/strings.xml | 5 + .../commonMain/resources/MR/hr/strings.xml | 223 +++++- .../commonMain/resources/MR/hu/strings.xml | 447 ++++++------ .../commonMain/resources/MR/in/strings.xml | 11 + .../commonMain/resources/MR/it/strings.xml | 27 +- .../commonMain/resources/MR/nl/strings.xml | 25 +- .../commonMain/resources/MR/ro/strings.xml | 72 +- .../commonMain/resources/MR/tr/strings.xml | 23 +- .../commonMain/resources/MR/uk/strings.xml | 18 +- .../commonMain/resources/MR/vi/strings.xml | 11 + .../resources/MR/zh-rCN/strings.xml | 23 + 42 files changed, 3409 insertions(+), 862 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 396232e038..dce5d2030c 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -1212,6 +1212,10 @@ По-добри групи No comment provided by engineer.
+ + Better groups performance + No comment provided by engineer. + Better message dates. По-добри дати на съобщението. @@ -1232,6 +1236,10 @@ Подобрени известия No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ По-добра сигурност ✅ @@ -2730,6 +2738,10 @@ This is your own one-time link! Не активирай No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Не показвай отново @@ -3452,11 +3464,19 @@ This is your own one-time link! Бързо и без чакане, докато подателят е онлайн! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. По-бързо присъединяване и по-надеждни съобщения. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Любим @@ -3731,6 +3751,10 @@ Error: %2$@ GIF файлове и стикери No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3843,6 +3867,10 @@ Error: %2$@ Помощ No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Скрит @@ -4595,6 +4623,10 @@ This is your link for group %@! Членовете на групата могат да изпращат гласови съобщения. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -5027,6 +5059,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5357,6 +5393,10 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Други @@ -5608,6 +5648,10 @@ Error: %@ Поверителни имена на файлове No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -6470,6 +6514,10 @@ Enable in *Network & servers* settings. Изпращай известия No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Изпращайте въпроси и идеи @@ -6690,6 +6738,10 @@ Enable in *Network & servers* settings. Задайте го вместо системната идентификация. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Задай kод за достъп @@ -7444,6 +7496,10 @@ It can happen because of some bug or when the connection is compromised.This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Тази настройка се прилага за съобщения в текущия ви профил **%@**. 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 1f417ad85a..55b750b371 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1156,6 +1156,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1173,6 +1177,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -2611,6 +2619,10 @@ This is your own one-time link! Nepovolovat No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Znovu neukazuj @@ -3310,10 +3322,18 @@ This is your own one-time link! Rychle a bez čekání, než bude odesílatel online! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Oblíbené @@ -3579,6 +3599,10 @@ Error: %2$@ GIFy a nálepky No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3689,6 +3713,10 @@ Error: %2$@ Pomoc No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Skryté @@ -4414,6 +4442,10 @@ This is your link for group %@! Členové skupiny mohou posílat hlasové zprávy. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4830,6 +4862,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5150,6 +5186,10 @@ Vyžaduje povolení sítě VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -5392,6 +5432,10 @@ Error: %@ Soukromé názvy souborů No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -6233,6 +6277,10 @@ Enable in *Network & servers* settings. Odeslat oznámení No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Zasílání otázek a nápadů @@ -6451,6 +6499,10 @@ Enable in *Network & servers* settings. Nastavte jej namísto ověřování systému. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Nastavit heslo @@ -7184,6 +7236,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Toto nastavení platí pro zprávy ve vašem aktuálním chat profilu **%@**. 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 9ab9b2a818..3efdfa942b 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -491,7 +491,7 @@ 1 month - Älter als einen Monat + Älter als ein Monat delete after time time interval @@ -545,10 +545,12 @@ @%@ + @%@ No comment provided by engineer. @'%@' + @'%@' No comment provided by engineer. @@ -653,6 +655,7 @@ Active + Aktiv token status text @@ -842,6 +845,7 @@ All reports will be archived for you. + Alle Meldungen werden für Sie archiviert. No comment provided by engineer. @@ -921,6 +925,7 @@ Allow to report messsages to moderators. + Melden von Nachrichten an Moderatoren erlauben. No comment provided by engineer. @@ -1090,10 +1095,12 @@ Archive %lld reports? + Archiviere %lld Meldungen? No comment provided by engineer. Archive all reports? + Alle Meldungen archivieren? No comment provided by engineer. @@ -1118,6 +1125,7 @@ Archive reports + Meldungen archivieren swipe action @@ -1235,6 +1243,10 @@ Bessere Gruppen No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Verbesserte Nachrichten-Datumsinformation @@ -1255,6 +1267,10 @@ Verbesserte Benachrichtigungen No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Verbesserte Sicherheit ✅ @@ -1679,6 +1695,7 @@ Clear group? + Gruppe entfernen? No comment provided by engineer. @@ -1828,6 +1845,7 @@ Confirmed + Bestätigt token status text @@ -2532,7 +2550,7 @@ Das ist Ihr eigener Einmal-Link! Delete messages after - Löschen der Nachrichten + Nachrichten löschen No comment provided by engineer. @@ -2845,6 +2863,10 @@ Das ist Ihr eigener Einmal-Link! Nicht aktivieren No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Nicht nochmals anzeigen @@ -3203,6 +3225,7 @@ Das ist Ihr eigener Einmal-Link! Error checking token status + Fehler beim Überprüfen des Token-Status No comment provided by engineer. @@ -3362,6 +3385,7 @@ Das ist Ihr eigener Einmal-Link! Error registering for notifications + Fehler beim Registrieren für Benachrichtigungen alert title @@ -3471,6 +3495,7 @@ Das ist Ihr eigener Einmal-Link! Error testing server connection + Fehler beim Testen der Server-Verbindung No comment provided by engineer. @@ -3555,6 +3580,7 @@ Das ist Ihr eigener Einmal-Link! Expired + Abgelaufen token status text @@ -3597,11 +3623,19 @@ Das ist Ihr eigener Einmal-Link! Schnell und ohne warten auf den Absender, bis er online ist! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Favorit @@ -3758,6 +3792,7 @@ Das ist Ihr eigener Einmal-Link! For all moderators + Für alle Moderatoren No comment provided by engineer. @@ -3777,6 +3812,7 @@ Das ist Ihr eigener Einmal-Link! For me + Für mich No comment provided by engineer. @@ -3903,6 +3939,10 @@ Fehler: %2$@ GIFs und Sticker No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Guten Nachmittag! @@ -4018,6 +4058,10 @@ Fehler: %2$@ Hilfe No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Verborgen @@ -4329,22 +4373,27 @@ Weitere Verbesserungen sind bald verfügbar! Invalid + Ungültig token status text Invalid (bad token) + Ungültig (falsches Token) token status text Invalid (expired) + Ungültig (abgelaufen) token status text Invalid (unregistered) + Ungültig (nicht registriert) token status text Invalid (wrong topic) + Ungültig (falsches Thema) token status text @@ -4727,6 +4776,7 @@ Das ist Ihr Link für die Gruppe %@! Member reports + Mitglieder-Meldungen chat feature @@ -4766,6 +4816,7 @@ Das ist Ihr Link für die Gruppe %@! Members can report messsages to moderators. + Mitglieder können Nachrichten an Moderatoren melden. No comment provided by engineer. @@ -4793,6 +4844,10 @@ Das ist Ihr Link für die Gruppe %@! Gruppenmitglieder können Sprachnachrichten versenden. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menüs @@ -5045,6 +5100,7 @@ Das ist Ihr Link für die Gruppe %@! Mute all + Alle stummschalten notification label action @@ -5099,6 +5155,7 @@ Das ist Ihr Link für die Gruppe %@! New + Neu token status text @@ -5256,6 +5313,10 @@ Das ist Ihr Link für die Gruppe %@! Keine Medien- und Dateiserver. servers error + + No message + No comment provided by engineer. + No message servers. Keine Nachrichten-Server. @@ -5313,6 +5374,7 @@ Das ist Ihr Link für die Gruppe %@! No token! + Kein Token! alert title @@ -5357,6 +5419,7 @@ Das ist Ihr Link für die Gruppe %@! Notifications error + Benachrichtigungs-Fehler alert title @@ -5366,6 +5429,7 @@ Das ist Ihr Link für die Gruppe %@! Notifications status + Benachrichtigungs-Status alert title @@ -5611,6 +5675,10 @@ Dies erfordert die Aktivierung eines VPNs. Oder zum privaten Teilen No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Andere @@ -5804,14 +5872,17 @@ Fehler: %@ Please try to disable and re-enable notfications. + Bitte versuchen Sie, die Benachrichtigungen zu deaktivieren und wieder zu aktivieren. token info Please wait for token activation to complete. + Bitte warten Sie, bis die Token-Aktivierung abgeschlossen ist. token info Please wait for token to be registered. + Bitte warten Sie auf die Registrierung des Tokens. token info @@ -5874,6 +5945,10 @@ Fehler: %@ Neutrale Dateinamen No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Privates Nachrichten-Routing @@ -5951,6 +6026,7 @@ Fehler: %@ Prohibit reporting messages to moderators. + Melden von Nachrichten an Moderatoren nicht erlauben. No comment provided by engineer. @@ -6212,14 +6288,17 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Register + Registrieren No comment provided by engineer. Register notification token? + Benachrichtigungs-Token registrieren? token info Registered + Registriert token status text @@ -6360,10 +6439,12 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Report: (text.isEmpty ? reason.text : text) + Meldung: (text.isEmpty ? reason.text : text) report in notification Reporting messages to moderators is prohibited. + Melden von Nachrichten an Moderatoren ist nicht erlaubt. No comment provided by engineer. @@ -6792,6 +6873,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Benachrichtigungen senden No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Senden Sie Fragen und Ideen @@ -7032,6 +7117,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Anstelle der System-Authentifizierung festlegen. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Zugangscode einstellen @@ -7498,6 +7587,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. TCP port for messaging + TCP-Port für Nachrichtenübermittlung No comment provided by engineer. @@ -7577,6 +7667,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Test notifications + Benachrichtigungen testen No comment provided by engineer. @@ -7836,6 +7927,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Dieser Link wurde schon mit einem anderen Mobiltelefon genutzt. Bitte erstellen sie einen neuen Link in der Desktop-App. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**. @@ -7950,6 +8045,7 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Token status: %@. + Token-Status: %@. token status @@ -8211,6 +8307,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Use TCP port %@ when no port is specified. + Solange kein Port konfiguriert ist, wird TCP-Port %@ genutzt. No comment provided by engineer. @@ -8290,6 +8387,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Use web port + Web-Port nutzen No comment provided by engineer. @@ -8848,6 +8946,7 @@ Verbindungsanfrage wiederholen? You should receive notifications. + Sie sollten Benachrichtigungen erhalten. token info @@ -9327,7 +9426,7 @@ Verbindungsanfrage wiederholen? default (%@) - Voreinstellung (%@) + Default (%@) delete after time pref value 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 f1edf1e8cd..6d0086bd34 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1243,6 +1243,11 @@ Better groups No comment provided by engineer. + + Better groups performance + Better groups performance + No comment provided by engineer. + Better message dates. Better message dates. @@ -1263,6 +1268,11 @@ Better notifications No comment provided by engineer. + + Better privacy and security + Better privacy and security + No comment provided by engineer. + Better security ✅ Better security ✅ @@ -2855,6 +2865,11 @@ This is your own one-time link! Don't enable No comment provided by engineer. + + Don't miss important messages. + Don't miss important messages. + No comment provided by engineer. + Don't show again Don't show again @@ -3611,11 +3626,21 @@ This is your own one-time link! Fast and no wait until the sender is online! No comment provided by engineer. + + Faster deletion of groups. + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + Faster sending messages. + No comment provided by engineer. + Favorite Favorite @@ -3919,6 +3944,11 @@ Error: %2$@ GIFs and stickers No comment provided by engineer. + + Get notified when mentioned. + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Good afternoon! @@ -4034,6 +4064,11 @@ Error: %2$@ Help No comment provided by engineer. + + Help admins moderating their groups. + Help admins moderating their groups. + No comment provided by engineer. + Hidden Hidden @@ -4816,6 +4851,11 @@ This is your link for group %@! Members can send voice messages. No comment provided by engineer. + + Mention members 👋 + Mention members 👋 + No comment provided by engineer. + Menus Menus @@ -5281,6 +5321,11 @@ This is your link for group %@! No media & file servers. servers error + + No message + No message + No comment provided by engineer. + No message servers. No message servers. @@ -5639,6 +5684,11 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + Organize chats into lists + No comment provided by engineer. + Other Other @@ -5905,6 +5955,11 @@ Error: %@ Private filenames No comment provided by engineer. + + Private media file names. + Private media file names. + No comment provided by engineer. + Private message routing Private message routing @@ -6829,6 +6884,11 @@ Enable in *Network & servers* settings. Send notifications No comment provided by engineer. + + Send private reports + Send private reports + No comment provided by engineer. + Send questions and ideas Send questions and ideas @@ -7069,6 +7129,11 @@ Enable in *Network & servers* settings. Set it instead of system authentication. No comment provided by engineer. + + Set message expiration in chats. + Set message expiration in chats. + No comment provided by engineer. + Set passcode Set passcode @@ -7875,6 +7940,11 @@ It can happen because of some bug or when the connection is compromised.This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. This setting applies to messages in your current chat profile **%@**. 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 968dd52f13..c2b996fd2e 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -545,10 +545,12 @@ @%@ + @%@ No comment provided by engineer. @'%@' + @'%@' No comment provided by engineer. @@ -653,6 +655,7 @@ Active + Activo token status text @@ -842,6 +845,7 @@ All reports will be archived for you. + Todos los informes serán archivados para ti. No comment provided by engineer. @@ -921,6 +925,7 @@ Allow to report messsages to moderators. + Se permite informar de mensajes a los moderadores. No comment provided by engineer. @@ -1090,10 +1095,12 @@ Archive %lld reports? + ¿Archivar %lld informes? No comment provided by engineer. Archive all reports? + ¿Archivar todos los informes? No comment provided by engineer. @@ -1118,6 +1125,7 @@ Archive reports + Archivar informes swipe action @@ -1235,6 +1243,10 @@ Grupos mejorados No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Sistema de fechas mejorado. @@ -1255,6 +1267,10 @@ Notificaciones mejoradas No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Seguridad mejorada ✅ @@ -1679,6 +1695,7 @@ Clear group? + ¿Vaciar grupo? No comment provided by engineer. @@ -1828,6 +1845,7 @@ Confirmed + Confirmado token status text @@ -2527,7 +2545,7 @@ This is your own one-time link! Delete messages - Eliminar mensajes + Activar alert button @@ -2845,6 +2863,10 @@ This is your own one-time link! No activar No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again No volver a mostrar @@ -3203,6 +3225,7 @@ This is your own one-time link! Error checking token status + Error al verificar el estado del token No comment provided by engineer. @@ -3362,11 +3385,12 @@ This is your own one-time link! Error registering for notifications + Error al registrarse para notificaciones alert title Error removing member - Error al eliminar miembro + Error al expulsar miembro No comment provided by engineer. @@ -3471,6 +3495,7 @@ This is your own one-time link! Error testing server connection + Error al testar la conexión al servidor No comment provided by engineer. @@ -3555,6 +3580,7 @@ This is your own one-time link! Expired + Expirado token status text @@ -3597,11 +3623,19 @@ This is your own one-time link! ¡Rápido y sin necesidad de esperar a que el remitente esté en línea! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Mensajería más segura y conexión más rápida. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Favoritos @@ -3758,6 +3792,7 @@ This is your own one-time link! For all moderators + Para todos los moderadores No comment provided by engineer. @@ -3777,6 +3812,7 @@ This is your own one-time link! For me + para mí No comment provided by engineer. @@ -3903,6 +3939,10 @@ Error: %2$@ GIFs y stickers No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! ¡Buenas tardes! @@ -4018,6 +4058,10 @@ Error: %2$@ Ayuda No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Oculto @@ -4329,22 +4373,27 @@ More improvements are coming soon! Invalid + No válido token status text Invalid (bad token) + No válido (token incorrecto) token status text Invalid (expired) + No válido (expirado) token status text Invalid (unregistered) + No válido (no registrado) token status text Invalid (wrong topic) + No válido (tópico incorrecto) token status text @@ -4727,6 +4776,7 @@ This is your link for group %@! Member reports + Informes de miembro chat feature @@ -4766,6 +4816,7 @@ This is your link for group %@! Members can report messsages to moderators. + Los miembros pueden informar de mensajes a los moderadores. No comment provided by engineer. @@ -4793,6 +4844,10 @@ This is your link for group %@! Los miembros del grupo pueden enviar mensajes de voz. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menus @@ -5045,6 +5100,7 @@ This is your link for group %@! Mute all + Silenciar todo notification label action @@ -5099,6 +5155,7 @@ This is your link for group %@! New + Nuevo token status text @@ -5256,6 +5313,10 @@ This is your link for group %@! Sin servidores para archivos y multimedia. servers error + + No message + No comment provided by engineer. + No message servers. Sin servidores para mensajes. @@ -5313,6 +5374,7 @@ This is your link for group %@! No token! + ¡Sin token! alert title @@ -5357,6 +5419,7 @@ This is your link for group %@! Notifications error + Error en notificaciones alert title @@ -5366,6 +5429,7 @@ This is your link for group %@! Notifications status + Estado notificaciones alert title @@ -5611,6 +5675,10 @@ Requiere activación de la VPN. O para compartir en privado No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Otro @@ -5804,14 +5872,17 @@ Error: %@ Please try to disable and re-enable notfications. + Por favor, intenta desactivar y reactivar las notificaciones. token info Please wait for token activation to complete. + Por favor, espera a que el token de activación se complete. token info Please wait for token to be registered. + Por favor, espera a que el token se registre. token info @@ -5874,6 +5945,10 @@ Error: %@ Nombres de archivos privados No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Enrutamiento privado de mensajes @@ -5951,6 +6026,7 @@ Error: %@ Prohibit reporting messages to moderators. + No se permite informar de mensajes a los moderadores. No comment provided by engineer. @@ -6212,14 +6288,17 @@ Actívalo en ajustes de *Servidores y Redes*. Register + Registrar No comment provided by engineer. Register notification token? + ¿Registrar el token de notificaciones? token info Registered + Registrado token status text @@ -6360,10 +6439,12 @@ Actívalo en ajustes de *Servidores y Redes*. Report: (text.isEmpty ? reason.text : text) + Informe: (text.isEmpty ? reason.text : text) report in notification Reporting messages to moderators is prohibited. + No se permite informar de mensajes a los moderadores. No comment provided by engineer. @@ -6792,6 +6873,10 @@ Actívalo en ajustes de *Servidores y Redes*. Enviar notificaciones No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Consultas y sugerencias @@ -7032,6 +7117,10 @@ Actívalo en ajustes de *Servidores y Redes*. Úsalo en lugar de la autenticación del sistema. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Código autodestrucción @@ -7498,6 +7587,7 @@ Actívalo en ajustes de *Servidores y Redes*. TCP port for messaging + Puerto TCP para mensajes No comment provided by engineer. @@ -7577,6 +7667,7 @@ Actívalo en ajustes de *Servidores y Redes*. Test notifications + Probar notificaciones No comment provided by engineer. @@ -7783,7 +7874,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. - Todos los mensajes previos a la selección serán eliminados del chat. ¡No podrá deshacerse! + Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No podrá deshacerse! alert message @@ -7836,6 +7927,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Esta configuración se aplica a los mensajes del perfil actual **%@**. @@ -7950,6 +8045,7 @@ Se te pedirá que completes la autenticación antes de activar esta función.
Token status: %@. + Estado token: %@. token status @@ -8211,6 +8307,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use TCP port %@ when no port is specified. + Se usa el puerto TCP %@ cuando no se ha especificado otro. No comment provided by engineer. @@ -8290,6 +8387,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use web port + Usar puerto web No comment provided by engineer. @@ -8848,6 +8946,7 @@ Repeat connection request? You should receive notifications. + Deberías recibir notificaciones. token info @@ -9242,7 +9341,7 @@ Repeat connection request? connecting - conectando + conectando... No comment provided by engineer. @@ -9353,7 +9452,7 @@ Repeat connection request? deleted group - grupo eliminado + ha eliminado el grupo rcv group event chat item 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 e8113a5553..d0a7bd7e77 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1142,6 +1142,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1159,6 +1163,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -2596,6 +2604,10 @@ This is your own one-time link! Älä salli No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Älä näytä uudelleen @@ -3292,10 +3304,18 @@ This is your own one-time link! Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Suosikki @@ -3561,6 +3581,10 @@ Error: %2$@ GIFit ja tarrat No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3671,6 +3695,10 @@ Error: %2$@ Apua No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Piilotettu @@ -4396,6 +4424,10 @@ This is your link for group %@! Ryhmän jäsenet voivat lähettää ääniviestejä. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4811,6 +4843,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5130,6 +5166,10 @@ Edellyttää VPN:n sallimista. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -5372,6 +5412,10 @@ Error: %@ Yksityiset tiedostonimet No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -6212,6 +6256,10 @@ Enable in *Network & servers* settings. Lähetys ilmoitukset No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Lähetä kysymyksiä ja ideoita @@ -6430,6 +6478,10 @@ Enable in *Network & servers* settings. Aseta se järjestelmän todennuksen sijaan. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Aseta pääsykoodi @@ -7162,6 +7214,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.
This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. 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 0c9a87126e..2d838b54a4 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1222,6 +1222,10 @@ Des groupes plus performants No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Meilleures dates de messages. @@ -1242,6 +1246,10 @@ Notifications améliorées No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Sécurité accrue ✅ @@ -2815,6 +2823,10 @@ Il s'agit de votre propre lien unique ! Ne pas activer No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Ne plus afficher @@ -3561,11 +3573,19 @@ Il s'agit de votre propre lien unique ! Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne ! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Connexion plus rapide et messages plus fiables. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Favoris @@ -3864,6 +3884,10 @@ Erreur : %2$@ GIFs et stickers No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Bonjour ! @@ -3978,6 +4002,10 @@ Erreur : %2$@ Aide No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Caché @@ -4747,6 +4775,10 @@ Voici votre lien pour le groupe %@ ! Les membres du groupe peuvent envoyer des messages vocaux. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menus @@ -5205,6 +5237,10 @@ Voici votre lien pour le groupe %@ ! Pas de serveurs de médias et de fichiers. servers error + + No message + No comment provided by engineer. + No message servers. Pas de serveurs de messages. @@ -5553,6 +5589,10 @@ Nécessite l'activation d'un VPN. Ou à partager en privé No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Autres @@ -5816,6 +5856,10 @@ Erreur : %@ Noms de fichiers privés No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Routage privé des messages @@ -6725,6 +6769,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Envoi de notifications No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Envoyez vos questions et idées @@ -6964,6 +7012,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Il permet de remplacer l'authentification du système. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Définir le code d'accès @@ -7765,6 +7817,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Ce lien a été utilisé avec un autre appareil mobile, veuillez créer un nouveau lien sur le bureau. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Ce paramètre s'applique aux messages de votre profil de chat actuel **%@**. 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 14746bfb92..cbc05bda5e 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -44,7 +44,7 @@ ## In reply to - ## Válaszul erre: + ## Válaszul erre copied message info @@ -109,7 +109,7 @@ %@ is not verified - %@ nem hitelesített + %@ nincs hitelesítve No comment provided by engineer. @@ -239,7 +239,7 @@ %lld file(s) with total size of %@ - %lld fájl, amely(ek)nek teljes mérete: %@ + %lld fájl, %@ összméretben No comment provided by engineer. @@ -264,7 +264,7 @@ %lld messages marked deleted - %lld törlésre megjelölt üzenet + %lld üzenet megjelölve törlésre No comment provided by engineer. @@ -284,17 +284,17 @@ %lld seconds - %lld másodperc + %lld mp No comment provided by engineer. %lldd - %lldd + %lldn No comment provided by engineer. %lldh - %lldh + %lldó No comment provided by engineer. @@ -304,22 +304,22 @@ %lldm - %lldm + %lldp No comment provided by engineer. %lldmth - %lldmth + %lldh No comment provided by engineer. %llds - %llds + %lldmp No comment provided by engineer. %lldw - %lldw + %lldhét No comment provided by engineer. @@ -354,7 +354,7 @@ **Create 1-time link**: to create and share a new invitation link. - **Ismerős hozzáadása:** új meghívó-hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. + **Ismerős hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. No comment provided by engineer. @@ -379,7 +379,7 @@ **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Megjegyzés:** NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti. + **Megjegyzés:** NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti. No comment provided by engineer. @@ -436,7 +436,7 @@ - a bit better groups. - and more! - stabilabb üzenetkézbesítés. -- valamivel jobb csoportok. +- picit továbbfejlesztett csoportok. - és még sok más! No comment provided by engineer. @@ -454,7 +454,7 @@ - custom time to disappear. - editing history. - legfeljebb 5 perc hosszúságú hangüzenetek. -- egyedi üzenet-eltűnési időkorlát. +- egyéni üzenet-eltűnési időkorlát. - előzmények szerkesztése. No comment provided by engineer. @@ -508,12 +508,12 @@ 1-time link - Egyszer használható meghívó-hivatkozás + Egyszer használható meghívási hivatkozás No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. - Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható. + Az egyszer használható meghívási hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható. No comment provided by engineer. @@ -545,10 +545,12 @@ @%@ + @%@ No comment provided by engineer. @'%@' + @'%@' No comment provided by engineer. @@ -585,17 +587,17 @@ Abort changing address - Címváltoztatás megszakítása + Cím módosításának megszakítása No comment provided by engineer. Abort changing address? - Címváltoztatás megszakítása?? + Cím módosításának megszakítása? No comment provided by engineer. About SimpleX Chat - SimpleX Chat névjegye + A SimpleX Chat névjegye No comment provided by engineer. @@ -632,7 +634,7 @@ Accept incognito - Fogadás inkognitóban + Elfogadás inkognitóban accept contact request via notification swipe action @@ -653,6 +655,7 @@ Active + Aktív token status text @@ -747,12 +750,12 @@ Address change will be aborted. Old receiving address will be used. - A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva. + A cím módosítása meg fog szakadni. A régi fogadási cím lesz használva. No comment provided by engineer. Address or 1-time link? - Cím vagy egyszer használható meghívó-hivatkozás? + Cím vagy egyszer használható meghívási hivatkozás? No comment provided by engineer. @@ -767,7 +770,7 @@ Admins can create the links to join groups. - Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való kapcsolódáshoz. + Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. No comment provided by engineer. @@ -842,6 +845,7 @@ All reports will be archived for you. + Az összes jelentés archiválva lesz az Ön számára. No comment provided by engineer. @@ -891,12 +895,12 @@ Allow message reactions only if your contact allows them. - Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. No comment provided by engineer. Allow message reactions. - Üzenetreakciók engedélyezése. + A reakciók hozzáadása az üzenetekhez engedélyezve van. No comment provided by engineer. @@ -916,11 +920,12 @@ Allow to irreversibly delete sent messages. (24 hours) - Elküldött üzenetek végleges törlésének engedélyezése. (24 óra) + Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) No comment provided by engineer. Allow to report messsages to moderators. + Lehetővé teszi az üzenetek jelentését a moderátorok felé. No comment provided by engineer. @@ -930,12 +935,12 @@ Allow to send files and media. - Fájlok és médiatartalmak küldésének engedélyezése. + A fájlok- és a médiatartalmak küldése engedélyezve van. No comment provided by engineer. Allow to send voice messages. - Hangüzenetek küldésének engedélyezése. + A hangüzenetek küldése engedélyezve van. No comment provided by engineer. @@ -950,7 +955,7 @@ Allow your contacts adding message reactions. - Az üzenetreakciók küldése engedélyezve van az ismerősei számára. + A reakciók hozzáadása az üzenetekhez engedélyezve van az ismerősei számára. No comment provided by engineer. @@ -1000,7 +1005,7 @@ An empty chat profile with the provided name is created, and the app opens as usual. - Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik. + Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik. No comment provided by engineer. @@ -1090,10 +1095,12 @@ Archive %lld reports? + %lld jelentés archiválása? No comment provided by engineer. Archive all reports? + Az összes jelentés archiválása? No comment provided by engineer. @@ -1118,6 +1125,7 @@ Archive reports + Jelentések archiválása swipe action @@ -1152,7 +1160,7 @@ Audio/video calls - Hang-/videóhívások + Hang- és videóhívások chat feature @@ -1232,7 +1240,11 @@ Better groups - Javított csoportok + Továbbfejlesztett csoportok + No comment provided by engineer. + + + Better groups performance No comment provided by engineer. @@ -1242,7 +1254,7 @@ Better messages - Jobb üzenetek + Továbbfejlesztett üzenetek No comment provided by engineer. @@ -1255,6 +1267,10 @@ Továbbfejlesztett értesítések No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Továbbfejlesztett biztonság ✅ @@ -1277,7 +1293,7 @@ Block for all - Letiltás az összes tag számára + Letiltás No comment provided by engineer. @@ -1287,7 +1303,7 @@ Block member - Tag letiltása + Letiltás No comment provided by engineer. @@ -1302,7 +1318,7 @@ Blocked by admin - Az adminisztrátor letiltotta + Letiltva az adminisztrátor által No comment provided by engineer. @@ -1317,12 +1333,12 @@ Both you and your contact can add message reactions. - Mindkét fél is hozzáadhat üzenetreakciókat. + Mindkét fél hozzáadhat az üzenetekhez reakciókat. No comment provided by engineer. Both you and your contact can irreversibly delete sent messages. (24 hours) - Mindkét fél törölheti véglegesen az elküldött üzeneteket. (24 óra) + Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) No comment provided by engineer. @@ -1448,7 +1464,7 @@ Change - Megváltoztatás + Módosítás No comment provided by engineer. @@ -1458,52 +1474,52 @@ Change chat profiles - Csevegési profilok megváltoztatása + Csevegési profilok módosítása authentication reason Change database passphrase? - Az adatbázis jelmondatának megváltoztatása? + Az adatbázis jelmondatának módosítása? No comment provided by engineer. Change lock mode - Zárolási mód megváltoztatása + Zárolási mód módosítása authentication reason Change member role? - Tag szerepkörének megváltoztatása? + Tag szerepkörének módosítása? No comment provided by engineer. Change passcode - Jelkód megváltoztatása + Jelkód módosítása authentication reason Change receiving address - A fogadócím megváltoztatása + A fogadási cím módosítása No comment provided by engineer. Change receiving address? - Megváltoztatja a fogadócímet? + Módosítja a fogadási címet? No comment provided by engineer. Change role - Szerepkör megváltoztatása + Szerepkör módosítása No comment provided by engineer. Change self-destruct mode - Önmegsemmisítő-mód megváltoztatása + Önmegsemmisítő-mód módosítása authentication reason Change self-destruct passcode - Önmegsemmisítő-jelkód megváltoztatása + Önmegsemmisítő-jelkód módosítása authentication reason set passcode view @@ -1579,12 +1595,12 @@ Chat preferences - Csevegési beállítások + Csevegés-beállítások No comment provided by engineer. Chat preferences were changed. - A csevegési beállítások megváltoztak. + A csevegés-beállítások módosultak. alert message @@ -1679,6 +1695,7 @@ Clear group? + Csoport kiürítése? No comment provided by engineer. @@ -1713,7 +1730,7 @@ Compare file - Fájl összehasonlítás + Fájl-összehasonlítás server test step @@ -1728,7 +1745,7 @@ Conditions accepted on: %@. - Feltételek elfogadva ekkor: %@. + Feltételek elfogadva: %@. No comment provided by engineer. @@ -1748,7 +1765,7 @@ Conditions will be accepted for enabled operators after 30 days. - A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltető számára. + A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. No comment provided by engineer. @@ -1828,6 +1845,7 @@ Confirmed + Megerősítve token status text @@ -1871,7 +1889,7 @@ Ez az Ön SimpleX-címe! Connect to yourself? This is your own one-time link! Kapcsolódás saját magához? -Ez az Ön egyszer használható meghívó-hivatkozása! +Ez az Ön egyszer használható meghívási hivatkozása! No comment provided by engineer. @@ -1886,7 +1904,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Connect via one-time link - Kapcsolódás egyszer használható meghívó-hivatkozáson keresztül + Kapcsolódás egyszer használható meghívási hivatkozáson keresztül No comment provided by engineer. @@ -2033,7 +2051,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Contact hidden: - Ismerős elrejtve: + Rejtett név: notification @@ -2098,7 +2116,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Core version: v%@ - Alapverziószám: v%@ + Fő verzió: v%@ No comment provided by engineer. @@ -2118,7 +2136,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Create 1-time link - Egyszer használható meghívó-hivatkozás létrehozása + Egyszer használható meghívási hivatkozás létrehozása No comment provided by engineer. @@ -2128,7 +2146,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Create a group using a random profile. - Csoport létrehozása véletlenszerűen létrehozott profillal. + Csoport létrehozása véletlenszerű profillal. No comment provided by engineer. @@ -2168,7 +2186,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Create queue - Sorbaállítás létrehozása + Sorba állítás létrehozása server test step @@ -2188,12 +2206,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Created at - Létrehozva ekkor: + Létrehozva No comment provided by engineer. Created at: %@ - Létrehozva ekkor: %@ + Létrehozva: %@ copied message info @@ -2228,17 +2246,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Currently maximum supported file size is %@. - Jelenleg a maximális támogatott fájlméret %@. + Jelenleg támogatott legnagyobb fájl méret: %@. No comment provided by engineer. Custom time - Személyreszabott idő + Egyéni időköz No comment provided by engineer. Customizable message shape. - Testreszabható üzenetbuborékok. + Testre szabható üzenetbuborékok. No comment provided by engineer. @@ -2291,7 +2309,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database encryption passphrase will be updated. - Az datbázis titkosítási jelmondata frissülni fog. + Az adatbázis titkosítási jelmondata frissítve lesz. No comment provided by engineer. @@ -2302,12 +2320,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database is encrypted using a random passphrase, you can change it. - Az adatbázis egy véletlenszerű jelmondattal van titkosítva, ami megváltoztatható. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet szabadon módosíthat. No comment provided by engineer. Database is encrypted using a random passphrase. Please change it before exporting. - Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt módosítsa. No comment provided by engineer. @@ -2377,12 +2395,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete %lld messages of members? - Tagok %lld üzenetének törlése? + Törli a tagok %lld üzenetét? No comment provided by engineer. Delete %lld messages? - Töröl %lld üzenetet? + %lld üzenet törlése? No comment provided by engineer. @@ -2397,7 +2415,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete after - Törlés ennyi idő után: + Törlés ennyi idő után No comment provided by engineer. @@ -2467,7 +2485,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete files and media? - Fájlok és a médiatartalmak törlése? + A fájlok- és a médiatartalmak törlése? No comment provided by engineer. @@ -2502,7 +2520,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete link - Hivatkozás törlése + Törlés No comment provided by engineer. @@ -2532,7 +2550,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete messages after - Üzenetek törlése ennyi idő után: + Üzenetek törlése ennyi idő után No comment provided by engineer. @@ -2562,7 +2580,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete queue - Sorbaállítás törlése + Sorba állítás törlése server test step @@ -2592,12 +2610,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Deleted at - Törölve ekkor: + Törölve No comment provided by engineer. Deleted at: %@ - Törölve ekkor: %@ + Törölve: %@ copied message info @@ -2617,7 +2635,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delivery receipts are disabled! - A kézbesítési jelentések ki vannak kapcsolva! + A kézbesítési jelentések le vannak tiltva! No comment provided by engineer. @@ -2637,7 +2655,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Desktop app version %@ is not compatible with this app. - A számítógép-alkalmazás verziója %@ nem kompatibilis ezzel az alkalmazással. + A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. No comment provided by engineer. @@ -2652,7 +2670,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Destination server error: %@ - Célkiszolgáló hiba: %@ + Célkiszolgáló-hiba: %@ snd error text @@ -2717,7 +2735,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Direct messages between members are prohibited. - A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. + A tagok közötti közvetlen üzenetek le vannak tiltva. No comment provided by engineer. @@ -2742,7 +2760,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Disable for all - Letiltás az összes tag számára + Letiltás No comment provided by engineer. @@ -2767,17 +2785,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Disappearing messages are prohibited. - Az eltűnő üzenetek küldése le van tiltva ebben a csoportban. + Az eltűnő üzenetek küldése le van tiltva. No comment provided by engineer. Disappears at - Eltűnik ekkor: + Eltűnik No comment provided by engineer. Disappears at: %@ - Eltűnik ekkor: %@ + Eltűnik: %@ copied message info @@ -2792,7 +2810,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Discover and join groups - Helyi csoportok felfedezése és csatlakozás + Csoportok felfedezése és csatlakozás No comment provided by engineer. @@ -2807,7 +2825,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do NOT use SimpleX for emergency calls. - NE használja a SimpleX-et segélyhívásokhoz. + NE használja a SimpleXet segélyhívásokhoz. No comment provided by engineer. @@ -2845,6 +2863,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Ne engedélyezze No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Ne mutasd újra @@ -3013,7 +3035,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enabled for - Számukra engedélyezve: + Számukra engedélyezve No comment provided by engineer. @@ -3078,12 +3100,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encryption re-negotiation error - Titkosítás-újraegyeztetési hiba + Hiba a titkosítás újraegyeztetésekor message decrypt error item Encryption re-negotiation failed. - Sikertelen titkosítás-újraegyeztetés. + Nem sikerült a titkosítást újraegyeztetni. No comment provided by engineer. @@ -3153,7 +3175,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error aborting address change - Hiba a cím megváltoztatásának megszakításakor + Hiba a cím módosításának megszakításakor No comment provided by engineer. @@ -3178,7 +3200,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error changing address - Hiba a cím megváltoztatásakor + Hiba a cím módosításakor No comment provided by engineer. @@ -3188,12 +3210,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error changing role - Hiba a szerepkör megváltoztatásakor + Hiba a szerepkör módosításakor No comment provided by engineer. Error changing setting - Hiba a beállítás megváltoztatásakor + Hiba a beállítás módosításakor No comment provided by engineer. @@ -3203,6 +3225,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error checking token status + Hiba a token állapotának ellenőrzésekor No comment provided by engineer. @@ -3337,7 +3360,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error migrating settings - Hiba a beallítások átköltöztetésekor + Hiba a beállítások átköltöztetésekor No comment provided by engineer. @@ -3362,6 +3385,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error registering for notifications + Hiba az értesítések regisztrálásakor alert title @@ -3416,7 +3440,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error saving user password - Hiba a felhasználói jelszó mentésekor + Hiba a felhasználó jelszavának mentésekor No comment provided by engineer. @@ -3461,7 +3485,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error switching profile! - Hiba a profilváltásakor! + Hiba a profilváltáskor! alertTitle @@ -3471,6 +3495,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Error testing server connection + Hiba a kiszolgáló kapcsolatának tesztelésekor No comment provided by engineer. @@ -3555,6 +3580,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Expired + Lejárt token status text @@ -3597,11 +3623,19 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Gyors és nem kell várni, amíg a feladó online lesz! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Kedvenc @@ -3638,17 +3672,17 @@ Ez az Ön egyszer használható meghívó-hivatkozása! File server error: %@ - Fájlkiszolgáló hiba: %@ + Fájlkiszolgáló-hiba: %@ file error text File status - Fájlállapot + Fájl állapota No comment provided by engineer. File status: %@ - Fájlállapot: %@ + Fájl állapota: %@ copied message info @@ -3688,7 +3722,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Files and media are prohibited. - A fájlok- és a médiatartalmak le vannak tiltva ebben a csoportban. + A fájlok- és a médiatartalmak küldése le van tiltva. No comment provided by engineer. @@ -3758,6 +3792,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! For all moderators + Az összes moderátor számára No comment provided by engineer. @@ -3777,6 +3812,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! For me + Csak magamnak No comment provided by engineer. @@ -3826,7 +3862,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Forwarded from - Továbbítva innen: + Továbbítva innen No comment provided by engineer. @@ -3853,7 +3889,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Forwarding server: %1$@ Destination server error: %2$@ Továbbító-kiszolgáló: %1$@ -Célkiszolgáló hiba:%2$@ +Célkiszolgáló-hiba: %2$@ snd error text @@ -3903,6 +3939,10 @@ Hiba: %2$@ GIF-ek és matricák No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Jó napot! @@ -3975,7 +4015,7 @@ Hiba: %2$@ Group moderation - Csoport moderáció + Csoport moderálása No comment provided by engineer. @@ -4018,6 +4058,10 @@ Hiba: %2$@ Súgó No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Se név, se üzenet @@ -4090,12 +4134,12 @@ Hiba: %2$@ How to use it - Hogyan használja + Használati útmutató No comment provided by engineer. How to use your servers - Saját kiszolgálók használata + Hogyan használja a saját kiszolgálóit No comment provided by engineer. @@ -4155,7 +4199,7 @@ Hiba: %2$@ Immune to spam - Spam és visszaélések elleni védelem + Védett a kéretlen tartalommal szemben No comment provided by engineer. @@ -4217,7 +4261,7 @@ További fejlesztések hamarosan! In reply to - Válasz neki + Válaszul erre No comment provided by engineer. @@ -4307,7 +4351,7 @@ További fejlesztések hamarosan! Instant - Azonnal + Azonnali No comment provided by engineer. @@ -4329,22 +4373,27 @@ További fejlesztések hamarosan! Invalid + Érvénytelen token status text Invalid (bad token) + Érvénytelen (hibás token) token status text Invalid (expired) + Érvénytelen (lejárt) token status text Invalid (unregistered) + Érvénytelen (nincs regisztrálva) token status text Invalid (wrong topic) + Érvénytelen (rossz topic) token status text @@ -4429,7 +4478,7 @@ További fejlesztések hamarosan! Irreversible message deletion is prohibited. - Az üzenetek végleges törlése le van tiltva ebben a csoportban. + Az üzenetek végleges törlése le van tiltva. No comment provided by engineer. @@ -4449,7 +4498,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. Az üzenet visszafejtése sikertelen volt, mert vagy az ismerőse régebbi adatbázis biztonsági mentést használt. +2. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy az ismerőse régebbi adatbázis biztonsági mentést használt. 3. A kapcsolat sérült. No comment provided by engineer. @@ -4517,7 +4566,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Keep - Megtart + Megtartás alert action @@ -4627,7 +4676,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! List name and emoji should be different for all lists. - Az összes lista nevének és emodzsiának különbözőnek kell lennie. + Az összes lista nevének és emodzsijának különbözőnek kell lennie. No comment provided by engineer. @@ -4677,7 +4726,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. - Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. + Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. No comment provided by engineer. @@ -4707,7 +4756,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Media & file servers - Média és fájlkiszolgálók + Média- és fájlkiszolgálók No comment provided by engineer. @@ -4727,21 +4776,22 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Member reports + Tagok jelentései chat feature Member role will be changed to "%@". All chat members will be notified. - A tag szerepeköre meg fog változni a következőre: "%@". A csevegés tagjai értesítést fognak kapni. + A tag szerepköre a következőre fog módosulni: „%@”. A csevegés összes tagja értesítést fog kapni. No comment provided by engineer. Member role will be changed to "%@". All group members will be notified. - A tag szerepköre meg fog változni a következőre: „%@”. A csoportban az összes tag értesítve lesz. + A tag szerepköre a következőre fog módosulni: „%@”. A csoport az összes tagja értesítést fog kapni. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. - A tag szerepköre meg fog változni a következőre: „%@”. A tag új meghívást fog kapni. + A tag szerepköre a következőre fog módosulni: „%@”. A tag új meghívást fog kapni. No comment provided by engineer. @@ -4756,41 +4806,46 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Members can add message reactions. - Csoporttagok üzenetreakciókat adhatnak hozzá. + A tagok reakciókat adhatnak hozzá az üzenetekhez. No comment provided by engineer. Members can irreversibly delete sent messages. (24 hours) - A csoport tagjai véglegesen törölhetik az elküldött üzeneteiket. (24 óra) + A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) No comment provided by engineer. Members can report messsages to moderators. + A tagok jelenthetik az üzeneteket a moderátorok felé. No comment provided by engineer. Members can send SimpleX links. - A csoport tagjai küldhetnek SimpleX-hivatkozásokat. + A tagok küldhetnek SimpleX-hivatkozásokat. No comment provided by engineer. Members can send direct messages. - A csoport tagjai küldhetnek egymásnak közvetlen üzeneteket. + A tagok küldhetnek egymásnak közvetlen üzeneteket. No comment provided by engineer. Members can send disappearing messages. - A csoport tagjai küldhetnek eltűnő üzeneteket. + A tagok küldhetnek eltűnő üzeneteket. No comment provided by engineer. Members can send files and media. - A csoport tagjai küldhetnek fájlokat és médiatartalmakat. + A tagok küldhetnek fájlokat és médiatartalmakat. No comment provided by engineer. Members can send voice messages. - A csoport tagjai küldhetnek hangüzeneteket. + A tagok küldhetnek hangüzeneteket. + No comment provided by engineer. + + + Mention members 👋 No comment provided by engineer. @@ -4830,7 +4885,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message queue info - Üzenet-sorbaállítási információ + Üzenetsorbaállítási információ No comment provided by engineer. @@ -4840,12 +4895,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message reactions are prohibited in this chat. - Az üzenetreakciók küldése le van tiltva ebben a csevegésben. + A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben. No comment provided by engineer. Message reactions are prohibited. - Az üzenetreakciók küldése le van tiltva ebben a csoportban. + A reakciók hozzáadása az üzenetekhez le van tiltva. No comment provided by engineer. @@ -4860,7 +4915,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message shape - Üzenetbuborék formája + Üzenetbuborék alakja No comment provided by engineer. @@ -4870,12 +4925,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Message status - Üzenetállapot + Üzenet állapota No comment provided by engineer. Message status: %@ - Üzenetállapot: %@ + Üzenet állapota: %@ copied message info @@ -4900,7 +4955,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages from %@ will be shown! - A(z) %@ által írt üzenetek megjelennek! + Az összes %@ által írt üzenet meg fog jelenni! No comment provided by engineer. @@ -5000,12 +5055,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Moderated at - Moderálva ekkor: + Moderálva No comment provided by engineer. Moderated at: %@ - Moderálva ekkor: %@ + Moderálva: %@ copied message info @@ -5045,6 +5100,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Mute all + Összes némítása notification label action @@ -5099,6 +5155,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! New + Új token status text @@ -5118,7 +5175,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! New chat - Új beszélgetés + Új csevegés No comment provided by engineer. @@ -5223,7 +5280,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No device token! - Nincs kiszüléktoken! + Nincs készüléktoken! No comment provided by engineer. @@ -5256,6 +5313,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincsenek média- és fájlkiszolgálók. servers error + + No message + No comment provided by engineer. + No message servers. Nincsenek üzenet-kiszolgálók. @@ -5298,12 +5359,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No servers to receive files. - Nincsenek fájlfogadó-kiszolgálók. + Nincsenek fájlfogadási kiszolgálók. servers error No servers to receive messages. - Nincsenek üzenetfogadó-kiszolgálók. + Nincsenek üzenetfogadási kiszolgálók. servers error @@ -5313,6 +5374,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No token! + Nincs token! alert title @@ -5357,6 +5419,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Notifications error + Értesítési hiba alert title @@ -5366,6 +5429,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Notifications status + Értesítések állapota alert title @@ -5399,7 +5463,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! One-time invitation link - Egyszer használható meghívó-hivatkozás + Egyszer használható meghívási hivatkozás No comment provided by engineer. @@ -5418,12 +5482,12 @@ VPN engedélyezése szükséges. Onion hosts will not be used. - Onion-kiszolgálók nem lesznek használva. + Az onion-kiszolgálók nem lesznek használva. No comment provided by engineer. Only chat owners can change preferences. - Csak a csevegés tulajdonosai változtathatják meg a beállításokat. + Csak a csevegés tulajdonosai módosíthatják a csevegés-beállításokat. No comment provided by engineer. @@ -5438,17 +5502,17 @@ VPN engedélyezése szükséges. Only group owners can change group preferences. - Csak a csoport-tulajdonosok változtathatják meg a csoport-beállításokat. + Csak a csoport tulajdonosai módosíthatják a csoport-beállításokat. No comment provided by engineer. Only group owners can enable files and media. - Csak a csoport-tulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. + Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését. No comment provided by engineer. Only group owners can enable voice messages. - Csak a csoport-tulajdonosok engedélyezhetik a hangüzenetek küldését. + Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését. No comment provided by engineer. @@ -5463,7 +5527,7 @@ VPN engedélyezése szükséges. Only you can add message reactions. - Csak Ön adhat hozzá üzenetreakciókat. + Csak Ön adhat hozzá reakciókat az üzenetekhez. No comment provided by engineer. @@ -5488,7 +5552,7 @@ VPN engedélyezése szükséges. Only your contact can add message reactions. - Csak az ismerőse tud üzenetreakciókat küldeni. + Csak az ismerőse adhat hozzá reakciókat az üzenetekhez. No comment provided by engineer. @@ -5523,7 +5587,7 @@ VPN engedélyezése szükséges. Open changes - Változások megnyitása + Módosítások megtekintése No comment provided by engineer. @@ -5578,7 +5642,7 @@ VPN engedélyezése szükséges. Operator server - Kiszolgáló üzemeltető + Kiszolgáló-üzemeltető alert title @@ -5611,6 +5675,10 @@ VPN engedélyezése szükséges. Vagy a privát megosztáshoz No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other További @@ -5640,7 +5708,7 @@ VPN engedélyezése szükséges. Passcode changed! - A jelkód megváltozott! + A jelkód módosult! No comment provided by engineer. @@ -5650,7 +5718,7 @@ VPN engedélyezése szükséges. Passcode not changed! - A jelkód nem változott meg! + A jelkód nem módosult! No comment provided by engineer. @@ -5665,7 +5733,7 @@ VPN engedélyezése szükséges. Password to show - Jelszó megjelenítése + Jelszó a megjelenítéshez No comment provided by engineer. @@ -5700,7 +5768,7 @@ VPN engedélyezése szükséges. Periodic - Rendszeresen + Időszakos No comment provided by engineer. @@ -5799,19 +5867,22 @@ Hiba: %@ Please store passphrase securely, you will NOT be able to change it if you lose it. - Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni. + Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja módosítani. No comment provided by engineer. Please try to disable and re-enable notfications. + Próbálja meg letiltani és újra engedélyezni az értesítéseket. token info Please wait for token activation to complete. + Várjon, amíg a token aktiválása befejeződik. token info Please wait for token to be registered. + Várjon a token regisztrálására. token info @@ -5836,7 +5907,7 @@ Hiba: %@ Preset server address - Előre beállított kiszolgáló címe + Az előre beállított kiszolgáló címe No comment provided by engineer. @@ -5874,6 +5945,10 @@ Hiba: %@ Privát fájlnevek No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Privát üzenet-útválasztás @@ -5896,7 +5971,7 @@ Hiba: %@ Private routing error - Privát útválasztáshiba + Privát útválasztási hiba No comment provided by engineer. @@ -5936,21 +6011,22 @@ Hiba: %@ Prohibit irreversible message deletion. - Az üzenetek véglegesen való törlése le van tiltva. + Az elküldött üzenetek végleges törlése le van tiltva. No comment provided by engineer. Prohibit message reactions. - Az üzenetreakciók küldése le van tiltva. + A reakciók hozzáadása az üzenethez le van tiltva. No comment provided by engineer. Prohibit messages reactions. - Az üzenetreakciók tiltása. + A reakciók hozzáadása az üzenetekhez le van tiltva. No comment provided by engineer. Prohibit reporting messages to moderators. + Az üzenetek a moderátorok felé történő jelentésének megtiltása. No comment provided by engineer. @@ -5970,7 +6046,7 @@ Hiba: %@ Prohibit sending files and media. - Fájlok- és a médiatartalmak küldésének letiltása. + A fájlok- és a médiatartalmak küldése le van tiltva. No comment provided by engineer. @@ -5992,7 +6068,7 @@ Hiba: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben. -Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. +Engedélyezze a *Hálózat és kiszolgálók* menüben. No comment provided by engineer. @@ -6097,12 +6173,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Received at - Fogadva ekkor: + Fogadva No comment provided by engineer. Received at: %@ - Fogadva ekkor: %@ + Fogadva: %@ copied message info @@ -6132,7 +6208,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Receiving address will be changed to a different server. Address change will complete after sender comes online. - A fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be. + 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. No comment provided by engineer. @@ -6142,7 +6218,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Receiving via - Fogadás a + Fogadás a következőn keresztül: No comment provided by engineer. @@ -6197,12 +6273,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Record updated at - A bejegyzés frissítve + Bejegyzés frissítve No comment provided by engineer. Record updated at: %@ - A bejegyzés frissítve: %@ + Bejegyzés frissítve: %@ copied message info @@ -6212,14 +6288,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Register + Regisztrálás No comment provided by engineer. Register notification token? + Értesítési token regisztrálása? token info Registered + Regisztrálva token status text @@ -6280,7 +6359,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Renegotiate - Újraegyzetetés + Újraegyeztetés No comment provided by engineer. @@ -6360,10 +6439,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Report: (text.isEmpty ? reason.text : text) + Jelentés: (text.isEmpty ? reason.text : text) report in notification Reporting messages to moderators is prohibited. + Az üzenetek jelentése a moderátorok felé le van tiltva. No comment provided by engineer. @@ -6604,7 +6685,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Saved from - Elmentve innen: + Elmentve innen No comment provided by engineer. @@ -6659,7 +6740,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Search bar accepts invitation links. - A keresősáv elfogadja a meghívó-hivatkozásokat. + A keresősáv elfogadja a meghívási hivatkozásokat. No comment provided by engineer. @@ -6674,7 +6755,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Secure queue - Biztonságos sorbaállítás + Biztonságos sorba állítás server test step @@ -6709,7 +6790,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegés-beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -6724,7 +6805,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Self-destruct passcode changed! - Az önmegsemmisítő-jelkód megváltozott! + Az önmegsemmisítő-jelkód módosult! No comment provided by engineer. @@ -6764,7 +6845,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send link previews - Hivatkozás előnézetek küldése + Hivatkozás előnézete No comment provided by engineer. @@ -6792,9 +6873,13 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Értesítések küldése No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas - Ötletek és kérdések beküldése + Ötletek és javaslatok No comment provided by engineer. @@ -6864,12 +6949,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sent at - Elküldve ekkor: + Elküldve No comment provided by engineer. Sent at: %@ - Elküldve ekkor: %@ + Elküldve: %@ copied message info @@ -6939,7 +7024,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Server operator changed. - A kiszolgáló üzemeltetője megváltozott. + A kiszolgáló üzemeltetője módosult. alert title @@ -6949,17 +7034,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Server protocol changed. - A kiszolgáló-protokoll megváltozott. + A kiszolgáló-protokoll módosult. alert title Server requires authorization to create queues, check password - A kiszolgálónak engedélyre van szüksége a sorbaállítás létrehozásához, ellenőrizze jelszavát + A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát server test error Server requires authorization to upload, check password - A kiszolgálónak engedélyre van szüksége a várólisták feltöltéséhez, ellenőrizze jelszavát + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát server test error @@ -7029,7 +7114,11 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Set it instead of system authentication. - Rendszerhitelesítés helyetti beállítás. + Beállítás a rendszer-hitelesítés helyett. + No comment provided by engineer. + + + Set message expiration in chats. No comment provided by engineer. @@ -7049,7 +7138,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Set the message shown to new members! - Megjelenő üzenet beállítása az új tagok számára! + Megjelenítendő üzenet beállítása az új tagok számára! No comment provided by engineer. @@ -7064,7 +7153,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Settings were changed. - A beállítások megváltoztak. + A beállítások módosultak. alert message @@ -7080,12 +7169,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Share 1-time link - Egyszer használható hivatkozás megosztása + Egyszer használható meghívási hivatkozás megosztása No comment provided by engineer. Share 1-time link with a friend - Egyszer használható meghívó-hivatkozás megosztása egy baráttal + Egyszer használható meghívási hivatkozás megosztása egy baráttal No comment provided by engineer. @@ -7115,7 +7204,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Share link - Hivatkozás megosztása + Megosztás No comment provided by engineer. @@ -7125,12 +7214,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Share this 1-time invite link - Egyszer használható meghívó-hivatkozás megosztása + Ennek az egyszer használható meghívási hivatkozásnak a megosztása No comment provided by engineer. Share to SimpleX - Megosztás a SimpleX-ben + Megosztás a SimpleXben No comment provided by engineer. @@ -7160,7 +7249,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Show message status - Üzenetállapot megjelenítése + Üzenet állapotának megjelenítése No comment provided by engineer. @@ -7230,12 +7319,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SimpleX address and 1-time links are safe to share via any messenger. - A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. + A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. No comment provided by engineer. SimpleX address or 1-time link? - SimpleX-cím vagy egyszer használható meghívó-hivatkozás? + SimpleX-cím vagy egyszer használható meghívási hivatkozás? No comment provided by engineer. @@ -7260,7 +7349,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SimpleX links are prohibited. - A SimpleX-hivatkozások küldése le van tiltva ebben a csoportban. + A SimpleX-hivatkozások küldése le van tiltva. No comment provided by engineer. @@ -7270,7 +7359,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SimpleX one-time invitation - Egyszer használható SimpleX-meghívó-hivatkozás + Egyszer használható SimpleX-meghívási hivatkozás simplex link type @@ -7368,7 +7457,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Starting from %@. - Kezdve ettől %@. + Statisztikagyűjtés kezdete: %@. No comment provided by engineer. @@ -7473,7 +7562,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Switch chat profile for 1-time invitations. - Csevegési profilváltás az egyszer használható meghívó-hivatkozásokhoz. + Csevegési profilváltás az egyszer használható meghívási hivatkozásokhoz. No comment provided by engineer. @@ -7483,7 +7572,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. System authentication - Rendszerhitelesítés + Rendszer-hitelesítés No comment provided by engineer. @@ -7498,6 +7587,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. TCP port for messaging + TCP-port az üzenetváltáshoz No comment provided by engineer. @@ -7552,7 +7642,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Tap to join incognito - Koppintson ide az inkognitóban való csatlakozáshoz + Koppintson ide az inkognitóban való kapcsolódáshoz No comment provided by engineer. @@ -7562,21 +7652,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Tap to scan - Koppintson ide a QR-kód beolvasáshoz + Koppintson ide a QR-kód beolvasásához No comment provided by engineer. Temporary file error - Ideiglenesfájl-hiba + Ideiglenes fájlhiba file error alert title Test failed at step %@. - A teszt sikertelen volt a(z) %@ lépésnél. + A teszt a(z) %@ lépésnél sikertelen volt. server test failure Test notifications + Értesítések tesztelése No comment provided by engineer. @@ -7623,7 +7714,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The app protects your privacy by using different operators in each conversation. - Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőket használ. + Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. No comment provided by engineer. @@ -7633,7 +7724,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The attempt to change database passphrase was not completed. - Az adatbázis jelmondatának megváltoztatására tett kísérlet nem fejeződött be. + Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. No comment provided by engineer. @@ -7653,7 +7744,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The contact you shared this link with will NOT be able to connect! - Ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! + Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! No comment provided by engineer. @@ -7708,12 +7799,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The same conditions will apply to operator **%@**. - Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**. + Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**. No comment provided by engineer. The same conditions will apply to operator(s): **%@**. - Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k)re is: **%@**. + Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k) számára is: **%@**. No comment provided by engineer. @@ -7798,7 +7889,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This chat is protected by quantum resistant end-to-end encryption. - Ez a csevegés végpontok közötti kvantumálló tikosítással védett. + Ez a csevegés végpontok közötti kvantumálló titkosítással védett. E2EE info chat item @@ -7828,7 +7919,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This is your own one-time link! - Ez az Ön egyszer használható meghívó-hivatkozása! + Ez az Ön egyszer használható meghívási hivatkozása! No comment provided by engineer. @@ -7836,6 +7927,10 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegő-profiljában lévő üzenetekre vonatkozik. @@ -7890,7 +7985,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Az adatvédelem érdekében (a más csevegési platformokon megszokott felhasználó-azonosítók helyett) a SimpleX csak az üzenetek sorbaállításához használ azonosítókat, az összes ismerőséhez különbözőt. + Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. No comment provided by engineer. @@ -7915,7 +8010,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Rejtett profilja megjelenítéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben. + Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben. No comment provided by engineer. @@ -7950,6 +8045,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Token status: %@. + Token állapota: %@. token status @@ -7964,7 +8060,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Transport isolation - Átvitel-izoláció módja + Átvitel-izoláció No comment provided by engineer. @@ -8009,7 +8105,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Unblock for all - Letiltás feloldása az összes tag számára + Feloldás No comment provided by engineer. @@ -8101,7 +8197,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Unlink desktop? - Számítógép szétkapcsolása? + Számítógép leválasztása? No comment provided by engineer. @@ -8136,12 +8232,12 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Update database passphrase - Az adatbázis jelmondatának megváltoztatása + Az adatbázis jelmondatának módosítása No comment provided by engineer. Update network settings? - Hálózati beállítások megváltoztatása? + Hálózati beállítások módosítása? No comment provided by engineer. @@ -8211,11 +8307,12 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Use TCP port %@ when no port is specified. + A következő TCP-port használata, amikor nincs port megadva: %@. No comment provided by engineer. Use chat - Simplex Chat használata + SimpleX Chat használata No comment provided by engineer. @@ -8290,6 +8387,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Use web port + Webport használata No comment provided by engineer. @@ -8404,7 +8502,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Voice messages are prohibited. - A hangüzenetek küldése le van tiltva ebben a csoportban. + A hangüzenetek küldése le van tiltva. No comment provided by engineer. @@ -8414,7 +8512,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Voice messages prohibited! - A hangüzenetek le vannak tilva! + A hangüzenetek le vannak tiltva! No comment provided by engineer. @@ -8454,7 +8552,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Warning: starting chat on multiple devices is not supported and will cause message delivery failures - Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, továbbá üzenetkézbesítési hibákat okozhat + Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, mert üzenetkézbesítési hibákat okoz No comment provided by engineer. @@ -8469,7 +8567,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Welcome %@! - Üdvözöllek %@! + Üdvözöljük %@! No comment provided by engineer. @@ -8499,7 +8597,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc When more than one operator is enabled, none of them has metadata to learn who communicates with whom. - Amikor egynél több hálózati üzemeltető van engedélyezve, egyikük sem rendelkezik olyan metaadatokkal ahhoz, hogy felderítse, ki kommunikál kivel. + Amikor egynél több üzemeltető van engedélyezve, akkor egyik sem rendelkezik olyan metaadatokkal, amelyekből megtudható, hogy ki kivel kommunikál. No comment provided by engineer. @@ -8599,7 +8697,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already connected to %@. - Ön már kapcsolódva van a következőhöz: %@. + Ön már kapcsolódott a következőhöz: %@. No comment provided by engineer. @@ -8609,12 +8707,12 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already connecting to %@. - Már folyamatban van a kapcsolódás a következőhöz: %@. + A kapcsolódás már folyamatban van a következőhöz: %@. No comment provided by engineer. You are already connecting via this one-time link! - A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül! + A kapcsolódás már folyamatban van ezen az egyszer használható meghívási hivatkozáson keresztül! No comment provided by engineer. @@ -8640,7 +8738,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already joining the group! Repeat join request? - Csatlakozás folyamatban! + A csatlakozás már folyamatban van a csoporthoz! Csatlakozáskérés megismétlése? No comment provided by engineer. @@ -8651,7 +8749,7 @@ Csatlakozáskérés megismétlése? You are invited to group - Meghívást kapott a csoportba + Ön meghívást kapott a csoportba No comment provided by engineer. @@ -8666,12 +8764,12 @@ Csatlakozáskérés megismétlése? You can change it in Appearance settings. - Ezt a „Megjelenés” menüben változtathatja meg. + Ezt a „Megjelenés” menüben módosíthatja. No comment provided by engineer. You can configure operators in Network & servers settings. - Az üzemeltetőket a „Hálózat és kiszolgálók” beállításaban konfigurálhatja. + Az üzemeltetőket a „Hálózat és kiszolgálók” menüben konfigurálhatja. No comment provided by engineer. @@ -8691,7 +8789,7 @@ Csatlakozáskérés megismétlése? You can enable them later via app Privacy & Security settings. - Később engedélyezheti őket az alkalmazás „Adatvédelem és biztonság” menüjében. + Később engedélyezheti őket az „Adatvédelem és biztonság” menüben. No comment provided by engineer. @@ -8736,7 +8834,7 @@ Csatlakozáskérés megismétlése? You can share this address with your contacts to let them connect with **%@**. - Megoszthatja ezt a címet az ismerőseivel, hogy kapcsolatba léphessenek Önnel a(z) **%@** nevű profilján keresztül. + Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: **%@**. No comment provided by engineer. @@ -8761,7 +8859,7 @@ Csatlakozáskérés megismétlése? You can view invitation link again in connection details. - A meghívó-hivatkozást újra megtekintheti a kapcsolat részleteinél. + A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél. alert message @@ -8798,7 +8896,7 @@ Kapcsolatkérés megismétlése? You invited a contact - Meghívta egy ismerősét + Ön meghívta egy ismerősét No comment provided by engineer. @@ -8848,6 +8946,7 @@ Kapcsolatkérés megismétlése? You should receive notifications. + Ön megkapja az értesítéseket. token info @@ -8872,7 +8971,7 @@ Kapcsolatkérés megismétlése? You will be required to authenticate when you start or resume the app after 30 seconds in background. - Az alkalmazás indításakor, vagy 30 másodpercnyi háttérben töltött idő után az alkalmazáshoz visszatérve hitelesítés szükséges. + Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. No comment provided by engineer. @@ -8942,7 +9041,7 @@ Kapcsolatkérés megismétlése? Your chat preferences - Csevegési beállítások + Az Ön csevegés-beállításai alert title @@ -8957,7 +9056,7 @@ Kapcsolatkérés megismétlése? Your contact sent a file that is larger than currently supported maximum size (%@). - Az ismerőse olyan fájlt küldött, amely meghaladja a jelenleg támogatott maximális méretet (%@). + Az ismerőse a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött. No comment provided by engineer. @@ -9012,7 +9111,7 @@ Kapcsolatkérés megismétlése? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. - A profilja megváltozott. Ha elmenti, a profilfrissítés el lesz küldve az Ön ismerősei számára. + A profilja módosult. Ha elmenti, a profilfrissítés el lesz küldve az Ön ismerősei számára. alert message @@ -9072,7 +9171,7 @@ Kapcsolatkérés megismétlése? accepted call - elfogadott hívás + fogadott hívás call status @@ -9152,7 +9251,7 @@ Kapcsolatkérés megismétlése? blocked %@ - letiltotta %@-t + letiltotta őt: %@ rcv group event chat item @@ -9192,32 +9291,32 @@ Kapcsolatkérés megismétlése? changed address for you - megváltoztatta a címet az Ön számára + módosította a címet az Ön számára chat item text changed role of %1$@ to %2$@ - %1$@ szerepkörét megváltoztatta a következőre: %2$@ + a következőre módosította %1$@ szerepkörét: „%2$@” rcv group event chat item changed your role to %@ - megváltoztatta az Ön szerepkörét a következőre: %@ + a következőre módosította az Ön szerepkörét: „%@” rcv group event chat item changing address for %@… - cím megváltoztatása %@ számára… + cím módosítása %@ számára… chat item text changing address… - cím megváltoztatása… + cím módosítása… chat item text colored - színes + színezett No comment provided by engineer. @@ -9262,7 +9361,7 @@ Kapcsolatkérés megismétlése? connecting (introduction invitation) - kapcsolódás (bemutatkozó-meghívó) + kapcsolódás (bemutatkozó meghívó) No comment provided by engineer. @@ -9287,17 +9386,17 @@ Kapcsolatkérés megismétlése? contact %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét a következőre: %2$@ + %1$@ a következőre módosította a nevét: %2$@ profile update event chat item contact has e2e encryption - az ismerősnél az e2e titkosítás elérhető + az ismerős e2e titkosítással rendelkezik No comment provided by engineer. contact has no e2e encryption - az ismerősnél az e2e titkosítás nem elérhető + az ismerős nem rendelkezik e2e titkosítással No comment provided by engineer. @@ -9307,7 +9406,7 @@ Kapcsolatkérés megismétlése? custom - egyedi + egyéni dropdown time picker choice @@ -9358,7 +9457,7 @@ Kapcsolatkérés megismétlése? different migration in the app/database: %@ / %@ - különböző átköltöztetések az alkalmazásban/adatbázisban: %@ / %@ + különböző átköltöztetés az alkalmazásban/adatbázisban: %@ / %@ No comment provided by engineer. @@ -9418,12 +9517,12 @@ Kapcsolatkérés megismétlése? encryption ok for %@ - titkosítás rendben van %@ számára + titkosítás rendben %@ számára chat item text encryption re-negotiation allowed - titkosítás-újraegyeztetés engedélyezve + a titkosítás újraegyeztetése engedélyezve van chat item text @@ -9433,7 +9532,7 @@ Kapcsolatkérés megismétlése? encryption re-negotiation required - titkosítás-újraegyeztetés szükséges + a titkosítás újraegyeztetése szükséges chat item text @@ -9498,7 +9597,7 @@ Kapcsolatkérés megismétlése? iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását. No comment provided by engineer. @@ -9518,7 +9617,7 @@ Kapcsolatkérés megismétlése? incognito via one-time link - inkognitó egy egyszer használható meghívó-hivatkozáson keresztül + inkognitó egy egyszer használható meghívási hivatkozáson keresztül chat list item description @@ -9578,7 +9677,7 @@ Kapcsolatkérés megismétlése? join as %@ - csatlakozás mint: %@ + csatlakozás mint %@ No comment provided by engineer. @@ -9598,7 +9697,7 @@ Kapcsolatkérés megismétlése? member %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét a következőre: %2$@ + %1$@ a következőre módosította a nevét: %2$@ profile update event chat item @@ -9690,7 +9789,7 @@ Kapcsolatkérés megismétlése? offered %1$@: %2$@ - ajánlotta %1$@: %2$@-kor + ajánlotta: %1$@, ekkor: %2$@ feature offered item @@ -9720,7 +9819,7 @@ Kapcsolatkérés megismétlése? peer-to-peer - ponttól-pontig + egyenrangú No comment provided by engineer. @@ -9805,7 +9904,7 @@ Kapcsolatkérés megismétlése? security code changed - a biztonsági kód megváltozott + a biztonsági kód módosult chat item text @@ -9817,14 +9916,14 @@ Kapcsolatkérés megismétlése? server queue info: %1$@ last received msg: %2$@ - a kiszolgáló üzenet-sorbaállítási információi: %1$@ + a kiszolgáló sorbaállítási információi: %1$@ utoljára fogadott üzenet: %2$@ queue info set new contact address - új kapcsolattartási cím beállítása + új kapcsolattartási címet állított be profile update event chat item @@ -9909,7 +10008,7 @@ utoljára fogadott üzenet: %2$@ via one-time link - egy egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívási hivatkozáson keresztül chat list item description @@ -9964,7 +10063,7 @@ utoljára fogadott üzenet: %2$@ you are invited to group - meghívást kapott a csoportba + Ön meghívást kapott a csoportba No comment provided by engineer. @@ -9979,22 +10078,22 @@ utoljára fogadott üzenet: %2$@ you changed address - Ön megváltoztatta a címet + Ön módosította a címet chat item text you changed address for %@ - Ön megváltoztatta a címet %@ számára + Ön módosította a címet %@ számára chat item text you changed role for yourself to %@ - Ön megváltoztatta a saját szerepkörét a következőre: %@ + Ön a következőre módosította a saját szerepkörét: „%@” snd group event chat item you changed role of %1$@ to %2$@ - Ön megváltoztatta %1$@ szerepkörét a következőre: %@ + Ön a következőre módosította %1$@ szerepkörét: „%2$@” snd group event chat item @@ -10009,12 +10108,12 @@ utoljára fogadott üzenet: %2$@ you shared one-time link - Ön egy egyszer használható meghívó-hivatkozást osztott meg + Ön egy egyszer használható meghívási hivatkozást osztott meg chat list item description you shared one-time link incognito - Ön egy egyszer használható meghívó-hivatkozást osztott meg inkognitóban + Ön egy egyszer használható meghívási hivatkozást osztott meg inkognitóban chat list item description @@ -10184,7 +10283,7 @@ utoljára fogadott üzenet: %2$@ Currently maximum supported file size is %@. - Jelenleg a maximálisan támogatott fájlméret: %@. + Jelenleg támogatott legnagyobb fájl méret: %@. No comment provided by engineer. @@ -10289,7 +10388,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegés-beállítások tiltják ezt az üzenetet. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 8dd8b44054..c01699933c 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -545,10 +545,12 @@ @%@ + @%@ No comment provided by engineer. @'%@' + @'%@' No comment provided by engineer. @@ -653,6 +655,7 @@ Active + Attivo token status text @@ -842,6 +845,7 @@ All reports will be archived for you. + Tutte le segnalazioni verranno archiviate per te. No comment provided by engineer. @@ -921,6 +925,7 @@ Allow to report messsages to moderators. + Consenti di segnalare messaggi ai moderatori. No comment provided by engineer. @@ -1090,10 +1095,12 @@ Archive %lld reports? + Archiviare %lld segnalazioni? No comment provided by engineer. Archive all reports? + Archiviare tutte le segnalazioni? No comment provided by engineer. @@ -1118,6 +1125,7 @@ Archive reports + Archivia segnalazioni swipe action @@ -1235,6 +1243,10 @@ Gruppi migliorati No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Date dei messaggi migliorate. @@ -1255,6 +1267,10 @@ Notifiche migliorate No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Sicurezza migliorata ✅ @@ -1679,6 +1695,7 @@ Clear group? + Svuotare il gruppo? No comment provided by engineer. @@ -1828,6 +1845,7 @@ Confirmed + Confermato token status text @@ -2845,6 +2863,10 @@ Questo è il tuo link una tantum! Non attivare No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Non mostrare più @@ -3203,6 +3225,7 @@ Questo è il tuo link una tantum! Error checking token status + Errore di controllo dello stato del token No comment provided by engineer. @@ -3362,6 +3385,7 @@ Questo è il tuo link una tantum! Error registering for notifications + Errore di registrazione per le notifiche alert title @@ -3471,6 +3495,7 @@ Questo è il tuo link una tantum! Error testing server connection + Errore provando la connessione al server No comment provided by engineer. @@ -3555,6 +3580,7 @@ Questo è il tuo link una tantum! Expired + Scaduto token status text @@ -3597,11 +3623,19 @@ Questo è il tuo link una tantum! Veloce e senza aspettare che il mittente sia in linea! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Ingresso più veloce e messaggi più affidabili. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Preferito @@ -3758,6 +3792,7 @@ Questo è il tuo link una tantum! For all moderators + Per tutti i moderatori No comment provided by engineer. @@ -3777,6 +3812,7 @@ Questo è il tuo link una tantum! For me + Per me No comment provided by engineer. @@ -3903,6 +3939,10 @@ Errore: %2$@ GIF e adesivi No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Buon pomeriggio! @@ -4018,6 +4058,10 @@ Errore: %2$@ Aiuto No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Nascosta @@ -4329,22 +4373,27 @@ Altri miglioramenti sono in arrivo! Invalid + Non valido token status text Invalid (bad token) + Non valido (token corrotto) token status text Invalid (expired) + Non valido (scaduto) token status text Invalid (unregistered) + Non valido (non registrato) token status text Invalid (wrong topic) + Non valido (argomento sbagliato) token status text @@ -4727,6 +4776,7 @@ Questo è il tuo link per il gruppo %@! Member reports + Segnalazioni dei membri chat feature @@ -4766,6 +4816,7 @@ Questo è il tuo link per il gruppo %@! Members can report messsages to moderators. + I membri possono segnalare messaggi ai moderatori. No comment provided by engineer. @@ -4793,6 +4844,10 @@ Questo è il tuo link per il gruppo %@! I membri del gruppo possono inviare messaggi vocali. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menu @@ -5045,6 +5100,7 @@ Questo è il tuo link per il gruppo %@! Mute all + Silenzia tutto notification label action @@ -5099,6 +5155,7 @@ Questo è il tuo link per il gruppo %@! New + Nuovo token status text @@ -5256,6 +5313,10 @@ Questo è il tuo link per il gruppo %@! Nessun server di multimediali e file. servers error + + No message + No comment provided by engineer. + No message servers. Nessun server dei messaggi. @@ -5313,6 +5374,7 @@ Questo è il tuo link per il gruppo %@! No token! + Nessun token! alert title @@ -5357,6 +5419,7 @@ Questo è il tuo link per il gruppo %@! Notifications error + Errore delle notifiche alert title @@ -5366,6 +5429,7 @@ Questo è il tuo link per il gruppo %@! Notifications status + Stato delle notifiche alert title @@ -5611,6 +5675,10 @@ Richiede l'attivazione della VPN. O per condividere in modo privato No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Altro @@ -5804,14 +5872,17 @@ Errore: %@ Please try to disable and re-enable notfications. + Prova a disattivare e riattivare le notifiche. token info Please wait for token activation to complete. + Attendi il completamento dell'attivazione del token. token info Please wait for token to be registered. + Attendi la registrazione del token. token info @@ -5874,6 +5945,10 @@ Errore: %@ Nomi di file privati No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Instradamento privato dei messaggi @@ -5951,6 +6026,7 @@ Errore: %@ Prohibit reporting messages to moderators. + Vieta di segnalare messaggi ai moderatori. No comment provided by engineer. @@ -6212,14 +6288,17 @@ Attivalo nelle impostazioni *Rete e server*. Register + Registra No comment provided by engineer. Register notification token? + Registrare il token di notifica? token info Registered + Registrato token status text @@ -6360,10 +6439,12 @@ Attivalo nelle impostazioni *Rete e server*. Report: (text.isEmpty ? reason.text : text) + Segnalazione: (text.isEmpty ? reason.text : text) report in notification Reporting messages to moderators is prohibited. + È vietato segnalare messaggi ai moderatori. No comment provided by engineer. @@ -6792,6 +6873,10 @@ Attivalo nelle impostazioni *Rete e server*. Invia notifiche No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Invia domande e idee @@ -7032,6 +7117,10 @@ Attivalo nelle impostazioni *Rete e server*. Impostalo al posto dell'autenticazione di sistema. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Imposta codice @@ -7498,6 +7587,7 @@ Attivalo nelle impostazioni *Rete e server*. TCP port for messaging + Porta TCP per i messaggi No comment provided by engineer. @@ -7577,6 +7667,7 @@ Attivalo nelle impostazioni *Rete e server*. Test notifications + Prova le notifiche No comment provided by engineer. @@ -7836,6 +7927,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questo link è stato usato con un altro dispositivo mobile, creane uno nuovo sul desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**. @@ -7950,6 +8045,7 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Token status: %@. + Stato del token: %@. token status @@ -8211,6 +8307,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Use TCP port %@ when no port is specified. + Usa la porta TCP %@ quando non è specificata alcuna porta. No comment provided by engineer. @@ -8290,6 +8387,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Use web port + Usa porta web No comment provided by engineer. @@ -8848,6 +8946,7 @@ Ripetere la richiesta di connessione? You should receive notifications. + Dovresti ricevere le notifiche. token info 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 9884967d68..81ceeaecba 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1191,6 +1191,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1208,6 +1212,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -2668,6 +2676,10 @@ This is your own one-time link! 有効にしない No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again 次から表示しない @@ -3365,10 +3377,18 @@ This is your own one-time link! 送信者がオンラインになるまでの待ち時間がなく、速い! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite お気に入り @@ -3634,6 +3654,10 @@ Error: %2$@ GIFとステッカー No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3744,6 +3768,10 @@ Error: %2$@ ヘルプ No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden プライベート @@ -4469,6 +4497,10 @@ This is your link for group %@! グループのメンバーが音声メッセージを送信できます。 No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4887,6 +4919,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5207,6 +5243,10 @@ VPN を有効にする必要があります。 Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -5449,6 +5489,10 @@ Error: %@ プライベートなファイル名 No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -6289,6 +6333,10 @@ Enable in *Network & servers* settings. 通知を送信する No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas 質問やアイデアを送る @@ -6500,6 +6548,10 @@ Enable in *Network & servers* settings. システム認証の代わりに設定します。 No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode パスコードを設定する @@ -7232,6 +7284,10 @@ It can happen because of some bug or when the connection is compromised.This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. この設定は現在のチャットプロフィール **%@** のメッセージに適用されます。 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 044ea87cd6..5da67ed1e6 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -545,10 +545,12 @@ @%@ + @%@ No comment provided by engineer. @'%@' + @'%@' No comment provided by engineer. @@ -653,6 +655,7 @@ Active + actief token status text @@ -842,6 +845,7 @@ All reports will be archived for you. + Alle rapporten worden voor u gearchiveerd. No comment provided by engineer. @@ -921,6 +925,7 @@ Allow to report messsages to moderators. + Hiermee kunt u berichten rapporteren aan moderators. No comment provided by engineer. @@ -1090,10 +1095,12 @@ Archive %lld reports? + %lld rapporten archiveren? No comment provided by engineer. Archive all reports? + Alle rapporten archiveren? No comment provided by engineer. @@ -1118,6 +1125,7 @@ Archive reports + Rapporten archiveren swipe action @@ -1235,6 +1243,10 @@ Betere groepen No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Betere datums voor berichten. @@ -1255,6 +1267,10 @@ Betere meldingen No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Betere beveiliging ✅ @@ -1679,6 +1695,7 @@ Clear group? + Groep wissen? No comment provided by engineer. @@ -1828,6 +1845,7 @@ Confirmed + Bevestigd token status text @@ -2845,6 +2863,10 @@ Dit is uw eigen eenmalige link! Niet inschakelen No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Niet meer weergeven @@ -3203,6 +3225,7 @@ Dit is uw eigen eenmalige link! Error checking token status + Fout bij het controleren van de tokenstatus No comment provided by engineer. @@ -3362,6 +3385,7 @@ Dit is uw eigen eenmalige link! Error registering for notifications + Fout bij registreren voor meldingen alert title @@ -3471,6 +3495,7 @@ Dit is uw eigen eenmalige link! Error testing server connection + Fout bij het testen van de serververbinding No comment provided by engineer. @@ -3555,6 +3580,7 @@ Dit is uw eigen eenmalige link! Expired + Verlopen token status text @@ -3597,11 +3623,19 @@ Dit is uw eigen eenmalige link! Snel en niet wachten tot de afzender online is! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Snellere deelname en betrouwbaardere berichten. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Favoriet @@ -3758,6 +3792,7 @@ Dit is uw eigen eenmalige link! For all moderators + Voor alle moderators No comment provided by engineer. @@ -3777,6 +3812,7 @@ Dit is uw eigen eenmalige link! For me + Voor mij No comment provided by engineer. @@ -3903,6 +3939,10 @@ Fout: %2$@ GIF's en stickers No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Goedemiddag! @@ -4018,6 +4058,10 @@ Fout: %2$@ Help No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Verborgen @@ -4329,22 +4373,27 @@ Binnenkort meer verbeteringen! Invalid + Ongeldig token status text Invalid (bad token) + Ongeldig (ongeldig token) token status text Invalid (expired) + Ongeldig (verlopen) token status text Invalid (unregistered) + Ongeldig (niet geregistreerd) token status text Invalid (wrong topic) + Ongeldig (verkeerd onderwerp) token status text @@ -4727,6 +4776,7 @@ Dit is jouw link voor groep %@! Member reports + Ledenrapporten chat feature @@ -4766,6 +4816,7 @@ Dit is jouw link voor groep %@! Members can report messsages to moderators. + Leden kunnen berichten melden bij moderators. No comment provided by engineer. @@ -4793,6 +4844,10 @@ Dit is jouw link voor groep %@! Groepsleden kunnen spraak berichten verzenden. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menu's @@ -5045,6 +5100,7 @@ Dit is jouw link voor groep %@! Mute all + Alles dempen notification label action @@ -5099,6 +5155,7 @@ Dit is jouw link voor groep %@! New + Nieuw token status text @@ -5256,6 +5313,10 @@ Dit is jouw link voor groep %@! Geen media- en bestandsservers. servers error + + No message + No comment provided by engineer. + No message servers. Geen berichtenservers. @@ -5313,6 +5374,7 @@ Dit is jouw link voor groep %@! No token! + Geen token! alert title @@ -5357,6 +5419,7 @@ Dit is jouw link voor groep %@! Notifications error + Meldingsfout alert title @@ -5366,6 +5429,7 @@ Dit is jouw link voor groep %@! Notifications status + Meldingsstatus alert title @@ -5611,6 +5675,10 @@ Vereist het inschakelen van VPN. Of om privé te delen No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Ander @@ -5804,14 +5872,17 @@ Fout: %@ Please try to disable and re-enable notfications. + Probeer meldingen uit en weer in te schakelen. token info Please wait for token activation to complete. + Wacht tot de tokenactivering voltooid is. token info Please wait for token to be registered. + Wacht tot het token is geregistreerd. token info @@ -5874,6 +5945,10 @@ Fout: %@ Privé bestandsnamen No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Routering van privéberichten @@ -5951,6 +6026,7 @@ Fout: %@ Prohibit reporting messages to moderators. + Het melden van berichten aan moderators is niet toegestaan. No comment provided by engineer. @@ -6212,14 +6288,17 @@ Schakel dit in in *Netwerk en servers*-instellingen. Register + Register No comment provided by engineer. Register notification token? + Meldingstoken registreren? token info Registered + Geregistreerd token status text @@ -6360,10 +6439,12 @@ Schakel dit in in *Netwerk en servers*-instellingen. Report: (text.isEmpty ? reason.text : text) + Rapport: (text.isEmpty ? reason.text : text) report in notification Reporting messages to moderators is prohibited. + Het is niet toegestaan om berichten aan moderators te melden. No comment provided by engineer. @@ -6792,6 +6873,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Meldingen verzenden No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Stuur vragen en ideeën @@ -7032,6 +7117,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Stel het in in plaats van systeemverificatie. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Toegangscode instellen @@ -7498,6 +7587,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. TCP port for messaging + TCP-poort voor berichtenuitwisseling No comment provided by engineer. @@ -7577,6 +7667,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Test notifications + Testmeldingen No comment provided by engineer. @@ -7836,6 +7927,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze link is gebruikt met een ander mobiel apparaat. Maak een nieuwe link op de desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Deze instelling is van toepassing op berichten in je huidige chatprofiel **%@**. @@ -7950,6 +8045,7 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Token status: %@. + Tokenstatus: %@. token status @@ -8211,6 +8307,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Use TCP port %@ when no port is specified. + Gebruik TCP-poort %@ als er geen poort is opgegeven. No comment provided by engineer. @@ -8290,6 +8387,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Use web port + Gebruik een webpoort No comment provided by engineer. @@ -8848,6 +8946,7 @@ Verbindingsverzoek herhalen? You should receive notifications. + U zou meldingen moeten ontvangen. token info @@ -9829,7 +9928,7 @@ laatst ontvangen bericht: %2$@ set new profile picture - nieuwe profielfoto instellen + nieuwe profielfoto profile update event chat item 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 1c39ed50ba..87200f0c24 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -544,10 +544,12 @@ @%@ + @%@ No comment provided by engineer. @'%@' + @'%@' No comment provided by engineer. @@ -652,6 +654,7 @@ Active + Aktywne token status text @@ -816,6 +819,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich. No comment provided by engineer. @@ -840,6 +844,7 @@ All reports will be archived for you. + Wszystkie raporty zostaną dla Ciebie zarchiwizowane. No comment provided by engineer. @@ -919,6 +924,7 @@ Allow to report messsages to moderators. + Zezwól na zgłaszanie wiadomości moderatorom. No comment provided by engineer. @@ -1003,6 +1009,7 @@ Another reason + Inny powód report reason @@ -1032,6 +1039,7 @@ App group: + Grupa aplikacji: No comment provided by engineer. @@ -1081,14 +1089,17 @@ Archive + Archiwizuj No comment provided by engineer. Archive %lld reports? + Archiwizować %lld reports? No comment provided by engineer. Archive all reports? + Archiwizować wszystkie zgłoszenia? No comment provided by engineer. @@ -1103,14 +1114,17 @@ Archive report + Archiwizuj zgłoszenie No comment provided by engineer. Archive report? + Archiwizować zgłoszenie? No comment provided by engineer. Archive reports + Archiwizuj zgłoszenia swipe action @@ -1125,6 +1139,7 @@ Ask + Zapytaj No comment provided by engineer. @@ -1227,6 +1242,10 @@ Lepsze grupy No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Lepsze daty wiadomości. @@ -1247,6 +1266,10 @@ Lepsze powiadomienia No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Lepsze zabezpieczenia ✅ @@ -2808,6 +2831,10 @@ To jest twój jednorazowy link! Nie włączaj No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Nie pokazuj ponownie @@ -3546,11 +3573,19 @@ To jest twój jednorazowy link! Szybko i bez czekania aż nadawca będzie online! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Szybsze dołączenie i bardziej niezawodne wiadomości. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Ulubione @@ -3844,6 +3879,10 @@ Błąd: %2$@ GIF-y i naklejki No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Dzień dobry! @@ -3958,6 +3997,10 @@ Błąd: %2$@ Pomoc No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Ukryte @@ -4718,6 +4761,10 @@ To jest twój link do grupy %@! Członkowie grupy mogą wysyłać wiadomości głosowe. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menu @@ -5170,6 +5217,10 @@ To jest twój link do grupy %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5505,6 +5556,10 @@ Wymaga włączenia VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Inne @@ -5766,6 +5821,10 @@ Błąd: %@ Prywatne nazwy plików No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Trasowanie prywatnych wiadomości @@ -6673,6 +6732,10 @@ Włącz w ustawianiach *Sieć i serwery* . Wyślij powiadomienia No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Wyślij pytania i pomysły @@ -6908,6 +6971,10 @@ Włącz w ustawianiach *Sieć i serwery* . Ustaw go zamiast uwierzytelniania systemowego. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Ustaw pin @@ -7690,6 +7757,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**. @@ -8868,6 +8939,7 @@ Powtórzyć prośbę połączenia? Your servers + Twoje serwery No comment provided by engineer. @@ -9946,6 +10018,7 @@ ostatnia otrzymana wiadomość: %2$@ New messages in %d chats + Nowe wiadomości w %d czatach notification body 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 9bae61e56d..a7a8f1290a 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1231,6 +1231,10 @@ Улучшенные группы No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Улучшенные даты сообщений. @@ -1251,6 +1255,10 @@ Улучшенные уведомления No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Улучшенная безопасность ✅ @@ -2824,6 +2832,10 @@ This is your own one-time link! Не включать No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Не показывать @@ -3570,11 +3582,19 @@ This is your own one-time link! Быстрые и не нужно ждать, когда отправитель онлайн! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Быстрое вступление и надежная доставка сообщений. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Избранный @@ -3873,6 +3893,10 @@ Error: %2$@ ГИФ файлы и стикеры No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Добрый день! @@ -3987,6 +4011,10 @@ Error: %2$@ Помощь No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Скрытое @@ -4755,6 +4783,10 @@ This is your link for group %@! Члены группы могут отправлять голосовые сообщения. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Меню @@ -5213,6 +5245,10 @@ This is your link for group %@! Нет серверов файлов и медиа. servers error + + No message + No comment provided by engineer. + No message servers. Нет серверов сообщений. @@ -5561,6 +5597,10 @@ Requires compatible VPN. Или поделиться конфиденциально No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Другaя сеть @@ -5824,6 +5864,10 @@ Error: %@ Защищенные имена файлов No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Конфиденциальная доставка сообщений @@ -6733,6 +6777,10 @@ Enable in *Network & servers* settings. Отправлять уведомления No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Отправьте вопросы и идеи @@ -6972,6 +7020,10 @@ Enable in *Network & servers* settings. Установите код вместо системной аутентификации. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Установить код доступа @@ -7773,6 +7825,10 @@ It can happen because of some bug or when the connection is compromised.Эта ссылка была использована на другом мобильном, пожалуйста, создайте новую ссылку на компьютере. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**. 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 7239c30b44..bfbfe297b2 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1134,6 +1134,10 @@ Better groups No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1151,6 +1155,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -2583,6 +2591,10 @@ This is your own one-time link! อย่าเปิดใช้งาน No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again ไม่ต้องแสดงอีก @@ -3277,10 +3289,18 @@ This is your own one-time link! รวดเร็วและไม่ต้องรอจนกว่าผู้ส่งจะออนไลน์! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite ที่ชอบ @@ -3546,6 +3566,10 @@ Error: %2$@ GIFs และสติกเกอร์ No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! message preview @@ -3656,6 +3680,10 @@ Error: %2$@ ความช่วยเหลือ No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden ซ่อนอยู่ @@ -4379,6 +4407,10 @@ This is your link for group %@! สมาชิกกลุ่มสามารถส่งข้อความเสียง No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus No comment provided by engineer. @@ -4792,6 +4824,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5109,6 +5145,10 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other No comment provided by engineer. @@ -5351,6 +5391,10 @@ Error: %@ ชื่อไฟล์ส่วนตัว No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing No comment provided by engineer. @@ -6189,6 +6233,10 @@ Enable in *Network & servers* settings. ส่งการแจ้งเตือน No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas ส่งคําถามและความคิด @@ -6405,6 +6453,10 @@ Enable in *Network & servers* settings. ตั้งแทนการรับรองความถูกต้องของระบบ No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode ตั้งรหัสผ่าน @@ -7134,6 +7186,10 @@ It can happen because of some bug or when the connection is compromised.This link was used with another mobile device, please create a new link on the desktop. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. การตั้งค่านี้ใช้กับข้อความในโปรไฟล์แชทปัจจุบันของคุณ **%@** 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 0963a750aa..de90854e24 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -1222,6 +1222,10 @@ Daha iyi gruplar No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Daha iyi mesaj tarihleri. @@ -1242,6 +1246,10 @@ Daha iyi bildirimler No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Daha iyi güvenlik ✅ @@ -2814,6 +2822,10 @@ Bu senin kendi tek kullanımlık bağlantın! Etkinleştirme No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Yeniden gösterme @@ -3560,11 +3572,19 @@ Bu senin kendi tek kullanımlık bağlantın! Hızlı ve gönderici çevrimiçi olana kadar beklemek yok! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Daha hızlı katılma ve daha güvenilir mesajlar. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Favori @@ -3863,6 +3883,10 @@ Hata: %2$@ GİFler ve çıkartmalar No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! İyi öğlenler! @@ -3977,6 +4001,10 @@ Hata: %2$@ Yardım No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Gizlenmiş @@ -4745,6 +4773,10 @@ Bu senin grup için bağlantın %@! Grup üyeleri sesli mesajlar gönderebilir. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Menüler @@ -5197,6 +5229,10 @@ Bu senin grup için bağlantın %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5532,6 +5568,10 @@ VPN'nin etkinleştirilmesi gerekir. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Diğer @@ -5793,6 +5833,10 @@ Hata: %@ Gizli dosya adları No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Gizli mesaj yönlendirme @@ -6700,6 +6744,10 @@ Enable in *Network & servers* settings. Bildirimler gönder No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Fikirler ve sorular gönderin @@ -6935,6 +6983,10 @@ Enable in *Network & servers* settings. Sistem kimlik doğrulaması yerine ayarla. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Şifre ayarla @@ -7720,6 +7772,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Bu bağlantı başka bir mobil cihazda kullanıldı, lütfen masaüstünde yeni bir bağlantı oluşturun. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Bu ayar, geçerli sohbet profiliniz **%@** deki mesajlara uygulanır. 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 8d48227e77..61eb8d8206 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1222,6 +1222,10 @@ Кращі групи No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. Кращі дати повідомлень. @@ -1242,6 +1246,10 @@ Кращі сповіщення No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ Краща безпека ✅ @@ -2815,6 +2823,10 @@ This is your own one-time link! Не вмикати No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again Більше не показувати @@ -3561,11 +3573,19 @@ This is your own one-time link! Швидко і без очікування, поки відправник буде онлайн! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. Швидше приєднання та надійніші повідомлення. No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite Улюблений @@ -3864,6 +3884,10 @@ Error: %2$@ GIF-файли та наклейки No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! Доброго дня! @@ -3978,6 +4002,10 @@ Error: %2$@ Довідка No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden Приховано @@ -4747,6 +4775,10 @@ This is your link for group %@! Учасники групи можуть надсилати голосові повідомлення. No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus Меню @@ -5205,6 +5237,10 @@ This is your link for group %@! Ніяких медіа та файлових серверів. servers error + + No message + No comment provided by engineer. + No message servers. Ніяких серверів повідомлень. @@ -5553,6 +5589,10 @@ Requires compatible VPN. Або поділитися приватно No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other Інше @@ -5816,6 +5856,10 @@ Error: %@ Приватні імена файлів No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing Маршрутизація приватних повідомлень @@ -6725,6 +6769,10 @@ Enable in *Network & servers* settings. Надсилати сповіщення No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas Надсилайте запитання та ідеї @@ -6964,6 +7012,10 @@ Enable in *Network & servers* settings. Встановіть його замість аутентифікації системи. No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode Встановити пароль @@ -7765,6 +7817,10 @@ It can happen because of some bug or when the connection is compromised.Це посилання було використано з іншого мобільного пристрою, будь ласка, створіть нове посилання на робочому столі. No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**. 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 6ac247c6f1..c79f95ba8a 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 @@ -1197,6 +1197,10 @@ 更佳的群组 No comment provided by engineer. + + Better groups performance + No comment provided by engineer. + Better message dates. No comment provided by engineer. @@ -1215,6 +1219,10 @@ Better notifications No comment provided by engineer. + + Better privacy and security + No comment provided by engineer. + Better security ✅ No comment provided by engineer. @@ -2755,6 +2763,10 @@ This is your own one-time link! 不要启用 No comment provided by engineer. + + Don't miss important messages. + No comment provided by engineer. + Don't show again 不再显示 @@ -3488,11 +3500,19 @@ This is your own one-time link! 快速且无需等待发件人在线! No comment provided by engineer. + + Faster deletion of groups. + No comment provided by engineer. + Faster joining and more reliable messages. 加入速度更快、信息更可靠。 No comment provided by engineer. + + Faster sending messages. + No comment provided by engineer. + Favorite 最喜欢 @@ -3780,6 +3800,10 @@ Error: %2$@ GIF 和贴纸 No comment provided by engineer. + + Get notified when mentioned. + No comment provided by engineer. + Good afternoon! 下午好! @@ -3894,6 +3918,10 @@ Error: %2$@ 帮助 No comment provided by engineer. + + Help admins moderating their groups. + No comment provided by engineer. + Hidden 隐藏 @@ -4653,6 +4681,10 @@ This is your link for group %@! 群组成员可以发送语音消息。 No comment provided by engineer. + + Mention members 👋 + No comment provided by engineer. + Menus 菜单 @@ -5101,6 +5133,10 @@ This is your link for group %@! No media & file servers. servers error + + No message + No comment provided by engineer. + No message servers. servers error @@ -5433,6 +5469,10 @@ Requires compatible VPN. Or to share privately No comment provided by engineer. + + Organize chats into lists + No comment provided by engineer. + Other 其他 @@ -5690,6 +5730,10 @@ Error: %@ 私密文件名 No comment provided by engineer. + + Private media file names. + No comment provided by engineer. + Private message routing 私有消息路由 @@ -6591,6 +6635,10 @@ Enable in *Network & servers* settings. 发送通知 No comment provided by engineer. + + Send private reports + No comment provided by engineer. + Send questions and ideas 发送问题和想法 @@ -6825,6 +6873,10 @@ Enable in *Network & servers* settings. 设置它以代替系统身份验证。 No comment provided by engineer. + + Set message expiration in chats. + No comment provided by engineer. + Set passcode 设置密码 @@ -7602,6 +7654,10 @@ It can happen because of some bug or when the connection is compromised.此链接已在其他移动设备上使用,请在桌面上创建新链接。 No comment provided by engineer. + + This message was deleted or not received yet. + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. 此设置适用于您当前聊天资料 **%@** 中的消息。 diff --git a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings index 5ef592ec70..3a577620a0 100644 --- a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings @@ -1,7 +1,3 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"New messages in %d chats" = "Nowe wiadomości w %d czatach"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 0ae5c52efa..2b7338396a 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -17,7 +17,7 @@ "Comment" = "Hozzászólás"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Jelenleg a maximálisan támogatott fájlméret: %@."; +"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájl méret: %@."; /* No comment provided by engineer. */ "Database downgrade required" = "Adatbázis visszafejlesztése szükséges"; @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Hozzon létre egy profilt a SimpleX alkalmazásban"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegés-beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Az üzenet elküldése a vártnál tovább tart."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 874d8c9c88..c391c1882d 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -58,6 +58,12 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"@'%@'" = "@'%@'"; + +/* No comment provided by engineer. */ +"@%@" = "@%@"; + /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Kontakt hinzufügen**: Um einen neuen Einladungslink zu erstellen."; @@ -304,7 +310,7 @@ /* delete after time time interval */ -"1 month" = "Älter als einen Monat"; +"1 month" = "Älter als ein Monat"; /* delete after time time interval */ @@ -397,6 +403,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Fehler bei der Bestätigung"; +/* token status text */ +"Active" = "Aktiv"; + /* No comment provided by engineer. */ "Active connections" = "Aktive Verbindungen"; @@ -523,6 +532,9 @@ /* profile dropdown */ "All profiles" = "Alle Profile"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Alle Meldungen werden für Sie archiviert."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Alle Ihre Kontakte bleiben verbunden."; @@ -568,6 +580,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Melden von Nachrichten an Moderatoren erlauben."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Das Senden von Dateien und Medien erlauben."; @@ -673,6 +688,12 @@ /* No comment provided by engineer. */ "Archive" = "Archiv"; +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiviere %lld Meldungen?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Alle Meldungen archivieren?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivieren und Hochladen"; @@ -685,6 +706,9 @@ /* No comment provided by engineer. */ "Archive report?" = "Meldung archivieren?"; +/* swipe action */ +"Archive reports" = "Meldungen archivieren"; + /* No comment provided by engineer. */ "Archived contacts" = "Archivierte Kontakte"; @@ -1080,6 +1104,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Chat-Inhalte entfernen?"; +/* No comment provided by engineer. */ +"Clear group?" = "Gruppe entfernen?"; + /* No comment provided by engineer. */ "Clear or delete group?" = "Gruppe entfernen oder löschen?"; @@ -1173,6 +1200,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Hochladen bestätigen"; +/* token status text */ +"Confirmed" = "Bestätigt"; + /* server test step */ "Connect" = "Verbinden"; @@ -1550,7 +1580,7 @@ /* delete after time pref value */ -"default (%@)" = "Voreinstellung (%@)"; +"default (%@)" = "Default (%@)"; /* No comment provided by engineer. */ "default (no)" = "Voreinstellung (Nein)"; @@ -1656,7 +1686,7 @@ "Delete messages" = "Nachrichten löschen"; /* No comment provided by engineer. */ -"Delete messages after" = "Löschen der Nachrichten"; +"Delete messages after" = "Nachrichten löschen"; /* No comment provided by engineer. */ "Delete old database" = "Alte Datenbank löschen"; @@ -2127,6 +2157,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Fehler beim Wechseln zum Inkognito-Profil!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Fehler beim Überprüfen des Token-Status"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut."; @@ -2220,6 +2253,9 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Fehler beim Wiederherstellen der Verbindungen zu den Servern"; +/* alert title */ +"Error registering for notifications" = "Fehler beim Registrieren für Benachrichtigungen"; + /* No comment provided by engineer. */ "Error removing member" = "Fehler beim Entfernen des Mitglieds"; @@ -2283,6 +2319,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Fehler beim Synchronisieren der Verbindung"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Fehler beim Testen der Server-Verbindung"; + /* No comment provided by engineer. */ "Error updating group link" = "Fehler beim Aktualisieren des Gruppen-Links"; @@ -2337,6 +2376,9 @@ /* No comment provided by engineer. */ "expired" = "Abgelaufen"; +/* token status text */ +"Expired" = "Abgelaufen"; + /* No comment provided by engineer. */ "Export database" = "Datenbank exportieren"; @@ -2454,6 +2496,9 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Reparatur wird vom Gruppenmitglied nicht unterstützt"; +/* No comment provided by engineer. */ +"For all moderators" = "Für alle Moderatoren"; + /* No comment provided by engineer. */ "for better metadata privacy." = "für einen besseren Metadatenschutz."; @@ -2466,6 +2511,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Wenn Ihr Kontakt beispielsweise Nachrichten über einen SimpleX-Chatserver empfängt, wird Ihre App diese über einen der Server von Flux versenden."; +/* No comment provided by engineer. */ +"For me" = "Für mich"; + /* No comment provided by engineer. */ "For private routing" = "Für privates Routing"; @@ -2814,6 +2862,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Interface-Farben"; +/* token status text */ +"Invalid" = "Ungültig"; + +/* token status text */ +"Invalid (bad token)" = "Ungültig (falsches Token)"; + +/* token status text */ +"Invalid (expired)" = "Ungültig (abgelaufen)"; + +/* token status text */ +"Invalid (unregistered)" = "Ungültig (nicht registriert)"; + +/* token status text */ +"Invalid (wrong topic)" = "Ungültig (falsches Thema)"; + /* invalid chat data */ "invalid chat" = "Ungültiger Chat"; @@ -3093,6 +3156,9 @@ /* item status text */ "Member inactive" = "Mitglied inaktiv"; +/* chat feature */ +"Member reports" = "Mitglieder-Meldungen"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "Die Rolle des Mitglieds wird auf \"%@\" geändert. Alle Chat-Mitglieder werden darüber informiert."; @@ -3114,6 +3180,9 @@ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen. (24 Stunden)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Mitglieder können Nachrichten an Moderatoren melden."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Gruppenmitglieder können Direktnachrichten versenden."; @@ -3303,6 +3372,9 @@ /* notification label action */ "Mute" = "Stummschalten"; +/* notification label action */ +"Mute all" = "Alle stummschalten"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Bei Inaktivität stummgeschaltet!"; @@ -3336,6 +3408,9 @@ /* delete after time */ "never" = "nie"; +/* token status text */ +"New" = "Neu"; + /* No comment provided by engineer. */ "New chat" = "Neuer Chat"; @@ -3474,6 +3549,9 @@ /* copied message info in history */ "no text" = "Kein Text"; +/* alert title */ +"No token!" = "Kein Token!"; + /* No comment provided by engineer. */ "No unread chats" = "Keine ungelesenen Chats"; @@ -3498,9 +3576,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!"; +/* alert title */ +"Notifications error" = "Benachrichtigungs-Fehler"; + /* No comment provided by engineer. */ "Notifications privacy" = "Datenschutz für Benachrichtigungen"; +/* alert title */ +"Notifications status" = "Benachrichtigungs-Status"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Administratoren können nun\n- Nachrichten von Gruppenmitgliedern löschen\n- Gruppenmitglieder deaktivieren (\"Beobachter\"-Rolle)"; @@ -3785,6 +3869,15 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren."; +/* token info */ +"Please try to disable and re-enable notfications." = "Bitte versuchen Sie, die Benachrichtigungen zu deaktivieren und wieder zu aktivieren."; + +/* token info */ +"Please wait for token activation to complete." = "Bitte warten Sie, bis die Token-Aktivierung abgeschlossen ist."; + +/* token info */ +"Please wait for token to be registered." = "Bitte warten Sie auf die Registrierung des Tokens."; + /* No comment provided by engineer. */ "Polish interface" = "Polnische Bedienoberfläche"; @@ -3866,6 +3959,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Reaktionen auf Nachrichten nicht erlauben."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Melden von Nachrichten an Moderatoren nicht erlauben."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Das Senden von Direktnachrichten an Gruppenmitglieder nicht erlauben."; @@ -4028,6 +4124,15 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Reduzierter Batterieverbrauch"; +/* No comment provided by engineer. */ +"Register" = "Registrieren"; + +/* token info */ +"Register notification token?" = "Benachrichtigungs-Token registrieren?"; + +/* token status text */ +"Registered" = "Registriert"; + /* reject incoming call via notification swipe action */ "Reject" = "Ablehnen"; @@ -4128,6 +4233,12 @@ /* report reason */ "Report violation: only group moderators will see it." = "Verstoß melden: Nur Gruppenmoderatoren werden es sehen."; +/* report in notification */ +"Report: (text.isEmpty ? reason.text : text)" = "Meldung: (text.isEmpty ? reason.text : text)"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Melden von Nachrichten an Moderatoren ist nicht erlaubt."; + /* No comment provided by engineer. */ "Reports" = "Meldungen"; @@ -4881,6 +4992,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Timeout der TCP-Verbindung"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-Port für Nachrichtenübermittlung"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4896,6 +5010,9 @@ /* server test failure */ "Test failed at step %@." = "Der Test ist beim Schritt %@ fehlgeschlagen."; +/* No comment provided by engineer. */ +"Test notifications" = "Benachrichtigungen testen"; + /* No comment provided by engineer. */ "Test server" = "Teste Server"; @@ -4971,9 +5088,6 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Das Profil wird nur mit Ihren Kontakten geteilt."; -/* No comment provided by engineer. */ -"The report will be archived for you." = "Die Meldung wird für Sie archiviert."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**."; @@ -5121,6 +5235,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Inkognito beim Verbinden einschalten."; +/* token status */ +"Token status: %@." = "Token-Status: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Deckkraft der Symbolleiste"; @@ -5334,12 +5451,18 @@ /* No comment provided by engineer. */ "Use SOCKS proxy" = "SOCKS-Proxy nutzen"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Solange kein Port konfiguriert ist, wird TCP-Port %@ genutzt."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Die App kann während eines Anrufs genutzt werden."; /* No comment provided by engineer. */ "Use the app with one hand." = "Die App mit einer Hand bedienen."; +/* No comment provided by engineer. */ +"Use web port" = "Web-Port nutzen"; + /* No comment provided by engineer. */ "User selection" = "Benutzer-Auswahl"; @@ -5748,6 +5871,9 @@ /* chat list item description */ "you shared one-time link incognito" = "Sie haben Inkognito einen Einmal-Link geteilt"; +/* token info */ +"You should receive notifications." = "Sie sollten Benachrichtigungen erhalten."; + /* snd group event chat item */ "you unblocked %@" = "Sie haben %@ freigegeben"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 2004e192c1..6e5d0e3d03 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -58,6 +58,12 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Estrella en GitHub](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"@'%@'" = "@'%@'"; + +/* No comment provided by engineer. */ +"@%@" = "@%@"; + /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Añadir contacto**: crea un enlace de invitación nuevo."; @@ -397,6 +403,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Errores de confirmación"; +/* token status text */ +"Active" = "Activo"; + /* No comment provided by engineer. */ "Active connections" = "Conexiones activas"; @@ -523,6 +532,9 @@ /* profile dropdown */ "All profiles" = "Todos los perfiles"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Todos los informes serán archivados para ti."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Todos tus contactos permanecerán conectados."; @@ -568,6 +580,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Se permite la eliminación irreversible de mensajes. (24 horas)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Se permite informar de mensajes a los moderadores."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Se permite enviar archivos y multimedia."; @@ -673,6 +688,12 @@ /* No comment provided by engineer. */ "Archive" = "Archivar"; +/* No comment provided by engineer. */ +"Archive %lld reports?" = "¿Archivar %lld informes?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "¿Archivar todos los informes?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivar y subir"; @@ -685,6 +706,9 @@ /* No comment provided by engineer. */ "Archive report?" = "¿Archivar informe?"; +/* swipe action */ +"Archive reports" = "Archivar informes"; + /* No comment provided by engineer. */ "Archived contacts" = "Contactos archivados"; @@ -817,7 +841,7 @@ /* rcv group event chat item */ "blocked %@" = "ha bloqueado a %@"; -/* blocked chat item */ +/* marked deleted chat item preview text */ "blocked by admin" = "bloqueado por administrador"; /* No comment provided by engineer. */ @@ -1080,6 +1104,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "¿Vaciar conversación?"; +/* No comment provided by engineer. */ +"Clear group?" = "¿Vaciar grupo?"; + /* No comment provided by engineer. */ "Clear or delete group?" = "¿Vaciar o eliminar grupo?"; @@ -1173,6 +1200,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Confirmar subida"; +/* token status text */ +"Confirmed" = "Confirmado"; + /* server test step */ "Connect" = "Conectar"; @@ -1231,7 +1261,7 @@ "Connected to desktop" = "Conectado con ordenador"; /* No comment provided by engineer. */ -"connecting" = "conectando"; +"connecting" = "conectando..."; /* No comment provided by engineer. */ "Connecting" = "Conectando"; @@ -1653,7 +1683,7 @@ "Delete message?" = "¿Eliminar mensaje?"; /* alert button */ -"Delete messages" = "Eliminar mensajes"; +"Delete messages" = "Activar"; /* No comment provided by engineer. */ "Delete messages after" = "Eliminar en"; @@ -1704,7 +1734,7 @@ "deleted contact" = "contacto eliminado"; /* rcv group event chat item */ -"deleted group" = "grupo eliminado"; +"deleted group" = "ha eliminado el grupo"; /* No comment provided by engineer. */ "Deletion errors" = "Errores de eliminación"; @@ -2127,6 +2157,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "¡Error al cambiar a incógnito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Error al verificar el estado del token"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde."; @@ -2220,8 +2253,11 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Error al reconectar con los servidores"; +/* alert title */ +"Error registering for notifications" = "Error al registrarse para notificaciones"; + /* No comment provided by engineer. */ -"Error removing member" = "Error al eliminar miembro"; +"Error removing member" = "Error al expulsar miembro"; /* alert title */ "Error reordering lists" = "Error al reorganizar listas"; @@ -2283,6 +2319,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Error al sincronizar conexión"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Error al testar la conexión al servidor"; + /* No comment provided by engineer. */ "Error updating group link" = "Error al actualizar enlace de grupo"; @@ -2307,8 +2346,7 @@ /* No comment provided by engineer. */ "Error: " = "Error: "; -/* file error text - snd error text */ +/* alert message */ "Error: %@" = "Error: %@"; /* No comment provided by engineer. */ @@ -2338,6 +2376,9 @@ /* No comment provided by engineer. */ "expired" = "expirados"; +/* token status text */ +"Expired" = "Expirado"; + /* No comment provided by engineer. */ "Export database" = "Exportar base de datos"; @@ -2455,6 +2496,9 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Corrección no compatible con miembro del grupo"; +/* No comment provided by engineer. */ +"For all moderators" = "Para todos los moderadores"; + /* No comment provided by engineer. */ "for better metadata privacy." = "para mejorar la privacidad de los metadatos."; @@ -2467,6 +2511,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Si por ejemplo tu contacto recibe los mensajes a través de un servidor de SimpleX Chat, tu aplicación los entregará a través de un servidor de Flux."; +/* No comment provided by engineer. */ +"For me" = "para mí"; + /* No comment provided by engineer. */ "For private routing" = "Para enrutamiento privado"; @@ -2815,6 +2862,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Colores del interfaz"; +/* token status text */ +"Invalid" = "No válido"; + +/* token status text */ +"Invalid (bad token)" = "No válido (token incorrecto)"; + +/* token status text */ +"Invalid (expired)" = "No válido (expirado)"; + +/* token status text */ +"Invalid (unregistered)" = "No válido (no registrado)"; + +/* token status text */ +"Invalid (wrong topic)" = "No válido (tópico incorrecto)"; + /* invalid chat data */ "invalid chat" = "chat no válido"; @@ -3094,6 +3156,9 @@ /* item status text */ "Member inactive" = "Miembro inactivo"; +/* chat feature */ +"Member reports" = "Informes de miembro"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "El rol del miembro cambiará a \"%@\" y todos serán notificados."; @@ -3115,6 +3180,9 @@ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Los miembros del grupo pueden eliminar mensajes de forma irreversible. (24 horas)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Los miembros pueden informar de mensajes a los moderadores."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Los miembros del grupo pueden enviar mensajes directos."; @@ -3304,6 +3372,9 @@ /* notification label action */ "Mute" = "Silenciar"; +/* notification label action */ +"Mute all" = "Silenciar todo"; + /* No comment provided by engineer. */ "Muted when inactive!" = "¡Silenciado cuando está inactivo!"; @@ -3337,6 +3408,9 @@ /* delete after time */ "never" = "nunca"; +/* token status text */ +"New" = "Nuevo"; + /* No comment provided by engineer. */ "New chat" = "Nuevo chat"; @@ -3475,6 +3549,9 @@ /* copied message info in history */ "no text" = "sin texto"; +/* alert title */ +"No token!" = "¡Sin token!"; + /* No comment provided by engineer. */ "No unread chats" = "Ningún chat sin leer"; @@ -3499,9 +3576,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "¡Las notificaciones están desactivadas!"; +/* alert title */ +"Notifications error" = "Error en notificaciones"; + /* No comment provided by engineer. */ "Notifications privacy" = "Privacidad en las notificaciones"; +/* alert title */ +"Notifications status" = "Estado notificaciones"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Ahora los administradores pueden:\n- eliminar mensajes de los miembros.\n- desactivar el rol miembro (a rol \"observador\")"; @@ -3786,6 +3869,15 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes."; +/* token info */ +"Please try to disable and re-enable notfications." = "Por favor, intenta desactivar y reactivar las notificaciones."; + +/* token info */ +"Please wait for token activation to complete." = "Por favor, espera a que el token de activación se complete."; + +/* token info */ +"Please wait for token to be registered." = "Por favor, espera a que el token se registre."; + /* No comment provided by engineer. */ "Polish interface" = "Interfaz en polaco"; @@ -3867,6 +3959,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "No se permiten reacciones a los mensajes."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "No se permite informar de mensajes a los moderadores."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "No se permiten mensajes directos entre miembros."; @@ -4029,6 +4124,15 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Reducción del uso de batería"; +/* No comment provided by engineer. */ +"Register" = "Registrar"; + +/* token info */ +"Register notification token?" = "¿Registrar el token de notificaciones?"; + +/* token status text */ +"Registered" = "Registrado"; + /* reject incoming call via notification swipe action */ "Reject" = "Rechazar"; @@ -4129,6 +4233,12 @@ /* report reason */ "Report violation: only group moderators will see it." = "Informar de violación: sólo los moderadores del grupo lo verán."; +/* report in notification */ +"Report: (text.isEmpty ? reason.text : text)" = "Informe: (text.isEmpty ? reason.text : text)"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "No se permite informar de mensajes a los moderadores."; + /* No comment provided by engineer. */ "Reports" = "Informes"; @@ -4882,6 +4992,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Timeout de la conexión TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Puerto TCP para mensajes"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4897,6 +5010,9 @@ /* server test failure */ "Test failed at step %@." = "Prueba no superada en el paso %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Probar notificaciones"; + /* No comment provided by engineer. */ "Test server" = "Probar servidor"; @@ -4972,9 +5088,6 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; -/* No comment provided by engineer. */ -"The report will be archived for you." = "El informe será archivado para ti."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Las mismas condiciones se aplicarán al operador **%@**."; @@ -5021,7 +5134,7 @@ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Esta acción es irreversible. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Podría tardar varios minutos."; /* alert message */ -"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Todos los mensajes previos a la selección serán eliminados del chat. ¡No podrá deshacerse!"; +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No podrá deshacerse!"; /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente."; @@ -5122,6 +5235,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Activa incógnito al conectar."; +/* token status */ +"Token status: %@." = "Estado token: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Opacidad barra"; @@ -5335,12 +5451,18 @@ /* No comment provided by engineer. */ "Use SOCKS proxy" = "Usar proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Se usa el puerto TCP %@ cuando no se ha especificado otro."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Usar la aplicación durante la llamada."; /* No comment provided by engineer. */ "Use the app with one hand." = "Usa la aplicación con una sola mano."; +/* No comment provided by engineer. */ +"Use web port" = "Usar puerto web"; + /* No comment provided by engineer. */ "User selection" = "Selección de usuarios"; @@ -5749,6 +5871,9 @@ /* chat list item description */ "you shared one-time link incognito" = "has compartido enlace de un solo uso en modo incógnito"; +/* token info */ +"You should receive notifications." = "Deberías recibir notificaciones."; + /* snd group event chat item */ "you unblocked %@" = "has desbloqueado a %@"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index f12a39b3b3..b926536f04 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -17,13 +17,13 @@ "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tag).\n- gyorsabb és stabilabb."; /* No comment provided by engineer. */ -"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- valamivel jobb csoportok.\n- és még sok más!"; +"- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más!"; /* No comment provided by engineer. */ "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- értesíti az ismerősöket a törlésről (nem kötelező)\n- profil nevek szóközökkel\n- és még sok más!"; /* No comment provided by engineer. */ -"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyedi üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; +"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; /* No comment provided by engineer. */ ", " = ", "; @@ -59,7 +59,13 @@ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Csillagozás a GitHubon](https://github.com/simplex-chat/simplex-chat)"; /* No comment provided by engineer. */ -"**Create 1-time link**: to create and share a new invitation link." = "**Ismerős hozzáadása:** új meghívó-hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; +"@'%@'" = "@'%@'"; + +/* No comment provided by engineer. */ +"@%@" = "@%@"; + +/* No comment provided by engineer. */ +"**Create 1-time link**: to create and share a new invitation link." = "**Ismerős hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; /* No comment provided by engineer. */ "**Create group**: to create a new group." = "**Csoport létrehozása:** új csoport létrehozásához."; @@ -80,7 +86,7 @@ "**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti."; /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; @@ -104,7 +110,7 @@ "## History" = "## Előzmények"; /* copied message info */ -"## In reply to" = "## Válaszul erre:"; +"## In reply to" = "## Válaszul erre"; /* No comment provided by engineer. */ "#secret#" = "#titok#"; @@ -143,7 +149,7 @@ "%@ is connected!" = "%@ kapcsolódott!"; /* No comment provided by engineer. */ -"%@ is not verified" = "%@ nem hitelesített"; +"%@ is not verified" = "%@ nincs hitelesítve"; /* No comment provided by engineer. */ "%@ is verified" = "%@ hitelesítve"; @@ -221,7 +227,7 @@ "%lld contact(s) selected" = "%lld ismerős kijelölve"; /* No comment provided by engineer. */ -"%lld file(s) with total size of %@" = "%lld fájl, amely(ek)nek teljes mérete: %@"; +"%lld file(s) with total size of %@" = "%lld fájl, %@ összméretben"; /* No comment provided by engineer. */ "%lld group events" = "%lld csoportesemény"; @@ -236,7 +242,7 @@ "%lld messages blocked by admin" = "%lld üzenetet letiltott az adminisztrátor"; /* No comment provided by engineer. */ -"%lld messages marked deleted" = "%lld törlésre megjelölt üzenet"; +"%lld messages marked deleted" = "%lld üzenet megjelölve törlésre"; /* No comment provided by engineer. */ "%lld messages moderated by %@" = "%@ %lld üzenetet moderált"; @@ -248,28 +254,28 @@ "%lld new interface languages" = "%lld új kezelőfelületi nyelv"; /* No comment provided by engineer. */ -"%lld seconds" = "%lld másodperc"; +"%lld seconds" = "%lld mp"; /* No comment provided by engineer. */ -"%lldd" = "%lldd"; +"%lldd" = "%lldn"; /* No comment provided by engineer. */ -"%lldh" = "%lldh"; +"%lldh" = "%lldó"; /* No comment provided by engineer. */ "%lldk" = "%lldk"; /* No comment provided by engineer. */ -"%lldm" = "%lldm"; +"%lldm" = "%lldp"; /* No comment provided by engineer. */ -"%lldmth" = "%lldmth"; +"%lldmth" = "%lldh"; /* No comment provided by engineer. */ -"%llds" = "%llds"; +"%llds" = "%lldmp"; /* No comment provided by engineer. */ -"%lldw" = "%lldw"; +"%lldw" = "%lldhét"; /* No comment provided by engineer. */ "%u messages failed to decrypt." = "%u üzenet visszafejtése sikertelen."; @@ -314,10 +320,10 @@ "1 year" = "1 év"; /* No comment provided by engineer. */ -"1-time link" = "Egyszer használható meghívó-hivatkozás"; +"1-time link" = "Egyszer használható meghívási hivatkozás"; /* No comment provided by engineer. */ -"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívási hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; /* No comment provided by engineer. */ "5 minutes" = "5 perc"; @@ -347,16 +353,16 @@ "Abort" = "Megszakítás"; /* No comment provided by engineer. */ -"Abort changing address" = "Címváltoztatás megszakítása"; +"Abort changing address" = "Cím módosításának megszakítása"; /* No comment provided by engineer. */ -"Abort changing address?" = "Címváltoztatás megszakítása??"; +"Abort changing address?" = "Cím módosításának megszakítása?"; /* No comment provided by engineer. */ "About operators" = "Az üzemeltetőkről"; /* No comment provided by engineer. */ -"About SimpleX Chat" = "SimpleX Chat névjegye"; +"About SimpleX Chat" = "A SimpleX Chat névjegye"; /* No comment provided by engineer. */ "above, then choose:" = "gombra fent, majd válassza ki:"; @@ -380,10 +386,10 @@ /* accept contact request via notification swipe action */ -"Accept incognito" = "Fogadás inkognitóban"; +"Accept incognito" = "Elfogadás inkognitóban"; /* call status */ -"accepted call" = "elfogadott hívás"; +"accepted call" = "fogadott hívás"; /* No comment provided by engineer. */ "Accepted conditions" = "Elfogadott feltételek"; @@ -397,6 +403,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Visszaigazolási hibák"; +/* token status text */ +"Active" = "Aktív"; + /* No comment provided by engineer. */ "Active connections" = "Aktív kapcsolatok száma"; @@ -452,10 +461,10 @@ "Address" = "Cím"; /* No comment provided by engineer. */ -"Address change will be aborted. Old receiving address will be used." = "A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva."; +"Address change will be aborted. Old receiving address will be used." = "A cím módosítása meg fog szakadni. A régi fogadási cím lesz használva."; /* No comment provided by engineer. */ -"Address or 1-time link?" = "Cím vagy egyszer használható meghívó-hivatkozás?"; +"Address or 1-time link?" = "Cím vagy egyszer használható meghívási hivatkozás?"; /* No comment provided by engineer. */ "Address settings" = "Címbeállítások"; @@ -470,7 +479,7 @@ "Admins can block a member for all." = "Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak."; /* No comment provided by engineer. */ -"Admins can create the links to join groups." = "Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való kapcsolódáshoz."; +"Admins can create the links to join groups." = "Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz."; /* No comment provided by engineer. */ "Advanced network settings" = "Speciális hálózati beállítások"; @@ -523,6 +532,9 @@ /* profile dropdown */ "All profiles" = "Összes profil"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Az összes jelentés archiválva lesz az Ön számára."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Az összes ismerősével kapcsolatban marad."; @@ -551,10 +563,10 @@ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra)"; /* No comment provided by engineer. */ -"Allow message reactions only if your contact allows them." = "Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; +"Allow message reactions only if your contact allows them." = "A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; /* No comment provided by engineer. */ -"Allow message reactions." = "Üzenetreakciók engedélyezése."; +"Allow message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van."; /* No comment provided by engineer. */ "Allow sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között engedélyezve van."; @@ -566,16 +578,19 @@ "Allow sharing" = "Megosztás engedélyezése"; /* No comment provided by engineer. */ -"Allow to irreversibly delete sent messages. (24 hours)" = "Elküldött üzenetek végleges törlésének engedélyezése. (24 óra)"; +"Allow to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra)"; /* No comment provided by engineer. */ -"Allow to send files and media." = "Fájlok és médiatartalmak küldésének engedélyezése."; +"Allow to report messsages to moderators." = "Lehetővé teszi az üzenetek jelentését a moderátorok felé."; + +/* No comment provided by engineer. */ +"Allow to send files and media." = "A fájlok- és a médiatartalmak küldése engedélyezve van."; /* No comment provided by engineer. */ "Allow to send SimpleX links." = "A SimpleX-hivatkozások küldése engedélyezve van."; /* No comment provided by engineer. */ -"Allow to send voice messages." = "Hangüzenetek küldésének engedélyezése."; +"Allow to send voice messages." = "A hangüzenetek küldése engedélyezve van."; /* No comment provided by engineer. */ "Allow voice messages only if your contact allows them." = "A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; @@ -584,7 +599,7 @@ "Allow voice messages?" = "Hangüzenetek engedélyezése?"; /* No comment provided by engineer. */ -"Allow your contacts adding message reactions." = "Az üzenetreakciók küldése engedélyezve van az ismerősei számára."; +"Allow your contacts adding message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van az ismerősei számára."; /* No comment provided by engineer. */ "Allow your contacts to call you." = "A hívások kezdeményezése engedélyezve van az ismerősei számára."; @@ -617,7 +632,7 @@ "Always use relay" = "Mindig használjon továbbító-kiszolgálót"; /* No comment provided by engineer. */ -"An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; +"An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; /* No comment provided by engineer. */ "and %lld other events" = "és további %lld esemény"; @@ -673,6 +688,12 @@ /* No comment provided by engineer. */ "Archive" = "Archívum"; +/* No comment provided by engineer. */ +"Archive %lld reports?" = "%lld jelentés archiválása?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Az összes jelentés archiválása?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiválás és feltöltés"; @@ -685,6 +706,9 @@ /* No comment provided by engineer. */ "Archive report?" = "Jelentés archiválása?"; +/* swipe action */ +"Archive reports" = "Jelentések archiválása"; + /* No comment provided by engineer. */ "Archived contacts" = "Archivált ismerősök"; @@ -713,7 +737,7 @@ "audio call (not e2e encrypted)" = "hanghívás (nem e2e titkosított)"; /* chat feature */ -"Audio/video calls" = "Hang-/videóhívások"; +"Audio/video calls" = "Hang- és videóhívások"; /* No comment provided by engineer. */ "Audio/video calls are prohibited." = "A hívások kezdeményezése le van tiltva ebben a csevegésben."; @@ -770,13 +794,13 @@ "Better calls" = "Továbbfejlesztett hívásélmény"; /* No comment provided by engineer. */ -"Better groups" = "Javított csoportok"; +"Better groups" = "Továbbfejlesztett csoportok"; /* No comment provided by engineer. */ "Better message dates." = "Továbbfejlesztett üzenetdátumok."; /* No comment provided by engineer. */ -"Better messages" = "Jobb üzenetek"; +"Better messages" = "Továbbfejlesztett üzenetek"; /* No comment provided by engineer. */ "Better networking" = "Jobb hálózatkezelés"; @@ -797,13 +821,13 @@ "Block" = "Letiltás"; /* No comment provided by engineer. */ -"Block for all" = "Letiltás az összes tag számára"; +"Block for all" = "Letiltás"; /* No comment provided by engineer. */ "Block group members" = "Csoporttagok letiltása"; /* No comment provided by engineer. */ -"Block member" = "Tag letiltása"; +"Block member" = "Letiltás"; /* No comment provided by engineer. */ "Block member for all?" = "Az összes tag számára letiltja ezt a tagot?"; @@ -815,13 +839,13 @@ "blocked" = "letiltva"; /* rcv group event chat item */ -"blocked %@" = "letiltotta %@-t"; +"blocked %@" = "letiltotta őt: %@"; /* marked deleted chat item preview text */ "blocked by admin" = "letiltva az adminisztrátor által"; /* No comment provided by engineer. */ -"Blocked by admin" = "Az adminisztrátor letiltotta"; +"Blocked by admin" = "Letiltva az adminisztrátor által"; /* No comment provided by engineer. */ "Blur for better privacy." = "Elhomályosítás a jobb adatvédelemért."; @@ -833,10 +857,10 @@ "bold" = "félkövér"; /* No comment provided by engineer. */ -"Both you and your contact can add message reactions." = "Mindkét fél is hozzáadhat üzenetreakciókat."; +"Both you and your contact can add message reactions." = "Mindkét fél hozzáadhat az üzenetekhez reakciókat."; /* No comment provided by engineer. */ -"Both you and your contact can irreversibly delete sent messages. (24 hours)" = "Mindkét fél törölheti véglegesen az elküldött üzeneteket. (24 óra)"; +"Both you and your contact can irreversibly delete sent messages. (24 hours)" = "Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra)"; /* No comment provided by engineer. */ "Both you and your contact can make calls." = "Mindkét fél tud hívásokat kezdeményezni."; @@ -927,56 +951,56 @@ "Cellular" = "Mobilhálózat"; /* No comment provided by engineer. */ -"Change" = "Megváltoztatás"; +"Change" = "Módosítás"; /* alert title */ "Change automatic message deletion?" = "Automatikus üzenettörlés módosítása?"; /* authentication reason */ -"Change chat profiles" = "Csevegési profilok megváltoztatása"; +"Change chat profiles" = "Csevegési profilok módosítása"; /* No comment provided by engineer. */ -"Change database passphrase?" = "Az adatbázis jelmondatának megváltoztatása?"; +"Change database passphrase?" = "Az adatbázis jelmondatának módosítása?"; /* authentication reason */ -"Change lock mode" = "Zárolási mód megváltoztatása"; +"Change lock mode" = "Zárolási mód módosítása"; /* No comment provided by engineer. */ -"Change member role?" = "Tag szerepkörének megváltoztatása?"; +"Change member role?" = "Tag szerepkörének módosítása?"; /* authentication reason */ -"Change passcode" = "Jelkód megváltoztatása"; +"Change passcode" = "Jelkód módosítása"; /* No comment provided by engineer. */ -"Change receiving address" = "A fogadócím megváltoztatása"; +"Change receiving address" = "A fogadási cím módosítása"; /* No comment provided by engineer. */ -"Change receiving address?" = "Megváltoztatja a fogadócímet?"; +"Change receiving address?" = "Módosítja a fogadási címet?"; /* No comment provided by engineer. */ -"Change role" = "Szerepkör megváltoztatása"; +"Change role" = "Szerepkör módosítása"; /* authentication reason */ -"Change self-destruct mode" = "Önmegsemmisítő-mód megváltoztatása"; +"Change self-destruct mode" = "Önmegsemmisítő-mód módosítása"; /* authentication reason set passcode view */ -"Change self-destruct passcode" = "Önmegsemmisítő-jelkód megváltoztatása"; +"Change self-destruct passcode" = "Önmegsemmisítő-jelkód módosítása"; /* chat item text */ -"changed address for you" = "megváltoztatta a címet az Ön számára"; +"changed address for you" = "módosította a címet az Ön számára"; /* rcv group event chat item */ -"changed role of %@ to %@" = "%1$@ szerepkörét megváltoztatta a következőre: %2$@"; +"changed role of %@ to %@" = "a következőre módosította %1$@ szerepkörét: „%2$@”"; /* rcv group event chat item */ -"changed your role to %@" = "megváltoztatta az Ön szerepkörét a következőre: %@"; +"changed your role to %@" = "a következőre módosította az Ön szerepkörét: „%@”"; /* chat item text */ -"changing address for %@…" = "cím megváltoztatása %@ számára…"; +"changing address for %@…" = "cím módosítása %@ számára…"; /* chat item text */ -"changing address…" = "cím megváltoztatása…"; +"changing address…" = "cím módosítása…"; /* No comment provided by engineer. */ "Chat" = "Csevegés"; @@ -1021,10 +1045,10 @@ "Chat migrated!" = "A csevegés átköltöztetve!"; /* No comment provided by engineer. */ -"Chat preferences" = "Csevegési beállítások"; +"Chat preferences" = "Csevegés-beállítások"; /* alert message */ -"Chat preferences were changed." = "A csevegési beállítások megváltoztak."; +"Chat preferences were changed." = "A csevegés-beállítások módosultak."; /* No comment provided by engineer. */ "Chat profile" = "Csevegési profil"; @@ -1080,6 +1104,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Üzenetek kiürítése?"; +/* No comment provided by engineer. */ +"Clear group?" = "Csoport kiürítése?"; + /* No comment provided by engineer. */ "Clear or delete group?" = "Csoport kiürítése vagy törlése?"; @@ -1096,13 +1123,13 @@ "Color mode" = "Színmód"; /* No comment provided by engineer. */ -"colored" = "színes"; +"colored" = "színezett"; /* report reason */ "Community guidelines violation" = "Közösségi irányelvek megsértése"; /* server test step */ -"Compare file" = "Fájl összehasonlítás"; +"Compare file" = "Fájl-összehasonlítás"; /* No comment provided by engineer. */ "Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása az ismerősökével."; @@ -1114,7 +1141,7 @@ "Completed" = "Elkészült"; /* No comment provided by engineer. */ -"Conditions accepted on: %@." = "Feltételek elfogadva ekkor: %@."; +"Conditions accepted on: %@." = "Feltételek elfogadva: %@."; /* No comment provided by engineer. */ "Conditions are accepted for the operator(s): **%@**." = "A következő üzemeltető(k) számára elfogadott feltételek: **%@**."; @@ -1126,7 +1153,7 @@ "Conditions of use" = "Használati feltételek"; /* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltető számára."; +"Conditions will be accepted for enabled operators after 30 days." = "A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára."; /* No comment provided by engineer. */ "Conditions will be accepted for operator(s): **%@**." = "A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**."; @@ -1173,6 +1200,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Feltöltés megerősítése"; +/* token status text */ +"Confirmed" = "Megerősítve"; + /* server test step */ "Connect" = "Kapcsolódás"; @@ -1195,7 +1225,7 @@ "Connect to yourself?" = "Kapcsolódás saját magához?"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódás saját magához?\nEz az Ön egyszer használható meghívó-hivatkozása!"; +"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódás saját magához?\nEz az Ön egyszer használható meghívási hivatkozása!"; /* No comment provided by engineer. */ "Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódás saját magához?\nEz az Ön SimpleX-címe!"; @@ -1207,7 +1237,7 @@ "Connect via link" = "Kapcsolódás egy hivatkozáson keresztül"; /* No comment provided by engineer. */ -"Connect via one-time link" = "Kapcsolódás egyszer használható meghívó-hivatkozáson keresztül"; +"Connect via one-time link" = "Kapcsolódás egyszer használható meghívási hivatkozáson keresztül"; /* No comment provided by engineer. */ "Connect with %@" = "Kapcsolódás a következővel: %@"; @@ -1246,7 +1276,7 @@ "connecting (introduced)" = "kapcsolódás (bejelentve)"; /* No comment provided by engineer. */ -"connecting (introduction invitation)" = "kapcsolódás (bemutatkozó-meghívó)"; +"connecting (introduction invitation)" = "kapcsolódás (bemutatkozó meghívó)"; /* call status */ "connecting call" = "kapcsolódási hívás…"; @@ -1318,7 +1348,7 @@ "Connections" = "Kapcsolatok"; /* profile update event chat item */ -"contact %@ changed to %@" = "%1$@ megváltoztatta a nevét a következőre: %2$@"; +"contact %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; /* No comment provided by engineer. */ "Contact allows" = "Ismerős engedélyezi"; @@ -1330,13 +1360,13 @@ "Contact deleted!" = "Ismerős törölve!"; /* No comment provided by engineer. */ -"contact has e2e encryption" = "az ismerősnél az e2e titkosítás elérhető"; +"contact has e2e encryption" = "az ismerős e2e titkosítással rendelkezik"; /* No comment provided by engineer. */ -"contact has no e2e encryption" = "az ismerősnél az e2e titkosítás nem elérhető"; +"contact has no e2e encryption" = "az ismerős nem rendelkezik e2e titkosítással"; /* notification */ -"Contact hidden:" = "Ismerős elrejtve:"; +"Contact hidden:" = "Rejtett név:"; /* notification */ "Contact is connected" = "Ismerőse kapcsolódott"; @@ -1375,7 +1405,7 @@ "Copy error" = "Másolási hiba"; /* No comment provided by engineer. */ -"Core version: v%@" = "Alapverziószám: v%@"; +"Core version: v%@" = "Fő verzió: v%@"; /* No comment provided by engineer. */ "Corner" = "Sarok"; @@ -1387,10 +1417,10 @@ "Create" = "Létrehozás"; /* No comment provided by engineer. */ -"Create 1-time link" = "Egyszer használható meghívó-hivatkozás létrehozása"; +"Create 1-time link" = "Egyszer használható meghívási hivatkozás létrehozása"; /* No comment provided by engineer. */ -"Create a group using a random profile." = "Csoport létrehozása véletlenszerűen létrehozott profillal."; +"Create a group using a random profile." = "Csoport létrehozása véletlenszerű profillal."; /* server test step */ "Create file" = "Fájl létrehozása"; @@ -1414,7 +1444,7 @@ "Create profile" = "Profil létrehozása"; /* server test step */ -"Create queue" = "Sorbaállítás létrehozása"; +"Create queue" = "Sorba állítás létrehozása"; /* No comment provided by engineer. */ "Create secret group" = "Titkos csoport létrehozása"; @@ -1429,10 +1459,10 @@ "Created" = "Létrehozva"; /* No comment provided by engineer. */ -"Created at" = "Létrehozva ekkor:"; +"Created at" = "Létrehozva"; /* copied message info */ -"Created at: %@" = "Létrehozva ekkor: %@"; +"Created at: %@" = "Létrehozva: %@"; /* No comment provided by engineer. */ "Creating archive link" = "Archívum hivatkozás létrehozása"; @@ -1456,16 +1486,16 @@ "Current profile" = "Jelenlegi profil"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Jelenleg a maximális támogatott fájlméret %@."; +"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájl méret: %@."; /* dropdown time picker choice */ -"custom" = "egyedi"; +"custom" = "egyéni"; /* No comment provided by engineer. */ -"Custom time" = "Személyreszabott idő"; +"Custom time" = "Egyéni időköz"; /* No comment provided by engineer. */ -"Customizable message shape." = "Testreszabható üzenetbuborékok."; +"Customizable message shape." = "Testre szabható üzenetbuborékok."; /* No comment provided by engineer. */ "Customize theme" = "Téma személyre szabása"; @@ -1486,7 +1516,7 @@ "Database encryption passphrase will be updated and stored in the keychain.\n" = "Az adatbázis titkosítási jelmondata frissülni fog és a kulcstartóban lesz tárolva.\n"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated.\n" = "Az datbázis titkosítási jelmondata frissülni fog.\n"; +"Database encryption passphrase will be updated.\n" = "Az adatbázis titkosítási jelmondata frissítve lesz.\n"; /* No comment provided by engineer. */ "Database error" = "Adatbázishiba"; @@ -1501,10 +1531,10 @@ "Database IDs and Transport isolation option." = "Adatbázis-azonosítók és átvitel-izolációs 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, ami megváltoztatható."; +"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."; /* No comment provided by engineer. */ -"Database is encrypted using a random passphrase. Please change it before exporting." = "Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg."; +"Database is encrypted using a random passphrase. Please change it before exporting." = "Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt módosítsa."; /* No comment provided by engineer. */ "Database passphrase" = "Adatbázis-jelmondat"; @@ -1563,10 +1593,10 @@ "Delete" = "Törlés"; /* No comment provided by engineer. */ -"Delete %lld messages of members?" = "Tagok %lld üzenetének törlése?"; +"Delete %lld messages of members?" = "Törli a tagok %lld üzenetét?"; /* No comment provided by engineer. */ -"Delete %lld messages?" = "Töröl %lld üzenetet?"; +"Delete %lld messages?" = "%lld üzenet törlése?"; /* No comment provided by engineer. */ "Delete address" = "Cím törlése"; @@ -1575,7 +1605,7 @@ "Delete address?" = "Cím törlése?"; /* No comment provided by engineer. */ -"Delete after" = "Törlés ennyi idő után:"; +"Delete after" = "Törlés ennyi idő után"; /* No comment provided by engineer. */ "Delete all files" = "Az összes fájl törlése"; @@ -1617,7 +1647,7 @@ "Delete file" = "Fájl törlése"; /* No comment provided by engineer. */ -"Delete files and media?" = "Fájlok és a médiatartalmak törlése?"; +"Delete files and media?" = "A fájlok- és a médiatartalmak törlése?"; /* No comment provided by engineer. */ "Delete files for all chat profiles" = "Fájlok törlése az összes csevegési profilból"; @@ -1638,7 +1668,7 @@ "Delete invitation" = "Meghívó törlése"; /* No comment provided by engineer. */ -"Delete link" = "Hivatkozás törlése"; +"Delete link" = "Törlés"; /* No comment provided by engineer. */ "Delete link?" = "Hivatkozás törlése?"; @@ -1656,7 +1686,7 @@ "Delete messages" = "Üzenetek törlése"; /* No comment provided by engineer. */ -"Delete messages after" = "Üzenetek törlése ennyi idő után:"; +"Delete messages after" = "Üzenetek törlése ennyi idő után"; /* No comment provided by engineer. */ "Delete old database" = "Régi adatbázis törlése"; @@ -1674,7 +1704,7 @@ "Delete profile" = "Profil törlése"; /* server test step */ -"Delete queue" = "Sorbaállítás törlése"; +"Delete queue" = "Sorba állítás törlése"; /* No comment provided by engineer. */ "Delete report" = "Jelentés törlése"; @@ -1695,10 +1725,10 @@ "Deleted" = "Törölve"; /* No comment provided by engineer. */ -"Deleted at" = "Törölve ekkor:"; +"Deleted at" = "Törölve"; /* copied message info */ -"Deleted at: %@" = "Törölve ekkor: %@"; +"Deleted at: %@" = "Törölve: %@"; /* rcv direct event chat item */ "deleted contact" = "törölt ismerős"; @@ -1716,7 +1746,7 @@ "Delivery" = "Kézbesítés"; /* No comment provided by engineer. */ -"Delivery receipts are disabled!" = "A kézbesítési jelentések ki vannak kapcsolva!"; +"Delivery receipts are disabled!" = "A kézbesítési jelentések le vannak tiltva!"; /* No comment provided by engineer. */ "Delivery receipts!" = "Kézbesítési jelentések!"; @@ -1728,7 +1758,7 @@ "Desktop address" = "Számítógép címe"; /* No comment provided by engineer. */ -"Desktop app version %@ is not compatible with this app." = "A számítógép-alkalmazás verziója %@ nem kompatibilis ezzel az alkalmazással."; +"Desktop app version %@ is not compatible with this app." = "A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; /* No comment provided by engineer. */ "Desktop devices" = "Számítógépek"; @@ -1737,7 +1767,7 @@ "Destination server address of %@ is incompatible with forwarding server %@ settings." = "A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbító kiszolgáló beállításaival."; /* snd error text */ -"Destination server error: %@" = "Célkiszolgáló hiba: %@"; +"Destination server error: %@" = "Célkiszolgáló-hiba: %@"; /* No comment provided by engineer. */ "Destination server version of %@ is incompatible with forwarding server %@." = "A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbító kiszolgálóval."; @@ -1767,7 +1797,7 @@ "Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "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."; /* No comment provided by engineer. */ -"different migration in the app/database: %@ / %@" = "különböző átköltöztetések az alkalmazásban/adatbázisban: %@ / %@"; +"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ó."; @@ -1782,7 +1812,7 @@ "Direct messages between members are prohibited in this chat." = "A tagok közötti közvetlen üzenetek le vannak tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Direct messages between members are prohibited." = "A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban."; +"Direct messages between members are prohibited." = "A tagok közötti közvetlen üzenetek le vannak tiltva."; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Letiltás (felülírások megtartásával)"; @@ -1794,7 +1824,7 @@ "Disable delete messages" = "Üzenetek törlésének letiltása"; /* No comment provided by engineer. */ -"Disable for all" = "Letiltás az összes tag számára"; +"Disable for all" = "Letiltás"; /* authentication reason */ "Disable SimpleX Lock" = "SimpleX-zár kikapcsolása"; @@ -1815,13 +1845,13 @@ "Disappearing messages are prohibited in this chat." = "Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Disappearing messages are prohibited." = "Az eltűnő üzenetek küldése le van tiltva ebben a csoportban."; +"Disappearing messages are prohibited." = "Az eltűnő üzenetek küldése le van tiltva."; /* No comment provided by engineer. */ -"Disappears at" = "Eltűnik ekkor:"; +"Disappears at" = "Eltűnik"; /* copied message info */ -"Disappears at: %@" = "Eltűnik ekkor: %@"; +"Disappears at: %@" = "Eltűnik: %@"; /* server test step */ "Disconnect" = "Kapcsolat bontása"; @@ -1830,7 +1860,7 @@ "Disconnect desktop?" = "Számítógép leválasztása?"; /* No comment provided by engineer. */ -"Discover and join groups" = "Helyi csoportok felfedezése és csatlakozás"; +"Discover and join groups" = "Csoportok felfedezése és csatlakozás"; /* No comment provided by engineer. */ "Discover via local network" = "Felfedezés helyi hálózaton keresztül"; @@ -1851,7 +1881,7 @@ "Do NOT use private routing." = "NE használjon privát útválasztást."; /* No comment provided by engineer. */ -"Do NOT use SimpleX for emergency calls." = "NE használja a SimpleX-et segélyhívásokhoz."; +"Do NOT use SimpleX for emergency calls." = "NE használja a SimpleXet segélyhívásokhoz."; /* No comment provided by engineer. */ "Documents:" = "Dokumentumok:"; @@ -1975,7 +2005,7 @@ "Enabled" = "Engedélyezve"; /* No comment provided by engineer. */ -"Enabled for" = "Számukra engedélyezve:"; +"Enabled for" = "Számukra engedélyezve"; /* enabled status */ "enabled for contact" = "engedélyezve az ismerős számára"; @@ -2029,22 +2059,22 @@ "encryption ok" = "titkosítás rendben"; /* chat item text */ -"encryption ok for %@" = "titkosítás rendben van %@ számára"; +"encryption ok for %@" = "titkosítás rendben %@ számára"; /* chat item text */ -"encryption re-negotiation allowed" = "titkosítás-újraegyeztetés engedélyezve"; +"encryption re-negotiation allowed" = "a titkosítás újraegyeztetése engedélyezve van"; /* chat item text */ "encryption re-negotiation allowed for %@" = "a titkosítás újraegyeztetése engedélyezve van %@ számára"; /* message decrypt error item */ -"Encryption re-negotiation error" = "Titkosítás-újraegyeztetési hiba"; +"Encryption re-negotiation error" = "Hiba a titkosítás újraegyeztetésekor"; /* No comment provided by engineer. */ -"Encryption re-negotiation failed." = "Sikertelen titkosítás-újraegyeztetés."; +"Encryption re-negotiation failed." = "Nem sikerült a titkosítást újraegyeztetni."; /* chat item text */ -"encryption re-negotiation required" = "titkosítás-újraegyeztetés szükséges"; +"encryption re-negotiation required" = "a titkosítás újraegyeztetése szükséges"; /* chat item text */ "encryption re-negotiation required for %@" = "a titkosítás újraegyeztetése szükséges %@ számára"; @@ -2098,7 +2128,7 @@ "Error" = "Hiba"; /* No comment provided by engineer. */ -"Error aborting address change" = "Hiba a cím megváltoztatásának megszakításakor"; +"Error aborting address change" = "Hiba a cím módosításának megszakításakor"; /* alert title */ "Error accepting conditions" = "Hiba a feltételek elfogadásakor"; @@ -2113,20 +2143,23 @@ "Error adding server" = "Hiba a kiszolgáló hozzáadásakor"; /* No comment provided by engineer. */ -"Error changing address" = "Hiba a cím megváltoztatásakor"; +"Error changing address" = "Hiba a cím módosításakor"; /* No comment provided by engineer. */ "Error changing connection profile" = "Hiba a kapcsolati profilra való váltáskor"; /* No comment provided by engineer. */ -"Error changing role" = "Hiba a szerepkör megváltoztatásakor"; +"Error changing role" = "Hiba a szerepkör módosításakor"; /* No comment provided by engineer. */ -"Error changing setting" = "Hiba a beállítás megváltoztatásakor"; +"Error changing setting" = "Hiba a beállítás módosításakor"; /* No comment provided by engineer. */ "Error changing to incognito!" = "Hiba az inkognitóprofilra való váltáskor!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Hiba a token állapotának ellenőrzésekor"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; @@ -2206,7 +2239,7 @@ "Error loading servers" = "Hiba a kiszolgálók betöltésekor"; /* No comment provided by engineer. */ -"Error migrating settings" = "Hiba a beallítások átköltöztetésekor"; +"Error migrating settings" = "Hiba a beállítások átköltöztetésekor"; /* No comment provided by engineer. */ "Error opening chat" = "Hiba a csevegés megnyitásakor"; @@ -2220,6 +2253,9 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Hiba a kiszolgálókhoz való újrakapcsolódáskor"; +/* alert title */ +"Error registering for notifications" = "Hiba az értesítések regisztrálásakor"; + /* No comment provided by engineer. */ "Error removing member" = "Hiba a tag eltávolításakor"; @@ -2251,7 +2287,7 @@ "Error saving settings" = "Hiba a beállítások mentésekor"; /* No comment provided by engineer. */ -"Error saving user password" = "Hiba a felhasználói jelszó mentésekor"; +"Error saving user password" = "Hiba a felhasználó jelszavának mentésekor"; /* No comment provided by engineer. */ "Error scanning code: %@" = "Hiba a kód beolvasásakor: %@"; @@ -2278,11 +2314,14 @@ "Error switching profile" = "Hiba a profilváltáskor"; /* alertTitle */ -"Error switching profile!" = "Hiba a profilváltásakor!"; +"Error switching profile!" = "Hiba a profilváltáskor!"; /* No comment provided by engineer. */ "Error synchronizing connection" = "Hiba a kapcsolat szinkronizálásakor"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Hiba a kiszolgáló kapcsolatának tesztelésekor"; + /* No comment provided by engineer. */ "Error updating group link" = "Hiba a csoporthivatkozás frissítésekor"; @@ -2337,6 +2376,9 @@ /* No comment provided by engineer. */ "expired" = "lejárt"; +/* token status text */ +"Expired" = "Lejárt"; + /* No comment provided by engineer. */ "Export database" = "Adatbázis exportálása"; @@ -2383,13 +2425,13 @@ "File not found - most likely file was deleted or cancelled." = "A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták."; /* file error text */ -"File server error: %@" = "Fájlkiszolgáló hiba: %@"; +"File server error: %@" = "Fájlkiszolgáló-hiba: %@"; /* No comment provided by engineer. */ -"File status" = "Fájlállapot"; +"File status" = "Fájl állapota"; /* copied message info */ -"File status: %@" = "Fájlállapot: %@"; +"File status: %@" = "Fájl állapota: %@"; /* No comment provided by engineer. */ "File will be deleted from servers." = "A fájl törölve lesz a kiszolgálókról."; @@ -2413,7 +2455,7 @@ "Files and media" = "Fájlok és médiatartalmak"; /* No comment provided by engineer. */ -"Files and media are prohibited." = "A fájlok- és a médiatartalmak le vannak tiltva ebben a csoportban."; +"Files and media are prohibited." = "A fájlok- és a médiatartalmak küldése le van tiltva."; /* No comment provided by engineer. */ "Files and media not allowed" = "A fájlok- és médiatartalmak nincsenek engedélyezve"; @@ -2454,6 +2496,9 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Csoporttag általi javítás nem támogatott"; +/* No comment provided by engineer. */ +"For all moderators" = "Az összes moderátor számára"; + /* No comment provided by engineer. */ "for better metadata privacy." = "a metaadatok jobb védelme érdekében."; @@ -2466,6 +2511,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni."; +/* No comment provided by engineer. */ +"For me" = "Csak magamnak"; + /* No comment provided by engineer. */ "For private routing" = "A privát útválasztáshoz"; @@ -2497,7 +2545,7 @@ "Forwarded" = "Továbbított"; /* No comment provided by engineer. */ -"Forwarded from" = "Továbbítva innen:"; +"Forwarded from" = "Továbbítva innen"; /* No comment provided by engineer. */ "Forwarding %lld messages" = "%lld üzenet továbbítása"; @@ -2512,7 +2560,7 @@ "Forwarding server version is incompatible with network settings: %@." = "A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@."; /* snd error text */ -"Forwarding server: %@\nDestination server error: %@" = "Továbbító-kiszolgáló: %1$@\nCélkiszolgáló hiba:%2$@"; +"Forwarding server: %@\nDestination server error: %@" = "Továbbító-kiszolgáló: %1$@\nCélkiszolgáló-hiba: %2$@"; /* snd error text */ "Forwarding server: %@\nError: %@" = "Továbbító-kiszolgáló: %1$@\nHiba: %2$@"; @@ -2587,7 +2635,7 @@ "Group message:" = "Csoport üzenet:"; /* No comment provided by engineer. */ -"Group moderation" = "Csoport moderáció"; +"Group moderation" = "Csoport moderálása"; /* No comment provided by engineer. */ "Group preferences" = "Csoport-beállítások"; @@ -2662,10 +2710,10 @@ "How to" = "Hogyan"; /* No comment provided by engineer. */ -"How to use it" = "Hogyan használja"; +"How to use it" = "Használati útmutató"; /* No comment provided by engineer. */ -"How to use your servers" = "Saját kiszolgálók használata"; +"How to use your servers" = "Hogyan használja a saját kiszolgálóit"; /* No comment provided by engineer. */ "Hungarian interface" = "Magyar kezelőfelület"; @@ -2698,7 +2746,7 @@ "Immediately" = "Azonnal"; /* No comment provided by engineer. */ -"Immune to spam" = "Spam és visszaélések elleni védelem"; +"Immune to spam" = "Védett a kéretlen tartalommal szemben"; /* No comment provided by engineer. */ "Import" = "Importálás"; @@ -2734,7 +2782,7 @@ "In order to continue, chat should be stopped." = "A folytatáshoz a csevegést meg kell szakítani."; /* No comment provided by engineer. */ -"In reply to" = "Válasz neki"; +"In reply to" = "Válaszul erre"; /* No comment provided by engineer. */ "In-call sounds" = "Bejövő hívás csengőhangja"; @@ -2767,7 +2815,7 @@ "incognito via group link" = "inkognitó a csoporthivatkozáson keresztül"; /* chat list item description */ -"incognito via one-time link" = "inkognitó egy egyszer használható meghívó-hivatkozáson keresztül"; +"incognito via one-time link" = "inkognitó egy egyszer használható meghívási hivatkozáson keresztül"; /* notification */ "Incoming audio call" = "Bejövő hanghívás"; @@ -2803,7 +2851,7 @@ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "A [SimpleX Chat terminálhoz] telepítése (https://github.com/simplex-chat/simplex-chat)"; /* No comment provided by engineer. */ -"Instant" = "Azonnal"; +"Instant" = "Azonnali"; /* No comment provided by engineer. */ "Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések el lesznek rejtve!\n"; @@ -2814,6 +2862,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Kezelőfelület színei"; +/* token status text */ +"Invalid" = "Érvénytelen"; + +/* token status text */ +"Invalid (bad token)" = "Érvénytelen (hibás token)"; + +/* token status text */ +"Invalid (expired)" = "Érvénytelen (lejárt)"; + +/* token status text */ +"Invalid (unregistered)" = "Érvénytelen (nincs regisztrálva)"; + +/* token status text */ +"Invalid (wrong topic)" = "Érvénytelen (rossz topic)"; + /* invalid chat data */ "invalid chat" = "érvénytelen csevegés"; @@ -2887,7 +2950,7 @@ "iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását."; /* No comment provided by engineer. */ "IP address" = "IP-cím"; @@ -2899,7 +2962,7 @@ "Irreversible message deletion is prohibited in this chat." = "Az üzenetek végleges törlése le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Irreversible message deletion is prohibited." = "Az üzenetek végleges törlése le van tiltva ebben a csoportban."; +"Irreversible message deletion is prohibited." = "Az üzenetek végleges törlése le van tiltva."; /* No comment provided by engineer. */ "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Lehetővé teszi, hogy egyetlen csevegőprofilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük."; @@ -2908,7 +2971,7 @@ "It can happen when you or your connection used the old database backup." = "Ez akkor fordulhat elő, ha Ön vagy az ismerőse régi adatbázis biztonsági mentést használt."; /* 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. Az üzenet visszafejtése sikertelen volt, mert vagy az ismerőse 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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy az ismerőse régebbi adatbázis biztonsági mentést használt.\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."; @@ -2929,7 +2992,7 @@ "Join" = "Csatlakozás"; /* No comment provided by engineer. */ -"join as %@" = "csatlakozás mint: %@"; +"join as %@" = "csatlakozás mint %@"; /* No comment provided by engineer. */ "Join group" = "Csatlakozás csoporthoz"; @@ -2953,7 +3016,7 @@ "Joining group" = "Csatlakozás a csoporthoz"; /* alert action */ -"Keep" = "Megtart"; +"Keep" = "Megtartás"; /* No comment provided by engineer. */ "Keep conversation" = "Beszélgetés megtartása"; @@ -3019,7 +3082,7 @@ "List" = "Lista"; /* No comment provided by engineer. */ -"List name and emoji should be different for all lists." = "Az összes lista nevének és emodzsiának különbözőnek kell lennie."; +"List name and emoji should be different for all lists." = "Az összes lista nevének és emodzsijának különbözőnek kell lennie."; /* No comment provided by engineer. */ "List name..." = "Listanév…"; @@ -3052,7 +3115,7 @@ "Make profile private!" = "Tegye priváttá a profilját!"; /* No comment provided by engineer. */ -"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva."; +"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva."; /* No comment provided by engineer. */ "Mark deleted for everyone" = "Jelölje meg az összes tag számára töröltként"; @@ -3073,7 +3136,7 @@ "Max 30 seconds, received instantly." = "Max. 30 másodperc, azonnal érkezett."; /* No comment provided by engineer. */ -"Media & file servers" = "Média és fájlkiszolgálók"; +"Media & file servers" = "Média- és fájlkiszolgálók"; /* blur media */ "Medium" = "Közepes"; @@ -3085,7 +3148,7 @@ "Member" = "Tag"; /* profile update event chat item */ -"member %@ changed to %@" = "%1$@ megváltoztatta a nevét a következőre: %2$@"; +"member %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; /* rcv group event chat item */ "member connected" = "kapcsolódott"; @@ -3093,14 +3156,17 @@ /* item status text */ "Member inactive" = "Inaktív tag"; -/* No comment provided by engineer. */ -"Member role will be changed to \"%@\". All chat members will be notified." = "A tag szerepeköre meg fog változni a következőre: \"%@\". A csevegés tagjai értesítést fognak kapni."; +/* chat feature */ +"Member reports" = "Tagok jelentései"; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre meg fog változni a következőre: „%@”. A csoportban az összes tag értesítve lesz."; +"Member role will be changed to \"%@\". All chat members will be notified." = "A tag szerepköre a következőre fog módosulni: „%@”. A csevegés összes tagja értesítést fog kapni."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre meg fog változni a következőre: „%@”. A tag új meghívást fog kapni."; +"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre a következőre fog módosulni: „%@”. A csoport az összes tagja értesítést fog kapni."; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre a következőre fog módosulni: „%@”. A tag új meghívást fog kapni."; /* No comment provided by engineer. */ "Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; @@ -3109,25 +3175,28 @@ "Member will be removed from group - this cannot be undone!" = "A tag eltávolítása a csoportból – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Members can add message reactions." = "Csoporttagok üzenetreakciókat adhatnak hozzá."; +"Members can add message reactions." = "A tagok reakciókat adhatnak hozzá az üzenetekhez."; /* No comment provided by engineer. */ -"Members can irreversibly delete sent messages. (24 hours)" = "A csoport tagjai véglegesen törölhetik az elküldött üzeneteiket. (24 óra)"; +"Members can irreversibly delete sent messages. (24 hours)" = "A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra)"; /* No comment provided by engineer. */ -"Members can send direct messages." = "A csoport tagjai küldhetnek egymásnak közvetlen üzeneteket."; +"Members can report messsages to moderators." = "A tagok jelenthetik az üzeneteket a moderátorok felé."; /* No comment provided by engineer. */ -"Members can send disappearing messages." = "A csoport tagjai küldhetnek eltűnő üzeneteket."; +"Members can send direct messages." = "A tagok küldhetnek egymásnak közvetlen üzeneteket."; /* No comment provided by engineer. */ -"Members can send files and media." = "A csoport tagjai küldhetnek fájlokat és médiatartalmakat."; +"Members can send disappearing messages." = "A tagok küldhetnek eltűnő üzeneteket."; /* No comment provided by engineer. */ -"Members can send SimpleX links." = "A csoport tagjai küldhetnek SimpleX-hivatkozásokat."; +"Members can send files and media." = "A tagok küldhetnek fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ -"Members can send voice messages." = "A csoport tagjai küldhetnek hangüzeneteket."; +"Members can send SimpleX links." = "A tagok küldhetnek SimpleX-hivatkozásokat."; + +/* No comment provided by engineer. */ +"Members can send voice messages." = "A tagok küldhetnek hangüzeneteket."; /* No comment provided by engineer. */ "Menus" = "Menük"; @@ -3154,16 +3223,16 @@ "Message may be delivered later if member becomes active." = "Az üzenet később is kézbesíthető, ha a tag aktívvá válik."; /* No comment provided by engineer. */ -"Message queue info" = "Üzenet-sorbaállítási információ"; +"Message queue info" = "Üzenetsorbaállítási információ"; /* chat feature */ "Message reactions" = "Üzenetreakciók"; /* No comment provided by engineer. */ -"Message reactions are prohibited in this chat." = "Az üzenetreakciók küldése le van tiltva ebben a csevegésben."; +"Message reactions are prohibited in this chat." = "A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Message reactions are prohibited." = "Az üzenetreakciók küldése le van tiltva ebben a csoportban."; +"Message reactions are prohibited." = "A reakciók hozzáadása az üzenetekhez le van tiltva."; /* notification */ "message received" = "üzenet érkezett"; @@ -3175,16 +3244,16 @@ "Message servers" = "Üzenetkiszolgálók"; /* No comment provided by engineer. */ -"Message shape" = "Üzenetbuborék formája"; +"Message shape" = "Üzenetbuborék alakja"; /* No comment provided by engineer. */ "Message source remains private." = "Az üzenet forrása titokban marad."; /* No comment provided by engineer. */ -"Message status" = "Üzenetállapot"; +"Message status" = "Üzenet állapota"; /* copied message info */ -"Message status: %@" = "Üzenetállapot: %@"; +"Message status: %@" = "Üzenet állapota: %@"; /* No comment provided by engineer. */ "Message text" = "Név és üzenet"; @@ -3199,7 +3268,7 @@ "Messages & files" = "Üzenetek és fájlok"; /* No comment provided by engineer. */ -"Messages from %@ will be shown!" = "A(z) %@ által írt üzenetek megjelennek!"; +"Messages from %@ will be shown!" = "Az összes %@ által írt üzenet meg fog jelenni!"; /* alert message */ "Messages in this chat will never be deleted." = "Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve."; @@ -3268,10 +3337,10 @@ "moderated" = "moderált"; /* No comment provided by engineer. */ -"Moderated at" = "Moderálva ekkor:"; +"Moderated at" = "Moderálva"; /* copied message info */ -"Moderated at: %@" = "Moderálva ekkor: %@"; +"Moderated at: %@" = "Moderálva: %@"; /* marked deleted chat item preview text */ "moderated by %@" = "moderálva lett %@ által"; @@ -3303,6 +3372,9 @@ /* notification label action */ "Mute" = "Némítás"; +/* notification label action */ +"Mute all" = "Összes némítása"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Némítás, ha inaktív!"; @@ -3336,8 +3408,11 @@ /* delete after time */ "never" = "soha"; +/* token status text */ +"New" = "Új"; + /* No comment provided by engineer. */ -"New chat" = "Új beszélgetés"; +"New chat" = "Új csevegés"; /* No comment provided by engineer. */ "New chat experience 🎉" = "Új csevegési élmény 🎉"; @@ -3415,7 +3490,7 @@ "No delivery information" = "Nincs kézbesítési információ"; /* No comment provided by engineer. */ -"No device token!" = "Nincs kiszüléktoken!"; +"No device token!" = "Nincs készüléktoken!"; /* item status description */ "No direct connection yet, message is forwarded by admin." = "Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja."; @@ -3463,10 +3538,10 @@ "No servers for private message routing." = "Nincsenek kiszolgálók a privát üzenet-útválasztáshoz."; /* servers error */ -"No servers to receive files." = "Nincsenek fájlfogadó-kiszolgálók."; +"No servers to receive files." = "Nincsenek fájlfogadási kiszolgálók."; /* servers error */ -"No servers to receive messages." = "Nincsenek üzenetfogadó-kiszolgálók."; +"No servers to receive messages." = "Nincsenek üzenetfogadási kiszolgálók."; /* servers error */ "No servers to send files." = "Nincsenek fájlküldő-kiszolgálók."; @@ -3474,6 +3549,9 @@ /* copied message info in history */ "no text" = "nincs szöveg"; +/* alert title */ +"No token!" = "Nincs token!"; + /* No comment provided by engineer. */ "No unread chats" = "Nincsenek olvasatlan csevegések"; @@ -3498,9 +3576,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Az értesítések le vannak tiltva!"; +/* alert title */ +"Notifications error" = "Értesítési hiba"; + /* No comment provided by engineer. */ "Notifications privacy" = "Értesítési adatvédelem"; +/* alert title */ +"Notifications status" = "Értesítések állapota"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Most már az adminisztrátorok is:\n- törölhetik a tagok üzeneteit.\n- letilthatnak tagokat („megfigyelő” szerepkör)"; @@ -3519,7 +3603,7 @@ "offered %@" = "%@ ajánlotta"; /* feature offered item */ -"offered %@: %@" = "ajánlotta %1$@: %2$@-kor"; +"offered %@: %@" = "ajánlotta: %1$@, ekkor: %2$@"; /* alert button */ "Ok" = "Rendben"; @@ -3534,7 +3618,7 @@ "on" = "bekapcsolva"; /* No comment provided by engineer. */ -"One-time invitation link" = "Egyszer használható meghívó-hivatkozás"; +"One-time invitation link" = "Egyszer használható meghívási hivatkozás"; /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Onion-kiszolgálók **szükségesek** a kapcsolódáshoz.\nKompatibilis VPN szükséges."; @@ -3543,10 +3627,10 @@ "Onion hosts will be used when available.\nRequires compatible VPN." = "Onion-kiszolgálók használata, ha azok rendelkezésre állnak.\nVPN engedélyezése szükséges."; /* No comment provided by engineer. */ -"Onion hosts will not be used." = "Onion-kiszolgálók nem lesznek használva."; +"Onion hosts will not be used." = "Az onion-kiszolgálók nem lesznek használva."; /* No comment provided by engineer. */ -"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai változtathatják meg a beállításokat."; +"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a csevegés-beállításokat."; /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül."; @@ -3555,13 +3639,13 @@ "Only delete conversation" = "Csak a beszélgetés törlése"; /* No comment provided by engineer. */ -"Only group owners can change group preferences." = "Csak a csoport-tulajdonosok változtathatják meg a csoport-beállításokat."; +"Only group owners can change group preferences." = "Csak a csoport tulajdonosai módosíthatják a csoport-beállításokat."; /* No comment provided by engineer. */ -"Only group owners can enable files and media." = "Csak a csoport-tulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését."; +"Only group owners can enable files and media." = "Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését."; /* No comment provided by engineer. */ -"Only group owners can enable voice messages." = "Csak a csoport-tulajdonosok engedélyezhetik a hangüzenetek küldését."; +"Only group owners can enable voice messages." = "Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését."; /* No comment provided by engineer. */ "Only sender and moderators see it" = "Csak a küldő és a moderátorok látják"; @@ -3570,7 +3654,7 @@ "Only you and moderators see it" = "Csak Ön és a moderátorok látják"; /* No comment provided by engineer. */ -"Only you can add message reactions." = "Csak Ön adhat hozzá üzenetreakciókat."; +"Only you can add message reactions." = "Csak Ön adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ "Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti meg őket ). (24 óra)"; @@ -3585,7 +3669,7 @@ "Only you can send voice messages." = "Csak Ön tud hangüzeneteket küldeni."; /* No comment provided by engineer. */ -"Only your contact can add message reactions." = "Csak az ismerőse tud üzenetreakciókat küldeni."; +"Only your contact can add message reactions." = "Csak az ismerőse adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ "Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; @@ -3603,7 +3687,7 @@ "Open" = "Megnyitás"; /* No comment provided by engineer. */ -"Open changes" = "Változások megnyitása"; +"Open changes" = "Módosítások megtekintése"; /* No comment provided by engineer. */ "Open chat" = "Csevegés megnyitása"; @@ -3639,7 +3723,7 @@ "Operator" = "Üzemeltető"; /* alert title */ -"Operator server" = "Kiszolgáló üzemeltető"; +"Operator server" = "Kiszolgáló-üzemeltető"; /* No comment provided by engineer. */ "Or import archive file" = "Vagy archívumfájl importálása"; @@ -3681,13 +3765,13 @@ "Passcode" = "Jelkód"; /* No comment provided by engineer. */ -"Passcode changed!" = "A jelkód megváltozott!"; +"Passcode changed!" = "A jelkód módosult!"; /* No comment provided by engineer. */ "Passcode entry" = "Jelkód bevitele"; /* No comment provided by engineer. */ -"Passcode not changed!" = "A jelkód nem változott meg!"; +"Passcode not changed!" = "A jelkód nem módosult!"; /* No comment provided by engineer. */ "Passcode set!" = "A jelkód beállítva!"; @@ -3696,7 +3780,7 @@ "Password" = "Jelszó"; /* No comment provided by engineer. */ -"Password to show" = "Jelszó megjelenítése"; +"Password to show" = "Jelszó a megjelenítéshez"; /* past/unknown group member */ "Past member %@" = "(Már nem tag) %@"; @@ -3714,13 +3798,13 @@ "Paste the link you received" = "Kapott hivatkozás beillesztése"; /* No comment provided by engineer. */ -"peer-to-peer" = "ponttól-pontig"; +"peer-to-peer" = "egyenrangú"; /* No comment provided by engineer. */ "Pending" = "Függőben"; /* No comment provided by engineer. */ -"Periodic" = "Rendszeresen"; +"Periodic" = "Időszakos"; /* message decrypt error item */ "Permanent decryption error" = "Végleges visszafejtési hiba"; @@ -3783,7 +3867,16 @@ "Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez."; /* No comment provided by engineer. */ -"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni."; +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja módosítani."; + +/* token info */ +"Please try to disable and re-enable notfications." = "Próbálja meg letiltani és újra engedélyezni az értesítéseket."; + +/* token info */ +"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása befejeződik."; + +/* token info */ +"Please wait for token to be registered." = "Várjon a token regisztrálására."; /* No comment provided by engineer. */ "Polish interface" = "Lengyel kezelőfelület"; @@ -3798,7 +3891,7 @@ "Preserve the last message draft, with attachments." = "Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt."; /* No comment provided by engineer. */ -"Preset server address" = "Előre beállított kiszolgáló címe"; +"Preset server address" = "Az előre beállított kiszolgáló címe"; /* No comment provided by engineer. */ "Preset servers" = "Előre beállított kiszolgálók"; @@ -3834,7 +3927,7 @@ "Private routing" = "Privát útválasztás"; /* No comment provided by engineer. */ -"Private routing error" = "Privát útválasztáshiba"; +"Private routing error" = "Privát útválasztási hiba"; /* No comment provided by engineer. */ "Profile and server connections" = "Profil és kiszolgálókapcsolatok"; @@ -3858,13 +3951,16 @@ "Prohibit audio/video calls." = "A hívások kezdeményezése le van tiltva."; /* No comment provided by engineer. */ -"Prohibit irreversible message deletion." = "Az üzenetek véglegesen való törlése le van tiltva."; +"Prohibit irreversible message deletion." = "Az elküldött üzenetek végleges törlése le van tiltva."; /* No comment provided by engineer. */ -"Prohibit message reactions." = "Az üzenetreakciók küldése le van tiltva."; +"Prohibit message reactions." = "A reakciók hozzáadása az üzenethez le van tiltva."; /* No comment provided by engineer. */ -"Prohibit messages reactions." = "Az üzenetreakciók tiltása."; +"Prohibit messages reactions." = "A reakciók hozzáadása az üzenetekhez le van tiltva."; + +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Az üzenetek a moderátorok felé történő jelentésének megtiltása."; /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között le van tiltva."; @@ -3873,7 +3969,7 @@ "Prohibit sending disappearing messages." = "Az eltűnő üzenetek küldése le van tiltva."; /* No comment provided by engineer. */ -"Prohibit sending files and media." = "Fájlok- és a médiatartalmak küldésének letiltása."; +"Prohibit sending files and media." = "A fájlok- és a médiatartalmak küldése le van tiltva."; /* No comment provided by engineer. */ "Prohibit sending SimpleX links." = "A SimpleX-hivatkozások küldése le van tiltva."; @@ -3891,7 +3987,7 @@ "Protect your chat profiles with a password!" = "Védje meg a csevegési profiljait egy jelszóval!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben."; /* No comment provided by engineer. */ "Protocol timeout" = "Protokoll időtúllépése"; @@ -3957,10 +4053,10 @@ "received answer…" = "válasz fogadása…"; /* No comment provided by engineer. */ -"Received at" = "Fogadva ekkor:"; +"Received at" = "Fogadva"; /* copied message info */ -"Received at: %@" = "Fogadva ekkor: %@"; +"Received at: %@" = "Fogadva: %@"; /* No comment provided by engineer. */ "received confirmation…" = "visszaigazolás fogadása…"; @@ -3981,13 +4077,13 @@ "Received total" = "Összes fogadott üzenet"; /* No comment provided by engineer. */ -"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "A fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be."; +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "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."; /* No comment provided by engineer. */ "Receiving file will be stopped." = "A fájl fogadása le fog állni."; /* No comment provided by engineer. */ -"Receiving via" = "Fogadás a"; +"Receiving via" = "Fogadás a következőn keresztül:"; /* No comment provided by engineer. */ "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)." = "Legutóbbi előzmények és továbbfejlesztett [könyvtárbot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)."; @@ -4020,14 +4116,23 @@ "Reconnect servers?" = "Újrakapcsolódás a kiszolgálókhoz?"; /* No comment provided by engineer. */ -"Record updated at" = "A bejegyzés frissítve"; +"Record updated at" = "Bejegyzés frissítve"; /* copied message info */ -"Record updated at: %@" = "A bejegyzés frissítve: %@"; +"Record updated at: %@" = "Bejegyzés frissítve: %@"; /* No comment provided by engineer. */ "Reduced battery usage" = "Csökkentett akkumulátor-használat"; +/* No comment provided by engineer. */ +"Register" = "Regisztrálás"; + +/* token info */ +"Register notification token?" = "Értesítési token regisztrálása?"; + +/* token status text */ +"Registered" = "Regisztrálva"; + /* reject incoming call via notification swipe action */ "Reject" = "Elutasítás"; @@ -4081,7 +4186,7 @@ "removed you" = "eltávolította Önt"; /* No comment provided by engineer. */ -"Renegotiate" = "Újraegyzetetés"; +"Renegotiate" = "Újraegyeztetés"; /* No comment provided by engineer. */ "Renegotiate encryption" = "Titkosítás újraegyeztetése"; @@ -4128,6 +4233,12 @@ /* report reason */ "Report violation: only group moderators will see it." = "Szabálysértés jelentése: csak a csoport moderátorai látják."; +/* report in notification */ +"Report: (text.isEmpty ? reason.text : text)" = "Jelentés: (text.isEmpty ? reason.text : text)"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Az üzenetek jelentése a moderátorok felé le van tiltva."; + /* No comment provided by engineer. */ "Reports" = "Jelentések"; @@ -4268,7 +4379,7 @@ "Saved" = "Mentett"; /* No comment provided by engineer. */ -"Saved from" = "Elmentve innen:"; +"Saved from" = "Elmentve innen"; /* No comment provided by engineer. */ "saved from %@" = "elmentve innen: %@"; @@ -4310,7 +4421,7 @@ "Search" = "Keresés"; /* No comment provided by engineer. */ -"Search bar accepts invitation links." = "A keresősáv elfogadja a meghívó-hivatkozásokat."; +"Search bar accepts invitation links." = "A keresősáv elfogadja a meghívási hivatkozásokat."; /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Keresés vagy SimpleX-hivatkozás beillesztése"; @@ -4328,7 +4439,7 @@ "secret" = "titok"; /* server test step */ -"Secure queue" = "Biztonságos sorbaállítás"; +"Secure queue" = "Biztonságos sorba állítás"; /* No comment provided by engineer. */ "Secured" = "Biztosítva"; @@ -4340,7 +4451,7 @@ "Security code" = "Biztonsági kód"; /* chat item text */ -"security code changed" = "a biztonsági kód megváltozott"; +"security code changed" = "a biztonsági kód módosult"; /* chat item action */ "Select" = "Kijelölés"; @@ -4352,7 +4463,7 @@ "Selected %lld" = "%lld kijelölve"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegés-beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; @@ -4361,7 +4472,7 @@ "Self-destruct passcode" = "Önmegsemmisítő-jelkód"; /* No comment provided by engineer. */ -"Self-destruct passcode changed!" = "Az önmegsemmisítő-jelkód megváltozott!"; +"Self-destruct passcode changed!" = "Az önmegsemmisítő-jelkód módosult!"; /* No comment provided by engineer. */ "Self-destruct passcode enabled!" = "Az önmegsemmisítő-jelkód engedélyezve!"; @@ -4388,7 +4499,7 @@ "Send errors" = "Üzenetküldési hibák"; /* No comment provided by engineer. */ -"Send link previews" = "Hivatkozás előnézetek küldése"; +"Send link previews" = "Hivatkozás előnézete"; /* No comment provided by engineer. */ "Send live message" = "Élő üzenet küldése"; @@ -4406,7 +4517,7 @@ "Send notifications" = "Értesítések küldése"; /* No comment provided by engineer. */ -"Send questions and ideas" = "Ötletek és kérdések beküldése"; +"Send questions and ideas" = "Ötletek és javaslatok"; /* No comment provided by engineer. */ "Send receipts" = "Kézbesítési jelentések küldése"; @@ -4448,10 +4559,10 @@ "Sending via" = "Küldés a következőn keresztül:"; /* No comment provided by engineer. */ -"Sent at" = "Elküldve ekkor:"; +"Sent at" = "Elküldve"; /* copied message info */ -"Sent at: %@" = "Elküldve ekkor: %@"; +"Sent at: %@" = "Elküldve: %@"; /* No comment provided by engineer. */ "Sent directly" = "Közvetlenül küldött"; @@ -4493,22 +4604,22 @@ "Server address is incompatible with network settings." = "A kiszolgáló címe nem kompatibilis a hálózati beállításokkal."; /* alert title */ -"Server operator changed." = "A kiszolgáló üzemeltetője megváltozott."; +"Server operator changed." = "A kiszolgáló üzemeltetője módosult."; /* No comment provided by engineer. */ "Server operators" = "Kiszolgáló-üzemeltetők"; /* alert title */ -"Server protocol changed." = "A kiszolgáló-protokoll megváltozott."; +"Server protocol changed." = "A kiszolgáló-protokoll módosult."; /* queue info */ -"server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló üzenet-sorbaállítási információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; +"server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló sorbaállítási információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "A kiszolgálónak engedélyre van szüksége a sorbaállítás létrehozásához, ellenőrizze jelszavát"; +"Server requires authorization to create queues, check password" = "A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát"; /* server test error */ -"Server requires authorization to upload, check password" = "A kiszolgálónak engedélyre van szüksége a várólisták feltöltéséhez, ellenőrizze jelszavát"; +"Server requires authorization to upload, check password" = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát"; /* No comment provided by engineer. */ "Server test failed!" = "Sikertelen kiszolgáló teszt!"; @@ -4550,10 +4661,10 @@ "Set group preferences" = "Csoport-beállítások megadása"; /* No comment provided by engineer. */ -"Set it instead of system authentication." = "Rendszerhitelesítés helyetti beállítás."; +"Set it instead of system authentication." = "Beállítás a rendszer-hitelesítés helyett."; /* profile update event chat item */ -"set new contact address" = "új kapcsolattartási cím beállítása"; +"set new contact address" = "új kapcsolattartási címet állított be"; /* profile update event chat item */ "set new profile picture" = "új profilképet állított be"; @@ -4568,7 +4679,7 @@ "Set passphrase to export" = "Jelmondat beállítása az exportáláshoz"; /* No comment provided by engineer. */ -"Set the message shown to new members!" = "Megjelenő üzenet beállítása az új tagok számára!"; +"Set the message shown to new members!" = "Megjelenítendő üzenet beállítása az új tagok számára!"; /* No comment provided by engineer. */ "Set timeouts for proxy/VPN" = "Időtúllépések beállítása a proxy/VPN számára"; @@ -4577,7 +4688,7 @@ "Settings" = "Beállítások"; /* alert message */ -"Settings were changed." = "A beállítások megváltoztak."; +"Settings were changed." = "A beállítások módosultak."; /* No comment provided by engineer. */ "Shape profile images" = "Profilkép alakzata"; @@ -4587,10 +4698,10 @@ "Share" = "Megosztás"; /* No comment provided by engineer. */ -"Share 1-time link" = "Egyszer használható hivatkozás megosztása"; +"Share 1-time link" = "Egyszer használható meghívási hivatkozás megosztása"; /* No comment provided by engineer. */ -"Share 1-time link with a friend" = "Egyszer használható meghívó-hivatkozás megosztása egy baráttal"; +"Share 1-time link with a friend" = "Egyszer használható meghívási hivatkozás megosztása egy baráttal"; /* No comment provided by engineer. */ "Share address" = "Cím megosztása"; @@ -4605,7 +4716,7 @@ "Share from other apps." = "Megosztás más alkalmazásokból."; /* No comment provided by engineer. */ -"Share link" = "Hivatkozás megosztása"; +"Share link" = "Megosztás"; /* No comment provided by engineer. */ "Share profile" = "Profil megosztása"; @@ -4614,10 +4725,10 @@ "Share SimpleX address on social media." = "SimpleX-cím megosztása a közösségi médiában."; /* No comment provided by engineer. */ -"Share this 1-time invite link" = "Egyszer használható meghívó-hivatkozás megosztása"; +"Share this 1-time invite link" = "Ennek az egyszer használható meghívási hivatkozásnak a megosztása"; /* No comment provided by engineer. */ -"Share to SimpleX" = "Megosztás a SimpleX-ben"; +"Share to SimpleX" = "Megosztás a SimpleXben"; /* No comment provided by engineer. */ "Share with contacts" = "Megosztás az ismerősökkel"; @@ -4635,7 +4746,7 @@ "Show last messages" = "Legutóbbi üzenet előnézetének megjelenítése"; /* No comment provided by engineer. */ -"Show message status" = "Üzenetállapot megjelenítése"; +"Show message status" = "Üzenet állapotának megjelenítése"; /* No comment provided by engineer. */ "Show percentage" = "Százalék megjelenítése"; @@ -4659,10 +4770,10 @@ "SimpleX Address" = "SimpleX-cím"; /* No comment provided by engineer. */ -"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül."; +"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül."; /* No comment provided by engineer. */ -"SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó-hivatkozás?"; +"SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívási hivatkozás?"; /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba."; @@ -4683,7 +4794,7 @@ "SimpleX links" = "SimpleX-hivatkozások"; /* No comment provided by engineer. */ -"SimpleX links are prohibited." = "A SimpleX-hivatkozások küldése le van tiltva ebben a csoportban."; +"SimpleX links are prohibited." = "A SimpleX-hivatkozások küldése le van tiltva."; /* No comment provided by engineer. */ "SimpleX links not allowed" = "A SimpleX-hivatkozások küldése le van tiltva"; @@ -4701,7 +4812,7 @@ "SimpleX Lock turned on" = "SimpleX-zár bekapcsolva"; /* simplex link type */ -"SimpleX one-time invitation" = "Egyszer használható SimpleX-meghívó-hivatkozás"; +"SimpleX one-time invitation" = "Egyszer használható SimpleX-meghívási hivatkozás"; /* 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."; @@ -4768,7 +4879,7 @@ "Start migration" = "Átköltöztetés indítása"; /* No comment provided by engineer. */ -"Starting from %@." = "Kezdve ettől %@."; +"Starting from %@." = "Statisztikagyűjtés kezdete: %@."; /* No comment provided by engineer. */ "starting…" = "indítás…"; @@ -4837,13 +4948,13 @@ "Switch audio and video during the call." = "Hang/Videó váltása hívás közben."; /* No comment provided by engineer. */ -"Switch chat profile for 1-time invitations." = "Csevegési profilváltás az egyszer használható meghívó-hivatkozásokhoz."; +"Switch chat profile for 1-time invitations." = "Csevegési profilváltás az egyszer használható meghívási hivatkozásokhoz."; /* No comment provided by engineer. */ "System" = "Rendszer"; /* No comment provided by engineer. */ -"System authentication" = "Rendszerhitelesítés"; +"System authentication" = "Rendszer-hitelesítés"; /* No comment provided by engineer. */ "Tail" = "Farok"; @@ -4867,13 +4978,13 @@ "Tap to join" = "Koppintson ide a csatlakozáshoz"; /* No comment provided by engineer. */ -"Tap to join incognito" = "Koppintson ide az inkognitóban való csatlakozáshoz"; +"Tap to join incognito" = "Koppintson ide az inkognitóban való kapcsolódáshoz"; /* No comment provided by engineer. */ "Tap to paste link" = "Koppintson ide a hivatkozás beillesztéséhez"; /* No comment provided by engineer. */ -"Tap to scan" = "Koppintson ide a QR-kód beolvasáshoz"; +"Tap to scan" = "Koppintson ide a QR-kód beolvasásához"; /* No comment provided by engineer. */ "TCP connection" = "TCP kapcsolat"; @@ -4881,6 +4992,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "TCP kapcsolat időtúllépése"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-port az üzenetváltáshoz"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4891,10 +5005,13 @@ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; /* file error alert title */ -"Temporary file error" = "Ideiglenesfájl-hiba"; +"Temporary file error" = "Ideiglenes fájlhiba"; /* server test failure */ -"Test failed at step %@." = "A teszt sikertelen volt a(z) %@ lépésnél."; +"Test failed at step %@." = "A teszt a(z) %@ lépésnél sikertelen volt."; + +/* No comment provided by engineer. */ +"Test notifications" = "Értesítések tesztelése"; /* No comment provided by engineer. */ "Test server" = "Kiszolgáló tesztelése"; @@ -4918,13 +5035,13 @@ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – beállítások megnyitása az engedélyezéshez."; /* No comment provided by engineer. */ -"The app protects your privacy by using different operators in each conversation." = "Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőket használ."; +"The app protects your privacy by using different operators in each conversation." = "Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ."; /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról (kivéve .onion) történő letöltések megerősítését."; /* No comment provided by engineer. */ -"The attempt to change database passphrase was not completed." = "Az adatbázis jelmondatának megváltoztatására tett kísérlet nem fejeződött be."; +"The attempt to change database passphrase was not completed." = "Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be."; /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás."; @@ -4936,7 +5053,7 @@ "The connection you accepted will be cancelled!" = "Az Ön által elfogadott kérelem vissza lesz vonva!"; /* No comment provided by engineer. */ -"The contact you shared this link with will NOT be able to connect!" = "Ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni!"; +"The contact you shared this link with will NOT be able to connect!" = "Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni!"; /* No comment provided by engineer. */ "The created archive is available via app Settings / Database / Old database archive." = "A létrehozott archívum a „Beállítások / Adatbázis / Régi adatbázis-archívum” menüben érhető el."; @@ -4972,13 +5089,10 @@ "The profile is only shared with your contacts." = "A profilja csak az ismerőseivel van megosztva."; /* No comment provided by engineer. */ -"The report will be archived for you." = "A jelentés archiválva lesz az Ön számára."; +"The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**."; /* No comment provided by engineer. */ -"The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**."; - -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k)re is: **%@**."; +"The same conditions will apply to operator(s): **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k) számára is: **%@**."; /* No comment provided by engineer. */ "The second preset operator in the app!" = "A második előre beállított üzemeltető az alkalmazásban!"; @@ -5029,7 +5143,7 @@ "This chat is protected by end-to-end encryption." = "Ez a csevegés végpontok közötti titkosítással védett."; /* E2EE info chat item */ -"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumálló tikosítással védett."; +"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumálló titkosítással védett."; /* notification title */ "this contact" = "ez az ismerős"; @@ -5047,7 +5161,7 @@ "This group no longer exists." = "Ez a csoport már nem létezik."; /* No comment provided by engineer. */ -"This is your own one-time link!" = "Ez az Ön egyszer használható meghívó-hivatkozása!"; +"This is your own one-time link!" = "Ez az Ön egyszer használható meghívási hivatkozása!"; /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Ez az Ön SimpleX-címe!"; @@ -5086,7 +5200,7 @@ "To protect your IP address, private routing uses your SMP servers to deliver messages." = "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."; /* No comment provided by engineer. */ -"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Az adatvédelem érdekében (a más csevegési platformokon megszokott felhasználó-azonosítók helyett) a SimpleX csak az üzenetek sorbaállításához használ azonosítókat, az összes ismerőséhez különbözőt."; +"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához."; /* No comment provided by engineer. */ "To receive" = "A fogadáshoz"; @@ -5101,7 +5215,7 @@ "To record voice message please grant permission to use Microphone." = "Hangüzenet rögzítéséhez adjon engedélyt a mikrofon használathoz."; /* No comment provided by engineer. */ -"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja megjelenítéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben."; +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben."; /* No comment provided by engineer. */ "To send" = "A küldéshez"; @@ -5121,6 +5235,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Inkognitóra váltás kapcsolódáskor."; +/* token status */ +"Token status: %@." = "Token állapota: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Eszköztár átlátszatlansága"; @@ -5128,7 +5245,7 @@ "Total" = "Összes kapcsolat"; /* No comment provided by engineer. */ -"Transport isolation" = "Átvitel-izoláció módja"; +"Transport isolation" = "Átvitel-izoláció"; /* No comment provided by engineer. */ "Transport sessions" = "Munkamenetek átvitele"; @@ -5155,7 +5272,7 @@ "Unblock" = "Feloldás"; /* No comment provided by engineer. */ -"Unblock for all" = "Letiltás feloldása az összes tag számára"; +"Unblock for all" = "Feloldás"; /* No comment provided by engineer. */ "Unblock member" = "Tag feloldása"; @@ -5221,7 +5338,7 @@ "Unlink" = "Szétkapcsolás"; /* No comment provided by engineer. */ -"Unlink desktop?" = "Számítógép szétkapcsolása?"; +"Unlink desktop?" = "Számítógép leválasztása?"; /* No comment provided by engineer. */ "Unlock" = "Feloldás"; @@ -5245,10 +5362,10 @@ "Update" = "Frissítés"; /* No comment provided by engineer. */ -"Update database passphrase" = "Az adatbázis jelmondatának megváltoztatása"; +"Update database passphrase" = "Az adatbázis jelmondatának módosítása"; /* No comment provided by engineer. */ -"Update network settings?" = "Hálózati beállítások megváltoztatása?"; +"Update network settings?" = "Hálózati beállítások módosítása?"; /* No comment provided by engineer. */ "Update settings?" = "Beállítások frissítése?"; @@ -5290,7 +5407,7 @@ "Use %@" = "%@ használata"; /* No comment provided by engineer. */ -"Use chat" = "Simplex Chat használata"; +"Use chat" = "SimpleX Chat használata"; /* No comment provided by engineer. */ "Use current profile" = "Jelenlegi profil használata"; @@ -5334,12 +5451,18 @@ /* No comment provided by engineer. */ "Use SOCKS proxy" = "SOCKS-proxy használata"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "A következő TCP-port használata, amikor nincs port megadva: %@."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Használja az alkalmazást hívás közben."; /* No comment provided by engineer. */ "Use the app with one hand." = "Használja az alkalmazást egy kézzel."; +/* No comment provided by engineer. */ +"Use web port" = "Webport használata"; + /* No comment provided by engineer. */ "User selection" = "Felhasználó kijelölése"; @@ -5386,7 +5509,7 @@ "via group link" = "a csoporthivatkozáson keresztül"; /* chat list item description */ -"via one-time link" = "egy egyszer használható meghívó-hivatkozáson keresztül"; +"via one-time link" = "egy egyszer használható meghívási hivatkozáson keresztül"; /* No comment provided by engineer. */ "via relay" = "egy továbbító-kiszolgálón keresztül"; @@ -5434,13 +5557,13 @@ "Voice messages are prohibited in this chat." = "A hangüzenetek küldése le van tiltva ebben a csevegésben."; /* No comment provided by engineer. */ -"Voice messages are prohibited." = "A hangüzenetek küldése le van tiltva ebben a csoportban."; +"Voice messages are prohibited." = "A hangüzenetek küldése le van tiltva."; /* No comment provided by engineer. */ "Voice messages not allowed" = "A hangüzenetek küldése le van tiltva"; /* No comment provided by engineer. */ -"Voice messages prohibited!" = "A hangüzenetek le vannak tilva!"; +"Voice messages prohibited!" = "A hangüzenetek le vannak tiltva!"; /* No comment provided by engineer. */ "waiting for answer…" = "várakozás a válaszra…"; @@ -5470,7 +5593,7 @@ "wants to connect to you!" = "kapcsolatba akar lépni Önnel!"; /* No comment provided by engineer. */ -"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, továbbá üzenetkézbesítési hibákat okozhat"; +"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, mert üzenetkézbesítési hibákat okoz"; /* No comment provided by engineer. */ "Warning: you may lose some data!" = "Figyelmeztetés: néhány adat elveszhet!"; @@ -5482,7 +5605,7 @@ "weeks" = "hét"; /* No comment provided by engineer. */ -"Welcome %@!" = "Üdvözöllek %@!"; +"Welcome %@!" = "Üdvözöljük %@!"; /* No comment provided by engineer. */ "Welcome message" = "Üdvözlőüzenet"; @@ -5503,7 +5626,7 @@ "when IP hidden" = "ha az IP-cím rejtett"; /* No comment provided by engineer. */ -"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Amikor egynél több hálózati üzemeltető van engedélyezve, egyikük sem rendelkezik olyan metaadatokkal ahhoz, hogy felderítse, ki kommunikál kivel."; +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Amikor egynél több üzemeltető van engedélyezve, akkor egyik sem rendelkezik olyan metaadatokkal, amelyekből megtudható, hogy ki kivel kommunikál."; /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Inkognitóprofil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott."; @@ -5569,16 +5692,16 @@ "You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet."; /* No comment provided by engineer. */ -"You are already connected to %@." = "Ön már kapcsolódva van a következőhöz: %@."; +"You are already connected to %@." = "Ön már kapcsolódott a következőhöz: %@."; /* No comment provided by engineer. */ "You are already connected with %@." = "Ön már kapcsolódva van vele: %@."; /* No comment provided by engineer. */ -"You are already connecting to %@." = "Már folyamatban van a kapcsolódás a következőhöz: %@."; +"You are already connecting to %@." = "A kapcsolódás már folyamatban van a következőhöz: %@."; /* No comment provided by engineer. */ -"You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül!"; +"You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívási hivatkozáson keresztül!"; /* No comment provided by engineer. */ "You are already in group %@." = "Ön már a(z) %@ nevű csoport tagja."; @@ -5593,16 +5716,16 @@ "You are already joining the group via this link." = "A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül."; /* No comment provided by engineer. */ -"You are already joining the group!\nRepeat join request?" = "Csatlakozás folyamatban!\nCsatlakozáskérés megismétlése?"; +"You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nCsatlakozáskérés megismétlése?"; /* No comment provided by engineer. */ "You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ -"you are invited to group" = "meghívást kapott a csoportba"; +"you are invited to group" = "Ön meghívást kapott a csoportba"; /* No comment provided by engineer. */ -"You are invited to group" = "Meghívást kapott a csoportba"; +"You are invited to group" = "Ön meghívást kapott a csoportba"; /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál."; @@ -5617,10 +5740,10 @@ "You can accept calls from lock screen, without device and app authentication." = "Hívásokat fogadhat a lezárási képernyőről, eszköz- és alkalmazás-hitelesítés nélkül."; /* No comment provided by engineer. */ -"You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben változtathatja meg."; +"You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben módosíthatja."; /* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Az üzemeltetőket a „Hálózat és kiszolgálók” beállításaban konfigurálhatja."; +"You can configure operators in Network & servers settings." = "Az üzemeltetőket a „Hálózat és kiszolgálók” menüben konfigurálhatja."; /* No comment provided by engineer. */ "You can configure servers via settings." = "A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja."; @@ -5632,7 +5755,7 @@ "You can enable later via Settings" = "Később engedélyezheti a „Beállításokban”"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Később engedélyezheti őket az alkalmazás „Adatvédelem és biztonság” menüjében."; +"You can enable them later via app Privacy & Security settings." = "Később engedélyezheti őket az „Adatvédelem és biztonság” menüben."; /* No comment provided by engineer. */ "You can give another try." = "Megpróbálhatja még egyszer."; @@ -5659,7 +5782,7 @@ "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." = "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."; /* No comment provided by engineer. */ -"You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a címet az ismerőseivel, hogy kapcsolatba léphessenek Önnel a(z) **%@** nevű profilján keresztül."; +"You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: **%@**."; /* No comment provided by engineer. */ "You can start chat via app Settings / Database or by restarting the app" = "A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el"; @@ -5674,22 +5797,22 @@ "You can use markdown to format messages:" = "Üzenetek formázása a szövegbe szúrt speciális karakterekkel:"; /* alert message */ -"You can view invitation link again in connection details." = "A meghívó-hivatkozást újra megtekintheti a kapcsolat részleteinél."; +"You can view invitation link again in connection details." = "A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél."; /* No comment provided by engineer. */ "You can't send messages!" = "Nem lehet üzeneteket küldeni!"; /* chat item text */ -"you changed address" = "Ön megváltoztatta a címet"; +"you changed address" = "Ön módosította a címet"; /* chat item text */ -"you changed address for %@" = "Ön megváltoztatta a címet %@ számára"; +"you changed address for %@" = "Ön módosította a címet %@ számára"; /* snd group event chat item */ -"you changed role for yourself to %@" = "Ön megváltoztatta a saját szerepkörét a következőre: %@"; +"you changed role for yourself to %@" = "Ön a következőre módosította a saját szerepkörét: „%@”"; /* snd group event chat item */ -"you changed role of %@ to %@" = "Ön megváltoztatta %1$@ szerepkörét a következőre: %@"; +"you changed role of %@ to %@" = "Ön a következőre módosította %1$@ szerepkörét: „%2$@”"; /* No comment provided by engineer. */ "You could not be verified; please try again." = "Nem sikerült hitelesíteni; próbálja meg újra."; @@ -5707,7 +5830,7 @@ "You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva."; /* No comment provided by engineer. */ -"You invited a contact" = "Meghívta egy ismerősét"; +"You invited a contact" = "Ön meghívta egy ismerősét"; /* No comment provided by engineer. */ "You joined this group" = "Ön csatlakozott ehhez a csoporthoz"; @@ -5743,10 +5866,13 @@ "You sent group invitation" = "Csoportmeghívó elküldve"; /* chat list item description */ -"you shared one-time link" = "Ön egy egyszer használható meghívó-hivatkozást osztott meg"; +"you shared one-time link" = "Ön egy egyszer használható meghívási hivatkozást osztott meg"; /* chat list item description */ -"you shared one-time link incognito" = "Ön egy egyszer használható meghívó-hivatkozást osztott meg inkognitóban"; +"you shared one-time link incognito" = "Ön egy egyszer használható meghívási hivatkozást osztott meg inkognitóban"; + +/* token info */ +"You should receive notifications." = "Ön megkapja az értesítéseket."; /* snd group event chat item */ "you unblocked %@" = "Ön feloldotta %@ letiltását"; @@ -5764,7 +5890,7 @@ "You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Az alkalmazás indításakor, vagy 30 másodpercnyi háttérben töltött idő után az alkalmazáshoz visszatérve hitelesítés szükséges."; +"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség."; /* No comment provided by engineer. */ "You will connect to all group members." = "Kapcsolódni fog a csoport összes tagjához."; @@ -5800,7 +5926,7 @@ "Your chat database is not encrypted - set passphrase to encrypt it." = "A csevegési adatbázis nincs titkosítva – adjon meg egy jelmondatot a titkosításhoz."; /* alert title */ -"Your chat preferences" = "Csevegési beállítások"; +"Your chat preferences" = "Az Ön csevegés-beállításai"; /* No comment provided by engineer. */ "Your chat profiles" = "Csevegési profilok"; @@ -5809,7 +5935,7 @@ "Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." = "A kapcsolata át lett helyezve ide: %@, de egy váratlan hiba történt a profilra való átirányításkor."; /* No comment provided by engineer. */ -"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Az ismerőse olyan fájlt küldött, amely meghaladja a jelenleg támogatott maximális méretet (%@)."; +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Az ismerőse a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött."; /* No comment provided by engineer. */ "Your contacts can allow full message deletion." = "Az ismerősei engedélyezhetik a teljes üzenet törlést."; @@ -5845,7 +5971,7 @@ "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 az ismerőseivel 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 megváltozott. Ha elmenti, a profilfrissítés el lesz küldve az Ön ismerősei számára."; +"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 az Ön ismerősei számára."; /* No comment provided by engineer. */ "Your profile, contacts and delivered messages are stored on your device." = "A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index b016babfc1..98854fefac 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -58,6 +58,12 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Dai una stella su GitHub](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"@'%@'" = "@'%@'"; + +/* No comment provided by engineer. */ +"@%@" = "@%@"; + /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Aggiungi contatto**: per creare un nuovo link di invito."; @@ -397,6 +403,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Errori di riconoscimento"; +/* token status text */ +"Active" = "Attivo"; + /* No comment provided by engineer. */ "Active connections" = "Connessioni attive"; @@ -523,6 +532,9 @@ /* profile dropdown */ "All profiles" = "Tutti gli profili"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Tutte le segnalazioni verranno archiviate per te."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Tutti i tuoi contatti resteranno connessi."; @@ -568,6 +580,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Consenti di segnalare messaggi ai moderatori."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Consenti l'invio di file e contenuti multimediali."; @@ -673,6 +688,12 @@ /* No comment provided by engineer. */ "Archive" = "Archivia"; +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiviare %lld segnalazioni?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archiviare tutte le segnalazioni?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archivia e carica"; @@ -685,6 +706,9 @@ /* No comment provided by engineer. */ "Archive report?" = "Archiviare la segnalazione?"; +/* swipe action */ +"Archive reports" = "Archivia segnalazioni"; + /* No comment provided by engineer. */ "Archived contacts" = "Contatti archiviati"; @@ -1080,6 +1104,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Svuotare la conversazione?"; +/* No comment provided by engineer. */ +"Clear group?" = "Svuotare il gruppo?"; + /* No comment provided by engineer. */ "Clear or delete group?" = "Svuotare o eliminare il gruppo?"; @@ -1173,6 +1200,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Conferma caricamento"; +/* token status text */ +"Confirmed" = "Confermato"; + /* server test step */ "Connect" = "Connetti"; @@ -2127,6 +2157,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Errore nel passaggio a incognito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Errore di controllo dello stato del token"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Errore di connessione al server di inoltro %@. Riprova più tardi."; @@ -2220,6 +2253,9 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Errore di riconnessione ai server"; +/* alert title */ +"Error registering for notifications" = "Errore di registrazione per le notifiche"; + /* No comment provided by engineer. */ "Error removing member" = "Errore nella rimozione del membro"; @@ -2283,6 +2319,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Errore nella sincronizzazione della connessione"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Errore provando la connessione al server"; + /* No comment provided by engineer. */ "Error updating group link" = "Errore nell'aggiornamento del link del gruppo"; @@ -2337,6 +2376,9 @@ /* No comment provided by engineer. */ "expired" = "scaduto"; +/* token status text */ +"Expired" = "Scaduto"; + /* No comment provided by engineer. */ "Export database" = "Esporta database"; @@ -2454,6 +2496,9 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Correzione non supportata dal membro del gruppo"; +/* No comment provided by engineer. */ +"For all moderators" = "Per tutti i moderatori"; + /* No comment provided by engineer. */ "for better metadata privacy." = "per una migliore privacy dei metadati."; @@ -2466,6 +2511,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Ad esempio, se il tuo contatto riceve messaggi tramite un server di SimpleX Chat, la tua app li consegnerà tramite un server Flux."; +/* No comment provided by engineer. */ +"For me" = "Per me"; + /* No comment provided by engineer. */ "For private routing" = "Per l'instradamento privato"; @@ -2814,6 +2862,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Colori dell'interfaccia"; +/* token status text */ +"Invalid" = "Non valido"; + +/* token status text */ +"Invalid (bad token)" = "Non valido (token corrotto)"; + +/* token status text */ +"Invalid (expired)" = "Non valido (scaduto)"; + +/* token status text */ +"Invalid (unregistered)" = "Non valido (non registrato)"; + +/* token status text */ +"Invalid (wrong topic)" = "Non valido (argomento sbagliato)"; + /* invalid chat data */ "invalid chat" = "chat non valida"; @@ -3093,6 +3156,9 @@ /* item status text */ "Member inactive" = "Membro inattivo"; +/* chat feature */ +"Member reports" = "Segnalazioni dei membri"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "Il ruolo del membro verrà cambiato in \"%@\". Verranno notificati tutti i membri della chat."; @@ -3114,6 +3180,9 @@ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "I membri del gruppo possono eliminare irreversibilmente i messaggi inviati. (24 ore)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "I membri possono segnalare messaggi ai moderatori."; + /* No comment provided by engineer. */ "Members can send direct messages." = "I membri del gruppo possono inviare messaggi diretti."; @@ -3303,6 +3372,9 @@ /* notification label action */ "Mute" = "Silenzia"; +/* notification label action */ +"Mute all" = "Silenzia tutto"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Silenzioso quando inattivo!"; @@ -3336,6 +3408,9 @@ /* delete after time */ "never" = "mai"; +/* token status text */ +"New" = "Nuovo"; + /* No comment provided by engineer. */ "New chat" = "Nuova chat"; @@ -3474,6 +3549,9 @@ /* copied message info in history */ "no text" = "nessun testo"; +/* alert title */ +"No token!" = "Nessun token!"; + /* No comment provided by engineer. */ "No unread chats" = "Nessuna chat non letta"; @@ -3498,9 +3576,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Le notifiche sono disattivate!"; +/* alert title */ +"Notifications error" = "Errore delle notifiche"; + /* No comment provided by engineer. */ "Notifications privacy" = "Privacy delle notifiche"; +/* alert title */ +"Notifications status" = "Stato delle notifiche"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Ora gli amministratori possono:\n- eliminare i messaggi dei membri.\n- disattivare i membri (ruolo \"osservatore\")"; @@ -3785,6 +3869,15 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Conserva la password in modo sicuro, NON potrai cambiarla se la perdi."; +/* token info */ +"Please try to disable and re-enable notfications." = "Prova a disattivare e riattivare le notifiche."; + +/* token info */ +"Please wait for token activation to complete." = "Attendi il completamento dell'attivazione del token."; + +/* token info */ +"Please wait for token to be registered." = "Attendi la registrazione del token."; + /* No comment provided by engineer. */ "Polish interface" = "Interfaccia polacca"; @@ -3866,6 +3959,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Proibisci le reazioni ai messaggi."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Vieta di segnalare messaggi ai moderatori."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Proibisci l'invio di messaggi diretti ai membri."; @@ -4028,6 +4124,15 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Consumo di batteria ridotto"; +/* No comment provided by engineer. */ +"Register" = "Registra"; + +/* token info */ +"Register notification token?" = "Registrare il token di notifica?"; + +/* token status text */ +"Registered" = "Registrato"; + /* reject incoming call via notification swipe action */ "Reject" = "Rifiuta"; @@ -4128,6 +4233,12 @@ /* report reason */ "Report violation: only group moderators will see it." = "Segnala violazione: solo i moderatori del gruppo lo vedranno."; +/* report in notification */ +"Report: (text.isEmpty ? reason.text : text)" = "Segnalazione: (text.isEmpty ? reason.text : text)"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "È vietato segnalare messaggi ai moderatori."; + /* No comment provided by engineer. */ "Reports" = "Segnalazioni"; @@ -4881,6 +4992,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Scadenza connessione TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Porta TCP per i messaggi"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4896,6 +5010,9 @@ /* server test failure */ "Test failed at step %@." = "Test fallito al passo %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Prova le notifiche"; + /* No comment provided by engineer. */ "Test server" = "Prova server"; @@ -4971,9 +5088,6 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Il profilo è condiviso solo con i tuoi contatti."; -/* No comment provided by engineer. */ -"The report will be archived for you." = "La segnalazione verrà archiviata per te."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Le stesse condizioni si applicheranno all'operatore **%@**."; @@ -5121,6 +5235,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Attiva/disattiva l'incognito quando ti colleghi."; +/* token status */ +"Token status: %@." = "Stato del token: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Opacità barra degli strumenti"; @@ -5334,12 +5451,18 @@ /* No comment provided by engineer. */ "Use SOCKS proxy" = "Usa proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Usa la porta TCP %@ quando non è specificata alcuna porta."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Usa l'app mentre sei in chiamata."; /* No comment provided by engineer. */ "Use the app with one hand." = "Usa l'app con una mano sola."; +/* No comment provided by engineer. */ +"Use web port" = "Usa porta web"; + /* No comment provided by engineer. */ "User selection" = "Selezione utente"; @@ -5748,6 +5871,9 @@ /* chat list item description */ "you shared one-time link incognito" = "hai condiviso un link incognito una tantum"; +/* token info */ +"You should receive notifications." = "Dovresti ricevere le notifiche."; + /* snd group event chat item */ "you unblocked %@" = "hai sbloccato %@"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 0f03f40496..587074589c 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -58,6 +58,12 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"@'%@'" = "@'%@'"; + +/* No comment provided by engineer. */ +"@%@" = "@%@"; + /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Contact toevoegen**: om een nieuwe uitnodigingslink aan te maken, of verbinding te maken via een link die u heeft ontvangen."; @@ -397,6 +403,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Bevestigingsfouten"; +/* token status text */ +"Active" = "actief"; + /* No comment provided by engineer. */ "Active connections" = "Actieve verbindingen"; @@ -523,6 +532,9 @@ /* profile dropdown */ "All profiles" = "Alle profielen"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Alle rapporten worden voor u gearchiveerd."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Al uw contacten blijven verbonden."; @@ -568,6 +580,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Sta toe om verzonden berichten definitief te verwijderen. (24 uur)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Hiermee kunt u berichten rapporteren aan moderators."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Sta toe om bestanden en media te verzenden."; @@ -673,6 +688,12 @@ /* No comment provided by engineer. */ "Archive" = "Archief"; +/* No comment provided by engineer. */ +"Archive %lld reports?" = "%lld rapporten archiveren?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Alle rapporten archiveren?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiveren en uploaden"; @@ -685,6 +706,9 @@ /* No comment provided by engineer. */ "Archive report?" = "Rapport archiveren?"; +/* swipe action */ +"Archive reports" = "Rapporten archiveren"; + /* No comment provided by engineer. */ "Archived contacts" = "Gearchiveerde contacten"; @@ -1080,6 +1104,9 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Gesprek wissen?"; +/* No comment provided by engineer. */ +"Clear group?" = "Groep wissen?"; + /* No comment provided by engineer. */ "Clear or delete group?" = "Groep wissen of verwijderen?"; @@ -1173,6 +1200,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Bevestig het uploaden"; +/* token status text */ +"Confirmed" = "Bevestigd"; + /* server test step */ "Connect" = "Verbind"; @@ -2127,6 +2157,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Fout bij het overschakelen naar incognito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Fout bij het controleren van de tokenstatus"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw."; @@ -2220,6 +2253,9 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Fout bij opnieuw verbinden van servers"; +/* alert title */ +"Error registering for notifications" = "Fout bij registreren voor meldingen"; + /* No comment provided by engineer. */ "Error removing member" = "Fout bij verwijderen van lid"; @@ -2283,6 +2319,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Fout bij het synchroniseren van de verbinding"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Fout bij het testen van de serververbinding"; + /* No comment provided by engineer. */ "Error updating group link" = "Fout bij bijwerken van groep link"; @@ -2337,6 +2376,9 @@ /* No comment provided by engineer. */ "expired" = "verlopen"; +/* token status text */ +"Expired" = "Verlopen"; + /* No comment provided by engineer. */ "Export database" = "Database exporteren"; @@ -2454,6 +2496,9 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Herstel wordt niet ondersteund door groepslid"; +/* No comment provided by engineer. */ +"For all moderators" = "Voor alle moderators"; + /* No comment provided by engineer. */ "for better metadata privacy." = "voor betere privacy van metagegevens."; @@ -2466,6 +2511,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Als uw contactpersoon bijvoorbeeld berichten ontvangt via een SimpleX Chat-server, worden deze door uw app via een Flux-server verzonden."; +/* No comment provided by engineer. */ +"For me" = "Voor mij"; + /* No comment provided by engineer. */ "For private routing" = "Voor privé-routering"; @@ -2814,6 +2862,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Interface kleuren"; +/* token status text */ +"Invalid" = "Ongeldig"; + +/* token status text */ +"Invalid (bad token)" = "Ongeldig (ongeldig token)"; + +/* token status text */ +"Invalid (expired)" = "Ongeldig (verlopen)"; + +/* token status text */ +"Invalid (unregistered)" = "Ongeldig (niet geregistreerd)"; + +/* token status text */ +"Invalid (wrong topic)" = "Ongeldig (verkeerd onderwerp)"; + /* invalid chat data */ "invalid chat" = "ongeldige gesprek"; @@ -3093,6 +3156,9 @@ /* item status text */ "Member inactive" = "Lid inactief"; +/* chat feature */ +"Member reports" = "Ledenrapporten"; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "De rol van het lid wordt gewijzigd naar \"%@\". Alle chatleden worden op de hoogte gebracht."; @@ -3114,6 +3180,9 @@ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Groepsleden kunnen verzonden berichten onherroepelijk verwijderen. (24 uur)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Leden kunnen berichten melden bij moderators."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Groepsleden kunnen directe berichten sturen."; @@ -3303,6 +3372,9 @@ /* notification label action */ "Mute" = "Dempen"; +/* notification label action */ +"Mute all" = "Alles dempen"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Gedempt wanneer inactief!"; @@ -3336,6 +3408,9 @@ /* delete after time */ "never" = "nooit"; +/* token status text */ +"New" = "Nieuw"; + /* No comment provided by engineer. */ "New chat" = "Nieuw gesprek"; @@ -3474,6 +3549,9 @@ /* copied message info in history */ "no text" = "geen tekst"; +/* alert title */ +"No token!" = "Geen token!"; + /* No comment provided by engineer. */ "No unread chats" = "Geen ongelezen chats"; @@ -3498,9 +3576,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Meldingen zijn uitgeschakeld!"; +/* alert title */ +"Notifications error" = "Meldingsfout"; + /* No comment provided by engineer. */ "Notifications privacy" = "Privacy van meldingen"; +/* alert title */ +"Notifications status" = "Meldingsstatus"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Nu kunnen beheerders: \n- berichten van leden verwijderen.\n- schakel leden uit (\"waarnemer\" rol)"; @@ -3785,6 +3869,15 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bewaar het wachtwoord veilig, u kunt deze NIET wijzigen als u het kwijtraakt."; +/* token info */ +"Please try to disable and re-enable notfications." = "Probeer meldingen uit en weer in te schakelen."; + +/* token info */ +"Please wait for token activation to complete." = "Wacht tot de tokenactivering voltooid is."; + +/* token info */ +"Please wait for token to be registered." = "Wacht tot het token is geregistreerd."; + /* No comment provided by engineer. */ "Polish interface" = "Poolse interface"; @@ -3866,6 +3959,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Berichten reacties verbieden."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Het melden van berichten aan moderators is niet toegestaan."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Verbied het sturen van directe berichten naar leden."; @@ -4028,6 +4124,15 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Verminderd batterijgebruik"; +/* No comment provided by engineer. */ +"Register" = "Register"; + +/* token info */ +"Register notification token?" = "Meldingstoken registreren?"; + +/* token status text */ +"Registered" = "Geregistreerd"; + /* reject incoming call via notification swipe action */ "Reject" = "Afwijzen"; @@ -4128,6 +4233,12 @@ /* report reason */ "Report violation: only group moderators will see it." = "Rapporteer overtreding: alleen groepsmoderators kunnen dit zien."; +/* report in notification */ +"Report: (text.isEmpty ? reason.text : text)" = "Rapport: (text.isEmpty ? reason.text : text)"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Het is niet toegestaan om berichten aan moderators te melden."; + /* No comment provided by engineer. */ "Reports" = "Rapporten"; @@ -4556,7 +4667,7 @@ "set new contact address" = "nieuw contactadres instellen"; /* profile update event chat item */ -"set new profile picture" = "nieuwe profielfoto instellen"; +"set new profile picture" = "nieuwe profielfoto"; /* No comment provided by engineer. */ "Set passcode" = "Toegangscode instellen"; @@ -4881,6 +4992,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Timeout van TCP-verbinding"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-poort voor berichtenuitwisseling"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4896,6 +5010,9 @@ /* server test failure */ "Test failed at step %@." = "Test mislukt bij stap %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Testmeldingen"; + /* No comment provided by engineer. */ "Test server" = "Server test"; @@ -4971,9 +5088,6 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Het profiel wordt alleen gedeeld met uw contacten."; -/* No comment provided by engineer. */ -"The report will be archived for you." = "Het rapport wordt voor u gearchiveerd."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dezelfde voorwaarden gelden voor operator **%@**."; @@ -5121,6 +5235,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Schakel incognito in tijdens het verbinden."; +/* token status */ +"Token status: %@." = "Tokenstatus: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "De transparantie van de werkbalk"; @@ -5334,12 +5451,18 @@ /* No comment provided by engineer. */ "Use SOCKS proxy" = "Gebruik SOCKS proxy"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Gebruik TCP-poort %@ als er geen poort is opgegeven."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Gebruik de app tijdens het gesprek."; /* No comment provided by engineer. */ "Use the app with one hand." = "Gebruik de app met één hand."; +/* No comment provided by engineer. */ +"Use web port" = "Gebruik een webpoort"; + /* No comment provided by engineer. */ "User selection" = "Gebruikersselectie"; @@ -5748,6 +5871,9 @@ /* chat list item description */ "you shared one-time link incognito" = "je hebt een eenmalige link incognito gedeeld"; +/* token info */ +"You should receive notifications." = "U zou meldingen moeten ontvangen."; + /* snd group event chat item */ "you unblocked %@" = "je hebt %@ gedeblokkeerd"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 592194f19a..4c88e3dcaf 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -55,6 +55,12 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Daj gwiazdkę na GitHub](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"@'%@'" = "@'%@'"; + +/* No comment provided by engineer. */ +"@%@" = "@%@"; + /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Dodaj kontakt**: aby utworzyć nowy link z zaproszeniem lub połączyć się za pomocą otrzymanego linku."; @@ -391,6 +397,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Błędy potwierdzenia"; +/* token status text */ +"Active" = "Aktywne"; + /* No comment provided by engineer. */ "Active connections" = "Aktywne połączenia"; @@ -502,6 +511,9 @@ /* feature role */ "all members" = "wszyscy członkowie"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Wszystkie wiadomości zostaną usunięte – nie można tego cofnąć!"; @@ -514,6 +526,9 @@ /* profile dropdown */ "All profiles" = "Wszystkie profile"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Wszystkie raporty zostaną dla Ciebie zarchiwizowane."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Wszystkie Twoje kontakty pozostaną połączone."; @@ -559,6 +574,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Zezwól na nieodwracalne usunięcie wysłanych wiadomości. (24 godziny)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Zezwól na zgłaszanie wiadomości moderatorom."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Pozwól na wysyłanie plików i mediów."; @@ -613,6 +631,9 @@ /* No comment provided by engineer. */ "and %lld other events" = "i %lld innych wydarzeń"; +/* report reason */ +"Another reason" = "Inny powód"; + /* No comment provided by engineer. */ "Answer call" = "Odbierz połączenie"; @@ -628,6 +649,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Aplikacja szyfruje nowe lokalne pliki (bez filmów)."; +/* No comment provided by engineer. */ +"App group:" = "Grupa aplikacji:"; + /* No comment provided by engineer. */ "App icon" = "Ikona aplikacji"; @@ -655,18 +679,39 @@ /* No comment provided by engineer. */ "Apply to" = "Zastosuj dla"; +/* No comment provided by engineer. */ +"Archive" = "Archiwizuj"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiwizować %lld reports?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archiwizować wszystkie zgłoszenia?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiwizuj i prześlij"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiwizuj kontakty aby porozmawiać później."; +/* No comment provided by engineer. */ +"Archive report" = "Archiwizuj zgłoszenie"; + +/* No comment provided by engineer. */ +"Archive report?" = "Archiwizować zgłoszenie?"; + +/* swipe action */ +"Archive reports" = "Archiwizuj zgłoszenia"; + /* No comment provided by engineer. */ "Archived contacts" = "Zarchiwizowane kontakty"; /* No comment provided by engineer. */ "Archiving database" = "Archiwizowanie bazy danych"; +/* No comment provided by engineer. */ +"Ask" = "Zapytaj"; + /* No comment provided by engineer. */ "Attach" = "Dołącz"; @@ -5357,6 +5402,9 @@ /* No comment provided by engineer. */ "Your server address" = "Twój adres serwera"; +/* No comment provided by engineer. */ +"Your servers" = "Twoje serwery"; + /* No comment provided by engineer. */ "Your settings" = "Twoje ustawienia"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 7075b9b185..28b2398018 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -174,7 +174,7 @@ تغيير عنوان الاستلام؟ نٌسخت إلى الحافظة مسح - مسح الدردشة + امسح الدردشة إنشاء عنوان الدردشات تأكيد عبارة المرور الجديدة… @@ -196,7 +196,7 @@ إنشاء مجموعة سرية خطأ في إحباط تغيير العنوان تفعيل قفل SimpleX - تأكد من بيانات الاعتماد الخاصة بك + تأكد من بيانات اعتمادك أنشئ عنوان SimpleX متابعة تحدث مع المطورين @@ -308,7 +308,7 @@ تواصل عبر الرابط / رمز QR إنشاء رابط دعوة لمرة واحدة تحقق من عنوان الخادم وحاول مرة أخرى. - مسح التَحَقُّق + امسح التحقُّق أنشئ عنوانًا للسماح للأشخاص بالتواصل معك. أدخل الخادم يدويًا ملون @@ -585,7 +585,7 @@ انتهت صلاحية الدعوة! انضمام إلى المجموعة؟ الانضمام المتخفي - الانضمام إلى المجموعة + جارِ الانضمام إلى المجموعة غادِر مغادرة المجموعة؟ غادر @@ -695,7 +695,7 @@ خطأ في إرسال الرسالة خطأ في الانضمام إلى المجموعة خطأ في مزامنة الاتصال - تسجيل الدخول باستخدام بيانات الاعتماد الخاصة بك + سجّل الدخول باستخدام بيانات اعتمادك خطأ في حفظ الملف علّم غير مقروءة تأكد من أن عناوين خادم WebRTC ICE بالتنسيق الصحيح، وأن تكون مفصولة بأسطر وليست مكررة. @@ -712,7 +712,7 @@ فتح في تطبيق الجوال، ثم انقر فوق اتصال في التطبيق.]]> تحت الإشراف في: %s ردود الفعل الرسائل ممنوعة في هذه الدردشة. - مُشرف بواسطة %s + أُشرف بواسطة %s من المرجح أن جهة الاتصال هذه قد حذفت الاتصال بك. ردود فعل الرسائل مكالمة فائتة @@ -775,8 +775,7 @@ يمكن إرسال 10 فيديوهات فقط في نفس الوقت رابط دعوة لمرة واحدة لا - سوف تكون مضيفات البصل مطلوبة للاتصال. -\nيُرجى ملاحظة: أنك لن تتمكن من الاتصال بالخوادم بدون عنوان onion. + ستكون مضيفات البصل مطلوبة للاتصال. \nيُرجى ملاحظة: أنك لن تتمكن من الاتصال بالخوادم بدون عنوان onion. اسم عرض جديد: عبارة مرور جديدة… قيد الانتظار @@ -905,7 +904,7 @@ منع إرسال الرسائل التي تختفي. الاحتفاظ بمسودة الرسالة الأخيرة، مع المرفقات. أسماء ملفات خاصة - حماية ملفات تعريف الدردشة الخاصة بك بكلمة مرور! + احمِ ملفات تعريف دردشتك بكلمة مرور! رُفضت المكالمة حماية شاشة التطبيق أُزيلت @@ -1098,7 +1097,7 @@ العنوان الرئيسي سيتم وضع علامة على الرسالة على أنها تحت الإشراف لجميع الأعضاء. انقر للانضمام - للكشف عن ملف تعريفك المخفي، أدخل كلمة مرور كاملة في حقل البحث في صفحة ملفات تعريف الدردشة الخاصة بك. + للكشف عن ملف تعريفك المخفي، أدخل كلمة مرور كاملة في حقل البحث في صفحة ملفات تعريف دردشتك. انقر للانضمام إلى وضع التخفي النظام السمات @@ -1142,8 +1141,8 @@ أنت لقد شاركت رابط لمرة واحدة سيتم إرسال ملف التعريفك إلى جهة الاتصال التي استلمت منها هذا الرابط. - سوف تتصل بجميع أعضاء المجموعة. - ملفات تعريف الدردشة الخاصة بك + ستتصل بجميع أعضاء المجموعة. + ملفات تعريف دردشتك عنوان SimpleX الخاص بك خوادم SMP الخاصة بك عندما يكون التطبيق قيد التشغيل @@ -1158,8 +1157,8 @@ تحقق من رمز الأمان رسائل صوتية عندما يطلب الأشخاص الاتصال، يمكنك قبوله أو رفضه. - سوف تكون متصلاً بالمجموعة عندما يكون جهاز مضيف المجموعة متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! - سوف تكون متصلاً عندما يتم قبول طلب اتصالك، يُرجى الانتظار أو التحقق لاحقًا! + ستكون متصلاً بالمجموعة عندما يكون جهاز مضيف المجموعة متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! + ستكون متصلاً عندما يتم قبول طلب اتصالك، يُرجى الانتظار أو التحقق لاحقًا! تستخدم خوادم SimpleX Chat. استخدم وكيل SOCKS استخدم مضيفي onion. @@ -1201,14 +1200,14 @@ عبر رابط لمرة واحدة مكالمة الفيديو ليست مُعمّاة بين الطريفين غيّرتَ العنوان - سوف تكون متصلاً عندما يكون جهاز جهة اتصالك متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! + ستكون متصلاً عندما يكون جهاز جهة اتصالك متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! غادرت - يجب عليك استخدام أحدث إصدار من قاعدة بيانات الدردشة الخاصة بك على جهاز واحد فقط، وإلا فقد تتوقف عن تلقي الرسائل من بعض جهات الاتصال. + يجب عليك استخدام أحدث إصدار من قاعدة بيانات دردشتك على جهاز واحد فقط، وإلا فقد تتوقف عن تلقي الرسائل من بعض جهات الاتصال. سيتم استلام الفيديو عندما تكون جهة اتصالك متصلة بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! يمكنك مشاركة هذا العنوان مع جهات اتصالك للسماح لهم بالاتصال بـ%s. أُزيلت %1$s حدّث - قاعدة بيانات الدردشة الخاصة بك غير مُعمّاة - عيّن عبارة مرور لحمايتها. + قاعدة بيانات دردشتك غير مُعمّاة - عيّن عبارة مرور لحمايتها. عبارة مرور قاعدة بيانات خاطئة سيتم إرسال ملف تعريف الدردشة الخاص بك إلى أعضاء المجموعة مرحبًا! %1$s @@ -1233,7 +1232,7 @@ سيتم حذف قاعدة بيانات الدردشة الحالية واستبدالها بالقاعدة المستوردة. \nلا يمكن التراجع عن هذا الإجراء - سيتم فقد ملف التعريف وجهات الاتصال والرسائل والملفات الخاصة بك بشكل نهائي. تحديث عبارة مرور قاعدة البيانات - سوف تتوقف عن تلقي الرسائل من هذه المجموعة. سيتم الاحتفاظ بسجل الدردشة. + ستتوقف عن تلقي الرسائل من هذه المجموعة. سيتم الاحتفاظ بسجل الدردشة. أسابيع يمكنك إخفاء أو كتم ملف تعريف المستخدم - اضغط مطولاً للقائمة. ما هو الجديد @@ -1258,9 +1257,9 @@ استخدم للاتصالات الجديدة استخدم الخادم عنوان خادمك - قاعدة بيانات الدردشة الخاصة بك + قاعدة بيانات دردشتك أنت مدعو إلى المجموعة. انضم للتواصل مع أعضاء المجموعة. - لقد انضممت إلى هذه المجموعة. الاتصال بدعوة عضو المجموعة. + لقد انضممت إلى هذه المجموعة. جارِ الاتصال بدعوة عضو المجموعة. غيّرتَ العنوان ل%s إلغاء إخفاء ملف تعريف الدردشة الرسائل الصوتية ممنوعة في هذه الدردشة. @@ -1351,7 +1350,7 @@ عيّن عبارة مرور قاعدة البيانات افتح مجلد قاعدة البيانات سيتم تخزين عبارة المرور في الإعدادات كنص عادي بعد تغييرها أو إعادة تشغيل التطبيق. - يُخزين عبارة المرور في الإعدادات كنص عادي. + عبارة المرور مخزنة في الإعدادات كنص عادي. يُرجى الملاحظة: يتم توصيل مرحلات الرسائل والملفات عبر وكيل SOCKS. تستخدم المكالمات وإرسال معاينات الروابط الاتصال المباشر.]]> عَمِّ الملفات المحلية عَمِّ الملفات والوسائط المخزنة @@ -1438,7 +1437,7 @@ مجموعات التخفي %s و%s و%d عضو هذا الجهاز - %1$d من الرسائل يُشرف عليها بواسطة %2$s + %1$d من الرسائل أُشرف عليها بواسطة %2$s إلغاء حظر العضو %s قُطع اتصاله]]> في انتظار سطح المكتب… @@ -2049,7 +2048,7 @@ شارك ملف التعريف استخدم بيانات اعتماد الوكيل المختلفة لكل اتصال. اسم المستخدم - قد يتم إرسال بيانات الاعتماد الخاصة بك غير مُعمَّاة. + قد يتم إرسال بيانات اعتمادك غير مُعمَّاة. خطأ في حفظ الوكيل إزالة الأرشيف؟ وضع النظام @@ -2058,7 +2057,7 @@ استخدم بيانات اعتماد عشوائية قاعدة بيانات الدردشة حُذف %1$d ملف/ات. - لا يزال يتم تنزيل %1$d ملفًا. + لا يزال يتم تنزيل %1$d ملف/ات. لا تستخدم بيانات الاعتماد مع الوكيل. خطأ في تحويل الرسائل خطأ في تبديل ملف التعريف @@ -2146,7 +2145,7 @@ %s.]]> أُضيفت خوادم الوسائط والملفات الشروط المفتوحة - الخوادم الخاصة بالملفات الجديدة لملف الدردشة الحالي الخاص بك + الخوادم الخاصة بالملفات الجديدة لملف دردشتك الحالي لإرسال خطأ في إضافة الخادم خطأ في تحديث الخادم @@ -2172,7 +2171,7 @@ لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: خطأ في قبول الشروط خطأ في حفظ الخوادم - على سبيل المثال، إذا تلقى أحد جهات اتصالك رسائل عبر خادم SimpleX Chat، فسوف يقوم تطبيقك بتسليمها عبر خادم Flux. + على سبيل المثال، إذا تلقى أحد جهات اتصالك رسائل عبر خادم SimpleX Chat، فسيقوم تطبيقك بتسليمها عبر خادم Flux. لا يوجد خوادم لتوجيه الرسائل الخاصة. لا يوجد خوادم رسائل. لا يوجد خوادم لاستقبال الملفات. @@ -2231,7 +2230,7 @@ عندما يتم تفعيل أكثر من مُشغل واحد، لن يكون لدى أي منهم بيانات تعريفية لمعرفة مَن يتواصل مع مَن. سيتم تغيير الدور إلى %s. وسيتم إشعار الجميع في الدردشة. سيتم إرسال ملف تعريفك للدردشة إلى أعضاء الدردشة - سوف تتوقف عن تلقي الرسائل من هذه الدردشة. سيتم حفظ سجل الدردشة. + ستتوقف عن تلقي الرسائل من هذه الدردشة. سيتم حفظ سجل الدردشة. عن المُشغلين توصلت SimpleX Chat وFlux إلى اتفاق لتضمين الخوادم التي تديرها Flux في التطبيق. جارِ إعادة التفاوض على التعمية. @@ -2270,7 +2269,7 @@ تغيير القائمة تغيير الترتيب خطأ في حفظ الإعدادات - خطأ في إنشاء التقرير + خطأ في إنشاء بلاغ أنت والمشرفون فقط هم من يرون ذلك بلاغ مؤرشف لا يراه إلا المُرسِل والمُشرفين @@ -2324,4 +2323,27 @@ اكتم الكل ذّكورات غير مقروءة يمكنك ذكر ما يصل إلى %1$s من الأعضاء في الرسالة الواحدة! + السماح بالإبلاغ عن الرسائل إلى المشرفين. + منع الإبلاغ عن الرسائل للمشرفين. + أرشفة كافة البلاغات؟ + أرشف البلاغات + لكل المشرفين + لي + بلاغ: %s + يمكن للأعضاء الإبلاغ عن الرسائل إلى المشرفين. + سيتم أرشفة كافة البلاغات لك. + أرشفة %d بلاغ؟ + يُمنع الإبلاغ عن الرسائل في هذه المجموعة. + لا تفوت رسائل مهمة. + مساعدة المُدراء على إشراف مجموعاتهم. + أذكر الأعضاء 👋 + نظّم الدردشات في القوائم + أرسل بلاغات خاصة + اضبط انتهاء صلاحية الرسالة في الدردشات. + حذف أسرع من المجموعات. + أسماء ملفات الوسائط خاصة. + استلم إشعارًا عند ذكرك. + أداء مجموعات أفضل + خصوصية وأمان أفضل + إرسال أسرع للرسائل. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index dd2f9a764c..de4042b8f5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -2297,4 +2297,30 @@ Port TCP per a missatgeria Emprar port web Emprar el port TCP %1$s quan no se n\'especifica cap. + Silenciar tot + Mencions no llegides + Podeu mencionar fins a %1$s membres per missatge! + Permetre denunciar missatges a moderació. + No permetre denunciar missatges a moderació. + Arxivar totes les denúncies? + Arxivar %d denúncies? + Arxivar denúncies + Per a totes les moderadores + Per a mi + Denúncia: %s + En aquest grup no es permet denunciar missatges. + Els/les membres poden denunciar els missatges a moderació. + Totes les denúncies s\'arxivaran. + Mencionar els membres 👋 + Enviar denúncies privades + Millor privadesa i seguretat + No us perdeu missatges importants. + Enviament de missatges més ràpid. + Organitzar els xats en llistes + Noms de fitxers multimèdia privats. + Establir la caducitat del missatge als xats. + Rebeu notificació quan se us menciona. + Millor rendiment dels grups + Supressió més ràpida de grups. + Ajudar els administradors a moderar els seus grups. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 3d9b4a4f87..75ecc38515 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -2042,4 +2042,173 @@ Přijmout podmínky Přijaté podmínky Přidat seznam + Vidí to pouze odesílatelé a moderátoři + Vidite to pouze vy a moderátor + archivovaná hlášení + Chyba v konfiguraci serverů. + Chyba při příjímání podmínek + Žádné servery pro odesílání souborů. + Pokračovat + Otevřít změny + Žádné chaty nebyly nalezeny + archivovaná hlášení podle %s + přijmuté pozvání + Porušení pokynů komunity + Nevhodný obsah + Jiný důvod + Chyba změny serverů + Nevhodný profil + Pro profil chatu %s: + Žádné chatovací servery. + Žádné servery pro soukromé směřování chatů. + Žádné servery pro příjem souborů. + Žádné servery pro příjem zpráv. + Všechny chaty budou ze seznamu odebrány %s, a seznam bude smazán + Pro sociální sítě + Vzdálené telefony + %s.]]> + %s.]]> + %s.]]> + Pro soukromé směrování + Otevřít podmínky + Nebo importovat soubor archivu + Obsah porušuje podmínky používání + Spojení blokováno + Připojení je blokováno serverovým operátorem:\n%1$s. + Archivovat hlášení? + Žádná zpráva + Archivovat + Archivovat hlášení + Vymazat hlášení + Soubor je blokován operátorem serveru:\n%1$s. + Nahlásit + Žádné nepřečtené chaty + Žádné chaty + Žádné chaty v seznamu %s. + Kontakty + Oblíbené + Vše + Nahlásit obsah: uvidí ho pouze moderátoři skupiny. + Otevřít pomocí %s + Vytvořit seznam + Přidat prátele + Ztlumit vše + Název seznamu a emoji by mělo být různé pro všechny seznamy. + Změnit seznam + Název seznamu... + Povolit logování + Síťoví operátoři + Otevřít odkaz ze seznamu chatu + Otevřít webový odkaz? + Zeptat se + Ne + Otevřít odkaz + Chyba ukládání databáze + Přidat členy týmu + Chat bude smazán pro všechny členy - tato akce je nevratná! + Chat bude pro vás smazán - tato akce je nevratná! + Opustit chat + Člen bude odstraněn z chatu - tato akce je nevratná! + Připojení není připraveno. + Podmínky budou automaticky přijaty pro povolené operátory dne: %s. + Síťový operátor + %s.]]> + Přednastavené servery + Text aktuálních podmínek se nepodařilo načíst, podmínky si můžete prohlédnout prostřednictvím tohoto odkazu: + Podmínky budou přijaty dne: %s. + %s.]]> + %s.]]> + Přidány servery pro média & soubory + Povolit flux + Servery přes proxy + Soukromí pro vaše zákazníky. + moderátor + Přidány chatovací servery + Operátor + Zakázat automatické mazání zpráv? + Vymazat zprávy chatu z tohoto zařízení. + Zakázat mazání zpráv + %s, přijměte podmínky používání.]]> + Xiaomi zařízení: aby fungovaly notifikace, povolte prosím Autostart v systémovém nastavení.]]> + Zpráva je moc velká! + Zmenšete prosím velikost zprávy a odešlete ji znovu. + Zmenšete prosím velikost zprávy nebo odeberte média a odešlete ji znovu. + pouze s jedním kontaktem - sdílejte osobně nebo přes jakoukoliv chatovací službu.]]> + Zabezpečení připojení + Aplikace vždy poběží na pozadí + Upozornění a baterie + Například, pokud váš kontakt dostane zprávu pres Chat server SimpleX, vaše aplikace ji doručí pomocí Flux serveru. + Chyba načtení seznamů chatu + Chyba vytváření seznamu chatu + Chyba aktualizace seznamu chatů + Firmy + Dosažitelný panel nástrojů chatu + Pozvat do chatu + Pouze vlastníci chatu mohou upravit předvolby. + Chat + Podmínky použití + Chyba přidání serveru + Panel nástrojů aplikace + Rozmazání + Přímé zprávy mezi členy jsou zakázány. + pro lepší ochranu metadat. + Chat již existuje + Přes proxy + Firemní chaty + Vylepšená navigace chatu + - Otevřít chat na první nepřečtené zprávě.\n- Přejít na citované zprávy. + Archivovat všechna hlášení? + Archivovat %d hlášení? + Pro všechny moderátory + Pro mě + %d hlášení + Skupiny + Seznam + Hlášení členů + Poznámky + Zprávy v tomto chatu nebudou nikdy smazány. + Změnit řazení + Vymazat + Vymazat seznam? + Upravit + Chyba ukládání nastavení + Nebo sdílet soukromě + Firemní adresa + Podmínky přijaté dne: %s. + Přímé zprávy mezi členy jsou v tomto chatu zakázány. + Členové mohou zprávy nahlásit moderátorům. + Zakázat nahlašování zpráv moderátorům. + Chyba aktualizace serveru + Operátor server + Decentralizace sítě + Archivována hlášení členů + Archivovat hlášení + Opustit chat? + Nastavení adres + Adresa nebo jednorázový odkaz? + Přidat na seznam + Přidat členy týmu ke konverzaci + Všechna hlášení vám budou archivována + Povolit nahlašování zpráv moderátorům + Změnit automatické mazání zpráv? + Vytvořit jednorázový odkaz + Jak to pomáhá soukromí + Vymazat chat + Vymazat chat? + %s.]]> + %s.]]> + %1$s.]]> + Relace aplikace + Podmínky budou přijaty pro povolené operátory po 30 dnech. + Připojení vyžaduje opětovné vyjednání šifrování. + Probíhá opětovné vyjednávání o šifrování. + Opravit + Opravit připojení? + Nový server + koncovým šifrováním, s post-quantovým zabezpečením v přímých zprávách.]]> + Žádné služba na pozadí + Kontrolovat zprávy každých 10 minut + Chyba vytváření hlášení + 1 rok + výchozí (%s) 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 fc4c421e3b..e79746c8f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -603,9 +603,9 @@ nie Älter als ein Tag Älter als eine Woche - Älter als einen Monat + Älter als ein Monat %s Sekunde(n) - Löschen der Nachrichten + Nachrichten löschen Automatisches Löschen von Nachrichten aktivieren? Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden! Nachrichten löschen @@ -2401,12 +2401,23 @@ Automatisches Löschen von Nachrichten deaktivieren? Löschen von Nachrichten deaktivieren Älter als ein Jahr - Voreinstellung (%s) + Default (%s) Nachrichten in diesem Chat werden nie gelöscht. - Solange kein Port konfiguriert ist, wird der TCP-Port %1$s genutzt. + Solange kein Port konfiguriert ist, wird TCP-Port %1$s genutzt. Web-Port nutzen - TCP-Port für die Nachrichtenübermittlung + TCP-Port für Nachrichtenübermittlung Sie können bis zu %1$s Mitglieder pro Nachricht erwähnen! Alle stummschalten Ungelesene Erwähnungen + Melden von Nachrichten ist in dieser Gruppe nicht erlaubt. + Alle Meldungen archivieren? + Archiviere %d Meldungen? + Für alle Moderatoren + Für mich + Meldungen archivieren + Mitglieder können Nachrichten an Moderatoren melden. + Melden von Nachrichten an Moderatoren nicht erlauben. + Melden von Nachrichten an Moderatoren erlauben. + Alle Meldungen werden für Sie archiviert. + Meldung: %s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 48d9969d06..cce810b10c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -87,7 +87,7 @@ ¿Eliminar contacto\? ¿Eliminar mensaje\? ¿Eliminar perfil? - grupo eliminado + ha eliminado el grupo ¿Eliminar grupo\? Eliminar en Autenticación de dispositivo desactivada. Puedes habilitar Bloqueo SimpleX en Configuración, después de activar la autenticación de dispositivo. @@ -110,7 +110,7 @@ mensaje duplicado Herramientas desarrollo Eliminar los archivos de todos los perfiles - Eliminar mensajes + Activar ¡Base de datos cifrada! La base de datos está cifrada con una contraseña aleatoria, puedes cambiarla. Error en base de datos @@ -169,7 +169,7 @@ conectando llamada… Activar llamadas desde la pantalla de bloqueo en Configuración La contraseña de cifrado de la base de datos será actualizada y almacenada en Keystore. - conectando + conectando... creador %d min Tiempo de conexión agotado @@ -355,7 +355,7 @@ Ignorar Error al eliminar base de datos Base de datos cifrada - Error al eliminar miembro + Error al expulsar miembro Los miembros pueden enviar mensajes de voz. en modo incógnito mediante enlace de dirección del contacto ¡Error al crear perfil! @@ -1474,14 +1474,14 @@ Ordenador encontrado ¡No compatible! Esperando conexión móvil: - Eliminar miembro + Expulsar miembro ¿Desbloquear miembro? Para permitir que la aplicación móvil se conecte al ordenador, abre este puerto en el firewall si está habilitado Usar desde ordenador Código de sesión ¿Repetir solicitud de admisión? Crear perfil de chat - ¿Eliminar miembro? + ¿Expulsar miembro? ¡Ya estás conectando mediante este enlace de un solo uso! Desenlazar El nombre del dispositivo será compartido con el cliente móvil conectado. @@ -2065,8 +2065,7 @@ ¿Reenviar mensajes sin los archivos? Asegúrate de que la configuración del proxy es correcta. %1$d archivo(s) ha(n) sido eliminado(s). - %1$d error(es) de archivo -\n%2$s + %1$d error(es) de archivo:\n%2$s La descarga ha fallado para %1$d archivo(s). %1$d archivo(s) no se ha(n) descargado. %1$s mensajes no enviados @@ -2328,13 +2327,36 @@ ¿Modificar la eliminación automática de mensajes? Elimina los mensajes del dispositivo Los mensajes de esta conversación nunca se borran. - Todos los mensajes previos a la selección serán eliminados del chat. ¡No podrá deshacerse! + Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No podrá deshacerse! Un año predefinido (%s) Puerto TCP para mensajes - Usar puerto TCP %1$s cuando no se especifica. + Se usa el puerto TCP %1$s cuando no se ha especificado otro. Usar puerto web Silenciar todo Menciones sin leer ¡Puedes mencionar hasta %1$s miembros por mensaje! + Los miembros pueden informar de mensajes a los moderadores. + No se permite informar de mensajes a los moderadores. + Se permite informar de mensajes a los moderadores. + Informe: %s + ¿Archivar %d informes? + Archivar informes + Para todos los moderadores + para mí + No se permite informar de mensajes en este grupo. + Todos los informes serán archivados para ti. + ¿Archivar todos los informes? + Las menciones ahora notifican. + Mencionar miembros 👋 + No pierdas los mensajes importantes. + Organiza los chats en listas + Nombres privados de archivos de media. + Eliminación más rápida de grupos. + Envío más rápido de mensajes. + Ayuda a los admins moderar sus grupos. + Mejor rendimiento de los grupos + Mejor privacidad y seguridad + Envia informes privados + Establece la expiración de mensajes en chats. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 376b3e2d36..b8e99587f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -1850,4 +1850,5 @@ \nلطفا هر مشکل دیگری را با توسعه‌دهندگان به اشتراک بگذارید.
این لینک توسط موبایل دیگری استفاده شده است، لطفا لینک جدیدی در کامپیوتر بسازید. خطای سرور پرونده:%1$s + %1$d خطای پرونده:\n%2$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 0721617b2e..6e23eca6b6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -2321,4 +2321,9 @@ Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. Transparence Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. + Autoriser le signalement des messages aux modérateurs. + 1 an + Tous les signalements seront archivés pour vous. + Archiver tous les signalements ? + Archiver %d signalements ? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 7f5b73a1d2..f434feb4cd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -255,7 +255,7 @@ povezivanje Povezan telefon VI - Zamagljenost za bolju privatnost. + Zamućeno za bolju privatnost. %d meseca(i) Poziv završen Poništiti @@ -291,7 +291,7 @@ Blokiraj člana Blokirano administratorom Blokiraj člana? - Zamagljenje + Zamućeno Otkači %d minut(a) Proveri ažuriranje @@ -358,7 +358,7 @@ Onemogućeno za sve Razgovor migriran! %s, %s i %d članovi - Prekinuti + Prekinuti vezu Razgovor već postoji! Baza podataka razgovora odstranjena Proveri ažuriranje @@ -386,7 +386,7 @@ Ili uvesti arhiviranu datoteku Razgovor će biti odstranjen za sve članove - ovo se ne može vratiti! Direktne poruke između članova su zabranjene u ovim porukama. - Prekinuti + Prekinuti vezu %s preuzeto Već ste povezani na %1$s. Glasovne poruke nisu dozvoljenje @@ -704,7 +704,7 @@ Informacije o serveru Popraviti vezu? Arhivirani kontakti - Arhiviraj izveštaj + Arhivirati izveštaj Skenirati QR kod sa radne površine Napustiti razgovor? Dolazni video poziv @@ -925,7 +925,7 @@ Greška: %s Greška u zaustavljanju razgovora Doprinesite - upravio %s + moderirao %s Trenutan Pin kod Čekanje na video Čekanje na dokument @@ -938,7 +938,7 @@ Omogućiti pin kod za samouništenje Pin kod za samouništenje Isključiti - Upravljen u + Moderirano u Vidljiva istorija Prekinuta veza iz razloga: %s Mobilna @@ -1013,7 +1013,7 @@ Promeniti ulogu grupe? Odstraniti datoteku SimpleX Tim - upravljen + moderirano Operator servera blokira vezu:\n%1$s. Promeniti ulogu Ime ovog uređaja @@ -1044,7 +1044,7 @@ Vaš nasumičan profil Molimo pokušajte kasnije. Pošaljite nam email - Upravljen u: %s + Moderirano u: %s Potvrditi otpremanje Popuniti Greške u dešifrovanju @@ -1207,4 +1207,209 @@ Nema poruka Nema istorije Reakcije na poruku su zabranjene. + Bezbedan redosled + Napraviti redosled + Koristiti SOCKS proxy + Greška pri šifrovanju baze podataka + Koristiti za poruke + Greška pri kreiranju profila! + Greška pri učitavanju detalja + Greška pri čuvanju XFTP servera + Otpremljene datoteke + Otvoriti podešavanja servera + Greška pri odstranjivanju privatnih beleški + Greška pri prosleđivanju poruka + Poruke su odstranjene nakon što ste ih odabrali. + NE koristiti privatno usmeravanje. + Kada je IP skrivena + Izgled + Pozvati prijatelje + Potvrditi šifru + Potvrditi pin kod + Odrediti pin kod + Ako unesete Vaš pin kod za samouništenje prilikom otvaranja aplikacije: + Onemogućiti potvrde? + povezivanje (uvedeno) + Uneti šifru u pretragu + Greška pri učitavanju XFTP servera + Greška pri odstranjivanju grupe + Nije moguće poslati poruku + Poslati poruku koja nestaje + Serveri za poruke + Pročitati više + XFTP server + Greška pri pravljenju adrese + neovlašćeno slanje + Verifikovati bezbednostni kod + Dugme zatvoriti + Greška pri pristupanju grupi + Opcije za programere + Alati za programere + Preference razgovora + Potvrdite da su mrežna podešavanja za ovaj uređaj ispravna. + Sadržaj krši uslove korišćenja + Nije moguće primiti datoteku + Greška pri kreiranju poruke + Otvoriti podešavanja aplikacije + Omogućiti SimpleX Zaključavanje + Podeliti datoteku… + Prilagođeno vreme + Povezani serveri + Vaš profil će biti poslan kontaktu od koga ste primili ovu vezu. + Pokrenuti periodično + Preskočene poruke + Pin kod postavljen! + Svi podaci u aplikaciji su odstranjeni. + Pin kod aplikacije je zamenjen pin kodom za samouništenje. + POTPORI SIMPLEX CHAT + Oblik poruke + IKONA APLIKACIJE + Pristupna fraza baze podataka + Odrediti pristupnu frazu + Baza podataka će biti šifrovana. + Neispravna pristupna fraza! + Uneti pristupnu frazu… + Uslovi korišćenja + Greška pri dodavanju servera + Primljen odgovor + Uvećanje + Dozvoliti slanje glasovnih poruka Vašim kontaktima. + Dozvoliti glasovne poruke samo ako ih Vaš kontakt dozvoljava. + Dozvoli svojim kontaktima dodavanje reakcija na poruke. + Dozvoliti slanje direktnih poruka članovima. + Zabraniti slanje direktnih poruka članovima. + Dozvoliti slanje glasovnih poruka. + Dozvoliti slanje SimpleX veza. + SimpleX veze su zabranjene. + Napraviti grupu koristeći nasumičan profil. + Koristiti aplikaciju jednom rukom. + Potvrditi mrežna podešavanja + Greška pri čuvanju baze podataka + Poslan odgovor + Isključiti zvuk svima + Greška pri čuvanju proxy + Ili deliti privatno + Greška pri čuvanju korisničke šifre + Vaše preference + Prethodno povezani serveri + Komadi su preuzeti + Onemogućiti automatsko brisanje poruka? + Hvala Vam što ste instalirali SimpleX Chat! + Neki serveri nisu prošli test: + Mrežni operator + Možete pokušati ponovo. + Poslano putem proxy + pozvan za povezivanje + pomoću %1$s + Greška pri učitavanju SMP servera + Greška pri čuvanju SMP servera + Greška pri promeni adrese + Pristupnu frazu je potrebna + Spomenuti članove 👋 + Bolja privatnost i bezbednost + Ne propustiti bitne poruke. + prilagođen + Otkriti pomoću lokalne mreže + Uneti pristupnu frazu + Proverite internet vezu i pokušajte ponovo + Bez Informacija, pokušajte ponovo da učitate + Komadi su odstranjeni + SimpleX Chat pozivi + XFTP serveri + Mreža i serveri + Podesite ICE servere + Koristiti direktnu internet vezu? + Instalirati ažuriranje + Vaši pozivi + Omogućiti zaključavanje + ID Baze podataka + Zapis ažuriran u + Koristiti za datoteke + Optimizacija baterije je aktivna, isključeni su usluga u pozadini i periodični zahtevi za nove poruke. Možete ih ponovo omogućiti putem podešavanja. + Potvrda identiteta + Test servera nije uspeo! + Greška pri izvoženju baze podataka razgovora + Vremensko ograničenje protokola + Greška pri slanju poruke + Greška pri odstranjivanju kontakta + Napraviti jednokratnu pozivnicu + Markdown pomoć + Dozvoliti slanje datoteka i medijskog sadržaji. + Pin kod promenjen! + ID Baze podataka: %d + SimpleX veze + Dodani serveri za medije i datoteke + Vas profil %1$s će biti deljen. + primljen odgovor… + Mrežni operatori + Privatno usmeravanje + Nevažeća putanja datoteke + Moderirati + %1$d poruke moderirane %2$s + Poruke od %s biće prikazane! + Pristupna fraza baze podataka i izvoz + Koristite privatno usmeravanje sa nepoznatim serverima. + Intenzivno + Poruka će biti označena za ostranjivanje. Primaoci će moći da otkriju ovu poruku. + Privatnost i bezbednost + SimpleX Chat poruke + Možete pokušati ponovo. + Greška pri čuvanju podešavanja + Postavi poruku koja će biti prikazana novim članovima! + Greška pri čuvanju servera + Član će biti uklonjen iz razgovora – ovo se ne može poništiti! + ažuriran profil + povezivanje (najavljeno) + Greška u promeni uloge + Prilagođena tema + Pronaći razgovore brže + Vaša privatnost + Bivši član %1$s + Vremensko ograničenje protokola po KB + Blago + Podeliti medije… + NE šaljite poruke direktno, čak i ako vaš ili odredišni serveri ne podržavaju privatno usmeravanje. + Promeniti pin kod za samouništenje + Ažurirati pristupna frazu baze podataka + Za mene + Podeliti poruku… + Arhivirati izveštaje + SimpleX veze nisu dozvoljene + Promeniti automatsko brisanje poruka? + Glasovne poruke su zabranjene! + Ili prikazati ovaj kod + Koristiti za nove konekcije + Vaši ICE serveri + pokretanje… + Otvoriti podešavanje + Za odvijanje poziva, dozvoliti upotrebu mikrofona. Završite razgovor i pokušajte ponovo. + Baza podataka je šifrovana! + Zapis ažuriran u: %s + Član će biti uklonjen iz grupe – ovo se ne može poništiti! + Pozivi zabranjeni! + \nDostupno u v5.1 + SimpleX veze + Odstraniti za sve + Dozvoliti slanje poruka koje nestaju. + Pin kod aplikacije + Za sakrivanje neželjenih poruka. + Greška pri čuvanju podešavanja + Nevažeće ime! + Komadi su otpremljeni + Greške u otpremanju + Uvek koristiti privatno usmeravanje. + Markdown u porukama + Kada je omogućeno više od jednog operatera, nijedan od njih nema metapodatke da nauči ko komunicira sa kim. + Dozvoliti vašim kontaktima da vas zovu. + Prijem poruke + Greška pri izvoženju baze podataka razgovora + Možete učiniti vidljivim vašim SimpleX kontaktima putem Podešavanja. + Greška pri slanju pozivnice + Preskočiti pozivanje članova + Za privatno usmeravanje + Dodatni sekundarni + Pokretanje iz %s. + Vaši ICE serveri + Koristiti nasumičnu pristupnu frazu + Onemogućiti SimpleX Zaključavanje 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 5cfb9fb8da..9100c029b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -10,18 +10,18 @@ 5 perc 1 perc A SimpleX-címről - Címváltoztatás megszakítása? + Cím módosításának megszakítása? Megszakítás 30 másodperc - Egyszer használható meghívó-hivatkozás + Egyszer használható meghívási hivatkozás %1$s szeretne kapcsolatba lépni Önnel a következőn keresztül: A SimpleX Chat névjegye 1 nap - Címváltoztatás megszakítása + Cím módosításának megszakítása A SimpleXről Kiemelés fogadott hívás - Hozzáférés a kiszolgálókhoz SOCKS-proxyn keresztül a(z) %d porton? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. + Hozzáférés a kiszolgálókhoz SOCKS-proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. Elfogadás Elfogadás gombra fent, majd: @@ -34,7 +34,7 @@ híváshiba Csoporttagok letiltása Hitelesítés - Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik. + Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik. %s visszavonva Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva ebben a csevegésben. @@ -50,7 +50,7 @@ Az adatbázis előkészítése sikertelen Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). - Egy új véletlenszerű profil lesz megosztva. + Egy új, véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. Az alkalmazás összeállítási száma: %s Hang- és videóhívások @@ -74,15 +74,15 @@ HÍVÁSOK és további %d esemény Cím - Csatlakozás folyamatban! + A csatlakozás folyamatban van a csoporthoz! Automatikus elfogadás A háttérszolgáltatás mindig fut – az értesítések megjelennek, amint az üzenetek elérhetővé válnak. Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) Mindkét fél küldhet hangüzeneteket. Téves üzenet ID - Az üzenetreakciók küldése engedélyezve van az ismerősei számára. + A reakciók hozzáadása az üzenetekhez engedélyezve van az ismerősei számára. A hangüzenetek küldése engedélyezve van. - Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. Vissza Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. @@ -91,11 +91,11 @@ Nem lehet meghívni az ismerőst! téves üzenet ID Kapcsolatkérések automatikus elfogadása - Megjegyzés: NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti.]]> + Megjegyzés: NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> hívás… További másodlagos Hozzáadás egy másik eszközhöz - Az üzenetreakciók küldése engedélyezve van. + A reakciók hozzáadása az üzenetekhez engedélyezve van. Fájlelőnézet visszavonása Az összes csoporttag kapcsolatban marad. Több akkumulátort használ! Az alkalmazás mindig fut a háttérben – az értesítések azonnal megjelennek.]]> @@ -112,7 +112,7 @@ hanghívás félkövér Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. - Arab, bulgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. + Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. Hangüzenetek engedélyezése? Mindig használjon továbbító-kiszolgálót mindig @@ -124,20 +124,20 @@ Hang- és videóhívások érvénytelen az üzenet hasítóértéke Mindig fut - Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi az értesítések fogadását. + Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi az értesítések fogadását. Az összes alkalmazásadat törölve. Legjobb akkumulátoridő. Csak akkor kap értesítéseket, amikor az alkalmazás meg van nyitva. (NINCS háttérszolgáltatás.)]]> Megjelenés - Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek rendszeres lekérdezését. A beállításokban újraengedélyezheti. - Biztosan letiltja? + Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. A beállításokban újraengedélyezheti. + Tag letiltása? %1$s hívása befejeződött Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> szerző Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra) Mégse - Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem fog elndúlni + Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem fog elindulni Továbbfejlesztett üzenetek - A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva. + A cím módosítása meg fog szakadni. A régi fogadási cím lesz használva. Engedélyezés Érvénytelen számítógépcím Profil hozzáadása @@ -157,7 +157,7 @@ A közvetlen üzenetek küldése a tagok között engedélyezve van. ALKALMAZÁS Hívás folyamatban - Mindkét fél küldhet üzenetreakciókat. + Mindkét fél hozzáadhat az üzenetekhez reakciókat. Mindkét fél tud hívásokat kezdeményezni. Sikertelen hitelesítés Az összes %s által írt új üzenet el lesz rejtve! @@ -167,29 +167,29 @@ Hang bekapcsolva hanghívás (nem e2e titkosított) letiltva - Az adatbázis jelmondatának megváltoztatása? + Az adatbázis jelmondatának módosítása? kapcsolódva - Jelkód megváltoztatása - %s szerepkörét megváltoztatta a következőre: %s - A fogadócím megváltoztatása - Megváltoztatás + Jelkód módosítása + a következőre módosította %s szerepkörét: „%s” + A fogadási cím módosítása + Módosítás Jelkód megerősítése Jelszó megerősítése - Tag szerepkörének megváltoztatása? - Zárolási mód megváltoztatása + Tag szerepkörének módosítása? + Zárolási mód módosítása Kapcsolódott kapcsolódott Kapcsolódás kapcsolódott Társított hordozható eszköz kapcsolódva - Szerepkör megváltoztatása + Szerepkör módosítása Kapcsolódva Hitelesítőadatok megerősítése - Megváltoztatja a fogadócímet? - megváltoztatta a címet az Ön számára - Önmegsemmisítő-mód megváltoztatása - megváltoztatta az Ön szerepkörét a következőre: %s + Módosítja a fogadási címet? + módosította a címet az Ön számára + Önmegsemmisítő-mód módosítása + a következőre módosította az Ön szerepkörét: „%s” Kapcsolódás Közvetlen kapcsolódás? Kapcsolódás @@ -199,7 +199,7 @@ Csoport létrehozása véletlenszerű profillal. Az ismerős és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. - Kapcsolódás egyszer használható meghívó-hivatkozással? + Kapcsolódás egyszer használható meghívási hivatkozással? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) Csak név @@ -224,7 +224,7 @@ Kapcsolat Kapcsolat megszakítva kapcsolat létrehozva - az ismerősnek nincs e2e titkosítása + az ismerős nem rendelkezik e2e titkosítással Ismerős engedélyezi Rejtett név: Társítás számítógéppel @@ -240,7 +240,7 @@ törölt ismerős Csoporttag üzenetének törlése? A csevegés fut - Egyszer használható meghívó-hivatkozás létrehozása + Egyszer használható meghívási hivatkozás létrehozása Törlés Új üzenetek ellenőrzése 10 percenként, legfeljebb 1 percen keresztül Adatbázis törlése @@ -248,8 +248,8 @@ Csevegési profil Profil létrehozása Társított számítógép - Törölve ekkor: %s - Törölve ekkor: + Törölve: %s + Törölve Kínai és spanyol kezelőfelület Nem lehet meghívni az ismerősöket! A csevegés megállt @@ -258,7 +258,7 @@ törölt csoport Törlés az összes tagnál Hivatkozás létrehozása - Csevegési beállítások + Csevegés-beállítások Profil törlése Jelenlegi jelkód kapcsolódás @@ -271,7 +271,7 @@ Jelenleg támogatott legnagyobb fájl méret: %1$s. Fájl törlése Hamarosan! - cím megváltoztatása %s számára… + cím módosítása %s számára… Csevegési adatbázis importálva Üzenetek törlése Kiürítés @@ -295,14 +295,14 @@ Csoport törlése? Adatbázis fejlesztésének megerősítése Saját profil létrehozása - cím megváltoztatása… + cím módosítása… kapcsolódás… Hívás kapcsolása A fájlok- és a médiatartalmak törlése? befejezett CSEVEGÉSI ADATBÁZIS - Önmegsemmisítő-jelkód megváltoztatása - Sorbaállítás létrehozása + Önmegsemmisítő-jelkód módosítása + Sorba állítás létrehozása színezett kapcsolódás… Sötét téma @@ -311,7 +311,7 @@ Csevegés a fejlesztőkkel Hivatkozás törlése? kapcsolódás - Egyéni időbeállítás + Egyéni időköz Kapcsolódás inkognitóban CSEVEGÉSEK Új profil létrehozása a számítógép alkalmazásban. 💻 @@ -322,9 +322,9 @@ Csoporthivatkozás létrehozása Csevegési konzol Fájlok törlése az összes csevegési profilból - Sorbaállítás törlése + Sorba állítás törlése Ismerős törlése - cím megváltoztatása… + cím módosítása… Társítva a hordozható eszközhöz Jelenlegi jelmondat… Fájl kiválasztása @@ -344,7 +344,7 @@ Üzenetek kiürítése? Adatbázis visszafejlesztése Üzenetek kiürítése - Az datbázis titkosítási jelmondata frissítve lesz. + Az adatbázis titkosítási jelmondata frissítve lesz. Kapcsolódás automatikusan Adatbázishiba Az adatbázis titkosítási jelmondata frissülni fog és a beállításokban lesz tárolva. @@ -367,7 +367,7 @@ 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 - Eltűnik ekkor: %s + Eltűnik: %s szerkesztve Törlés %d óra @@ -377,12 +377,12 @@ Az adatbázis-jelmondat eltér a Keystore-ban lévőtől. Közvetlen üzenetek E-mail - Letiltás az összes tag számára + Letiltás Fejlesztői eszközök Adatbázis-jelmondat %d nap Kapcsolat bontva - Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet megváltoztathat. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet szabadon módosíthat. %dó %dhét Felfedezés helyi hálózaton keresztül @@ -391,7 +391,7 @@ Eltűnő üzenet Ne hozzon létre címet Ne mutasd újra - SimpleX zár kikapcsolása + SimpleX-zár kikapcsolása e2e titkosított ESZKÖZ e2e titkosított videóhívás @@ -403,7 +403,7 @@ %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. %d perc - Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt módosítsa. Kézbesítés jelentések letiltása a csoportok számára? nap %d nap @@ -411,14 +411,14 @@ Letiltás (felülírások megtartásával) Adatbázis fejlesztése %d üzenet letiltva - Eltűnik ekkor: + Eltűnik %d hét engedélyezve az Ön számára Eltűnő üzenetek Törlés Törlés, és az ismerős értesítése letiltva - %d másodperc + %d mp Az összes fájl törlése Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás @@ -448,9 +448,9 @@ alapértelmezett (%s) duplikált üzenet Számítógép leválasztása? - Számítógép-alkalmazás verziója %s nem kompatibilis ezzel az alkalmazással. + A számítógép-alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. Kézbesítés - %d fájl %s összméretben + %d fájl, %s összméretben A csevegés megnyitásához adja meg az adatbázis jelmondatát. %dnap Engedélyezés az összes tag számára @@ -459,7 +459,7 @@ Hiba az üzenet küldésekor Adja meg a jelkódot Mindenkinél - Titkosítás-újraegyeztetési hiba + Hiba a titkosítás újraegyeztetésekor Hiba az adatbázis titkosításakor Hiba a csoport törlésekor Kilépés mentés nélkül @@ -483,7 +483,7 @@ Helyi fájlok titkosítása titkosítás elfogadva %s számára %d üzenet megjelölve törlésre - titkosítás újra egyeztetése engedélyezve + a titkosítás újraegyeztetése engedélyezve van Önmegsemmisítés engedélyezése Olvasatlan és kedvenc csevegésekre való szűrés. A csevegések betöltése sikertelen @@ -503,7 +503,7 @@ Gyors és nem kell várni, amíg a feladó online lesz! Hiba a csoporthoz való csatlakozáskor Kedvenc - Csoport moderáció + Csoport moderálása Fájl Csoporthivatkozás a titkosítás újraegyeztetése szükséges %s számára @@ -547,7 +547,7 @@ Engedélyezés (csoport felülírások megtartásával) Hiba az ismerős törlésekor A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) - Hiba a szerepkör megváltoztatásakor + Hiba a szerepkör módosításakor Javítás A tagok küldhetnek eltűnő üzeneteket. Kapcsolat javítása @@ -555,8 +555,8 @@ Hiba a tag(ok) hozzáadásakor Fájl A tagok küldhetnek fájlokat és médiatartalmakat. - Törlés ennyi idő után: - Hiba a beállítás megváltoztatásakor + Törlés ennyi idő után + Hiba a beállítás módosításakor Hiba a csoporthivatkozás frissítésekor a csoport törölve csoportprofil frissítve @@ -580,7 +580,7 @@ súgó Önmegsemmisítő-jelkód engedélyezése KÍSÉRLETI - Hiba a cím megváltoztatásának megszakításakor + Hiba a cím módosításának megszakításakor Hiba a fájl fogadásakor titkosítás rendben Hiba a kapcsolatkérés törlésekor @@ -598,7 +598,7 @@ Titkosítás javítása az adatmentések helyreállítása után. Hiba a csevegési adatbázis törlésekor Teljes hivatkozás - Hiba a cím megváltoztatásakor + Hiba a cím módosításakor A tagok küldhetnek hangüzeneteket. Csoport-beállítások Hiba: %s @@ -610,7 +610,7 @@ Hiba a részletek betöltésekor Hiba történt a kapcsolatkérés elfogadásakor a titkosítás újraegyeztetése engedélyezve van %s számára - titkosítás-újraegyeztetés szükséges + a titkosítás újraegyeztetése szükséges Rejtett csevegési profilok Fájlok és médiatartalmak A kép elmentve a „Galériába” @@ -618,7 +618,7 @@ Azonnal A fájlok- és a médiatartalmak küldése le van tiltva! Profil elrejtése - Kiszolgálók használata + Hogyan használja a saját kiszolgálóit Csevegési üzenetek gyorsabb megtalálása Téma importálása Hiba a téma importálásakor @@ -661,20 +661,20 @@ Elrejtés: Hiba az ismerőssel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) - beolvashatja a QR-kódot a videohívásban, vagy az ismerőse megoszthat egy meghívó-hivatkozást.]]> + beolvashatja a QR-kódot a videohívásban, vagy az ismerőse megoszthat egy meghívási hivatkozást.]]> Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> Megerősítés esetén az üzenetváltó-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. A kép akkor érkezik meg, amikor a küldője befejezte annak feltöltését. QR-kód beolvasásával.]]> - A kapott SimpleX Chat-meghívó-hivatkozását megnyithatja a böngészőjében: + A kapott SimpleX Chat-meghívási hivatkozását megnyithatja a böngészőjében: Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot: Megtalált számítógép Számítógépek A markdown használata Csevegési profil létrehozása - Levélszemét elleni védelem + 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ó. Elutasítás esetén a feladó NEM kap értesítést. @@ -682,7 +682,7 @@ A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! meghíva Érvénytelen kapcsolattartási hivatkozás - Némítás + Csak említések nincsenek részletek Nem fogadott hívás Világos @@ -694,12 +694,12 @@ Nincs kézbesítési információ moderált A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! - Győződjön meg arról, hogy az XFTP-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. + Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Nincs ismerős kijelölve - Nincsenek fogadott, vagy küldött fájlok + Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben - meghívás a %1$s csoportba + meghívás a(z) %1$s csoportba Zárolási mód Új hordozható eszköz Kapcsolatok megtartása @@ -714,13 +714,13 @@ Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 közvetett (%1$s) Hamarosan további fejlesztések érkeznek! - Az üzenetreakciók küldése le van tiltva ebben a csevegésben. + 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 az ismerőse régi adatbázis biztonsági mentést használt. Ú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 - A reakciók küldése az üzenetekre le van tiltva. + A reakciók hozzáadása az üzenetekhez le van tiltva. Nem nincs szöveg TAG @@ -733,7 +733,7 @@ k soha (új)]]> - Győződjön meg arról, hogy az SMP-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. + Győződjön meg arról, hogy a megadott SMP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Az onion-kiszolgálók nem lesznek használva. perc Tudjon meg többet @@ -757,7 +757,7 @@ Egy üzenet eltüntetése Végleges üzenettörlés Egyszerre csak 10 videó küldhető el - Csak Ön adhat hozzá üzenetreakciókat. + Csak Ön adhat hozzá reakciókat az üzenetekhez. elhagyta a csoportot Az üzenetek végleges törlése le van tiltva ebben a csevegésben. Max 40 másodperc, azonnal fogadható. @@ -773,16 +773,16 @@ Rendben Nincsenek szűrt csevegések érvénytelen adat - Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. - Csak a csoport-tulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. + Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. + Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését. A fájl betöltése… Nincs hozzáadandó ismerős Üzenetvázlat meghívta, hogy csatlakozzon - Egyszer használható meghívó-hivatkozás + Egyszer használható meghívási hivatkozás Értesítések Egyszerre csak 10 kép küldhető el - ajánlott %s: %2s + ajánlotta: %s, ekkor: %2s Nem kompatibilis! Tegye priváttá a profilját! Üzenetkézbesítési hiba @@ -791,10 +791,10 @@ Némítás Hordozható eszköz társítása Értesítési szolgáltatás - Csak a csoport-tulajdonosok engedélyezhetik a hangüzenetek küldését. + Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését. A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás - Csak a csoport-tulajdonosok változtathatják meg a csoport-beállításokat. + Csak a csoport tulajdonosai módosíthatják a csoport-beállításokat. Nincsenek előzmények Érvénytelen QR-kód Megjelölés olvasottként @@ -807,25 +807,25 @@ Az értesítések az alkalmazás elindításáig nem fognak működni kikapcsolva` (ez az eszköz: v%s)]]> - ajánlott %s + %s ajánlotta Csoport elhagyása - Az összes %s által írt üzenet megjelenik! + Az összes %s által írt üzenet 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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy az ismerőse régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült. megfigyelő inkognitó a csoporthivatkozáson keresztül Onion-kiszolgálók használata, ha azok rendelkezésre állnak. - Ismerősök meghívása + Barátok meghívása Menük és figyelmeztetések Tagok meghívása csatlakozás mint %s Nincs csevegés kijelölve Csak helyi profiladatok - inkognitó egy egyszer használható hivatkozáson keresztül - Moderálva lett ekkor: %s - Egyszer használható meghívó-hivatkozás + inkognitó egy egyszer használható meghívási hivatkozáson keresztül + Moderálva: %s + Egyszer használható meghívási hivatkozás Érvénytelen név! Beszélgessünk a SimpleX Chatben - Moderálva ekkor: + Moderálva Élő üzenetek Hitelesítés Üzenetkézbesítési jelentések! @@ -839,7 +839,7 @@ Új jelmondat… nem fogadott hívás Átköltöztetés: %s - Válaszul erre: + Válaszul erre Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! Információ @@ -869,10 +869,10 @@ Csatlakozás inkognitóban Csevegés megnyitása elutasított hívás - Rendszeres + Időszakos fogadott, tiltott Kapcsolatkérés megismétlése? - Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti őket ). (24 óra) + Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti meg őket ). (24 óra) Szerepkör SimpleX kapcsolattartási cím Megállítás @@ -898,22 +898,22 @@ Csak Ön tud hangüzeneteket küldeni. Frissítés Videó elküldve - Az adatbázis jelmondatának megváltoztatása + Az adatbázis jelmondatának módosítása Alkalmazás beállítások megnyitása - A jelkód nem változott meg! + A jelkód nem módosult! Frissítés Kijelölés Csak Ön tud hívásokat indítani. - Biztonságos sorbaállítás + Biztonságos sorba állítás Értékelje az alkalmazást - Egyszer használható meghívó-hivatkozás megosztása + Egyszer használható meghívási hivatkozás megosztása Hiba az adatbázis visszaállításakor %s és %s Ön engedélyezi Csökkentett akkumulátor-használat Mentés és az ismerősök értesítése Előnézet - Simplex Chat használata + SimpleX Chat használata Megosztás Fogadott üzenet Üdvözlőüzenet @@ -926,13 +926,13 @@ (beolvasás, vagy beillesztés a vágólapról) Várakozás a videóra Válasz - Ez az Ön egyszer használható meghívó-hivatkozása! + Ez az Ön egyszer használható meghívási hivatkozása! SimpleX Chat hívások Új inkognitóprofil használata Frissítse az alkalmazást, és lépjen kapcsolatba a fejlesztőkkel. SimpleX Hivatkozás előnézete - a biztonsági kód megváltozott + a biztonsági kód módosult Csak az ismerős nevének megjelenítése Hangszóró bekapcsolva Importált csevegési adatbázis használatához indítsa újra az alkalmazást. @@ -958,7 +958,7 @@ Adatbázismentés visszaállítása Visszavonás Kérje meg az ismerősét, hogy engedélyezze a hangüzenetek küldését. - Ön egy egyszer használható meghívó-hivatkozást osztott meg + Ön egy egyszer használható meghívási hivatkozást osztott meg A hivatkozás megnyitása a böngészőben gyengítheti az adatvédelmet és a biztonságot. A megbízhatatlan SimpleX-hivatkozások pirossal vannak kiemelve. Saját ICE-kiszolgálók Kapcsolat létrehozása @@ -968,7 +968,7 @@ Profiljelszó mentése Fájlküldés megállítása? Számítógép leválasztása? - A hangüzenetek le vannak tilva! + A hangüzenetek le vannak tiltva! Közvetlen üzenet küldése a kapcsolódáshoz PING-ek száma Fejlesztői beállítások megjelenítése @@ -987,12 +987,12 @@ %s: %s A SimpleX nem tud a háttérben futni. Csak akkor fog értesítéseket kapni, amikor az alkalmazás meg van nyitva. Túl sok kép! - %s, %s és %d tag + %s, %s és további %d tag Csevegési szolgáltatás megállítása SimpleX-hivatkozások Az elküldött üzenetek törölve lesznek a beállított idő után. Némítás megszüntetése - Elküldve ekkor: %s + Elküldve: %s Jelenlegi profil használata Ez az eszköz Megosztja a címet az ismerőseivel? @@ -1016,7 +1016,7 @@ Biztonsági kód Adja meg a helyes, jelenlegi jelmondatot. Az elküldött üzenetek végleges törlése le van tiltva. - Az üzenetreakciók küldése le van tiltva. + A reakciók hozzáadása az üzenethez le van tiltva. Véletlenszerű jelmondat használata egyenrangú CSEVEGÉSI SZOLGÁLTATÁS INDÍTÁSA @@ -1028,7 +1028,7 @@ SimpleX Chat szolgáltatás Nem lehet üzeneteket küldeni! %s hitelesítve - Jelszó megjelenítése + Jelszó a megjelenítéshez Adatvédelem és biztonság Eltávolítás A jelkód beállítva! @@ -1043,12 +1043,12 @@ Üdvözlőüzenet mentése? Új csevegési fiók létrehozásához indítsa újra az alkalmazást. Engedély megtagadva! - Főggőben lévő hívás + Függőben lévő hívás Adatbázis megnyitása… Leállítás? Jelmondat szükséges Privát értesítések - Meghívta egy ismerősét + Ön meghívta egy ismerősét %s nincs hitelesítve Koppintson ide a kapcsolódáshoz Ennek az eszköznek a neve @@ -1070,7 +1070,7 @@ tulajdonos Bekapcsolás %s, %s és %s kapcsolódott - Egyszer használható SimpleX-meghívó-hivatkozás + Egyszer használható SimpleX-meghívási hivatkozás Hívások nem sikerült elküldeni KEZELŐFELÜLET SZÍNEI @@ -1082,7 +1082,7 @@ SMP-kiszolgálók Videó Automatikus elfogadási beállítások mentése - Újraegyzetetés + Újraegyeztetés Várakozás a videóra Saját XFTP-kiszolgálók Videó kikapcsolva @@ -1101,20 +1101,20 @@ Saját SMP-kiszolgálók A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása - egy egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívási hivatkozáson keresztül Csoport-beállítások megadása a következőn keresztül: %1$s igen Hangüzenet Társítás számítógéppel PROFIL - port %d + %d-s port Kapcsolódás egy hivatkozáson keresztül Cím megosztása A kiszolgáló QR-kódjának beolvasása Megállítás Címmegosztás megállítása? - Csevegési profilok megváltoztatása + Csevegési profilok módosítása Csatlakozáskérés megismétlése? Várakozás a képre Hangüzenetek @@ -1132,12 +1132,12 @@ Jelkód beállítása Újdonságok Csoport megnyitása - Elküldve ekkor: + Elküldve A hangüzenetek küldése le van tiltva. Legutóbbi üzenet előnézetének megjelenítése Az előre beállított kiszolgáló címe - Rendszeres értesítések letiltva! - A jelkód megváltozott! + Időszakos értesítések letiltva! + A jelkód módosult! Akkor fut, amikor az alkalmazás meg van nyitva Ez a QR-kód nem egy hivatkozás! Várakozás a fájlra @@ -1145,7 +1145,7 @@ Szétkapcsolás Véletlenszerű profil Érvénytelen jelmondat! - Az üzenetreakciók küldése le van tiltva. + A reakciók hozzáadása az üzenetekhez le van tiltva. Rendszer olvasatlan Függőben @@ -1156,7 +1156,7 @@ Videó Frissítés Megnyitás - Rendszeres értesítések + Időszakos értesítések Kihagyott üzenetek A hangüzenetek küldése le van tiltva. Ismerős nevének beállítása @@ -1169,7 +1169,7 @@ Váltás A kapott hivatkozás beillesztése az ismerőshöz való kapcsolódáshoz… Beolvasás - Port megnyitása a tűzfalon + Port nyitása a tűzfalban indítás… Leállítás elküldve @@ -1190,7 +1190,7 @@ QR-kód Titkosítás újraegyeztetése Eltávolítás - Onion kiszolgálók használata + Onion-kiszolgálók használata Felfedés Zárolási mód Fájl visszavonása @@ -1214,7 +1214,7 @@ Közvetlen üzenet küldése Elutasítás Küldés - Rendszerhitelesítés + Rendszer-hitelesítés Böngészőn keresztül Védje meg a csevegési profiljait egy jelszóval! Csak az ismerőse tud eltűnő üzeneteket küldeni. @@ -1228,11 +1228,11 @@ - értesíti az ismerősöket a törlésről (nem kötelező)\n- profil nevek szóközökkel\n- és még sok más! Lengyel kezelőfelület Kiszolgáló használata - Fogadva ekkor: %s + Fogadva: %s SimpleX-zár Mentés és a csoporttagok értesítése Visszaállítás - Csak az ismerőse tud üzenetreakciókat küldeni. + Csak az ismerőse adhat hozzá reakciókat az üzenetekhez. Hangüzenetek Ön elhagyta a csoportot Hangüzenet rögzítése @@ -1252,18 +1252,18 @@ Tudjon meg többet A fájl küldője visszavonta az átvitelt. Csevegési szolgáltatás megállítása? - Fogadva ekkor: + Fogadva Beállítva 1 nap Felfedés Fogadott üzenetbuborék színe Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) - Az önmegsemmisítő-jelkód megváltozott! + Az önmegsemmisítő-jelkód módosult! SimpleX Chat-kiszolgálók használatban. SimpleX Chat-kiszolgálók használata? Csevegési profil felfedése Videók és fájlok legfeljebb 1GB méretig TCP kapcsolat időtúllépése - A(z) %1$s nevű profiljának SimpleX-címe meg lesz osztva. + A(z) %1$s nevű profilja meg lesz osztva. Ön már kapcsolódott a következőhöz: %1$s. A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, ismerősei, csevegési üzenetei és fájljai véglegesen törölve lesznek. Ötletek és javaslatok @@ -1271,25 +1271,25 @@ Koppintson ide az új csevegés indításához Várakozás a számítógépre… Az üzenetváltás jövője - Hálózati beállítások megváltoztatása? + Hálózati beállítások módosítása? Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat hitelesítése fájlok küldése egyelőre még nem támogatott - Ön megváltoztatta a címet %s számára + Ön módosította a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. videóhívás (nem e2e titkosított) Alkalmazás új kapcsolatokhoz - Az új üzeneteket az alkalmazás rendszeresen lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. + Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése a kapcsolattartási címhivatkozáson keresztül a SimpleX a háttérben fut a push értesítések használata helyett.]]> Az ismerősének online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt az ismerőskérelmet és eltávolíthatja az ismerőst (ezt később ismét megpróbálhatja egy új hivatkozással). - A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonságimentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. + A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. Az ismerősei továbbra is kapcsolódva maradnak. - A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze jelszavát + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. @@ -1303,9 +1303,9 @@ Ez a szöveg a „Beállításokban” érhető el A profilja el lesz küldve az ismerőse számára, akitől ezt a hivatkozást kapta. Az alkalmazás 1 perc után bezárható a háttérben. - meghívást kapott a csoportba + Ön meghívást kapott a csoportba Engedélyezze a következő párbeszédpanelen az azonnali értesítések fogadásához.]]> - A kiszolgálónak engedélyre van szüksége a sorbaállítás létrehozásához, ellenőrizze jelszavát + A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát Kapcsolódni fog a csoport összes tagjához. Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen A biztonsága érdekében kapcsolja be a SimpleX-zár funkciót.\nA funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beállítására az eszközén. @@ -1321,7 +1321,7 @@ A fájl fogadása le fog állni. Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani! A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését. - Ön egy egyszer használható meghívó-hivatkozást osztott meg inkognitóban + Ön egy egyszer használható meghívási hivatkozást osztott meg inkognitóban Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. Később engedélyezheti a „Beállításokban” Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! @@ -1332,16 +1332,16 @@ A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal. A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől. Ez a beállítás csak az Ön jelenlegi csevegő-profiljában lévő üzenetekre vonatkozik - Meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. + Ön meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. - Meghívást kapott a csoportba + Ön meghívást kapott a csoportba Az ismerőse a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. Az ismerősei és az üzenetek (kézbesítés után) nem a SimpleX-kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> A csevegési profilja el lesz küldve\naz ismerőse számára - Egy olyan ismerősét próbálja meghívni, akivel inkognitó-profilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban + Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban %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 @@ -1355,11 +1355,11 @@ %s ekkor: %s Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. - Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS-proxy nem támogatja őket.]]> + Onion-kiszolgálók használata opciót „Nemre”, ha a SOCKS-proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. - Ön megváltoztatta %s szerepkörét a következőre: %s + Ön a következőre módosította %s szerepkörét: „%s” Csoportmeghívó elutasítva Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. (a megosztáshoz az ismerősével) @@ -1376,19 +1376,19 @@ Fejlesztés és a csevegés megnyitása Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak. %1$s nevű csoport tagja.]]> - Ön megváltoztatta a címet + Ön módosította a címet Az ismerősei engedélyezhetik a teljes üzenet törlést. A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. Ha engedélyezni szeretné a hordozható eszköz-alkalmazás társítását a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, miután engedélyezte azt A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> - Ez a karakterlánc nem egy meghívó-hivatkozás! + Ez a karakterlánc nem egy meghívási hivatkozás! Új csevegés indításához - A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül! + A kapcsolódás már folyamatban van ezen az egyszer használható meghívási hivatkozáson keresztül! Nem veszíti el az ismerőseit, ha később törli a címét. A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! - Ön megváltoztatta a saját szerepkörét a következőre: %s + Ön a következőre módosította a saját szerepkörét: „%s” A csevegési szolgáltatás elindítható a „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával. Kód hitelesítése a hordozható eszközön Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. @@ -1405,7 +1405,7 @@ Kód hitelesítése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. A kapcsolatkérés el lesz küldve ezen csoporttag számára. - Inkognitó-profil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott. + Inkognitóprofil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott. Már küldött egy kapcsolatkérést ezen a címen keresztül! Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: %s. Amikor az emberek kapcsolatot kérnek, Ön elfogadhatja vagy elutasíthatja azokat. @@ -1413,7 +1413,7 @@ Köszönet a felhasználóknak – hozzájárulás a Weblate-en! A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz. Protokoll időtúllépése KB-onként - Az adatbázis-jelmondat megváltoztatására tett kísérlet nem fejeződött be. + Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. A profilja csak az ismerőseivel van megosztva. Néhány kiszolgáló megbukott a teszten: @@ -1425,19 +1425,19 @@ A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára. Bluetooth támogatás és további fejlesztések. Ez a funkció még nem támogatott. Próbálja meg a következő kiadásban. - A bejegyzés frissítve: %s + Bejegyzés frissítve: %s Tagok meghívásának kihagyása Ezek felülbírálhatók az ismerős- és csoport-beállításokban. Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! - A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEzt később megváltoztathatja. + A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEzt később módosíthatja. Koppintson ide az inkognitóban való kapcsolódáshoz Jelmondat beállítása az exportáláshoz 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 – hozzájárulás a Weblate-en! 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. - Rendszerhitelesítés helyetti beállítás. - A fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be. + 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. Jelmondat mentése a Keystore-ba Köszönet a felhasználóknak – hozzájárulás a Weblate-en! @@ -1448,7 +1448,7 @@ 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 - A szerepkör meg fog változni a következőre: %s. A csoport tagjai értesítést fognak kapni. + A tag szerepköre a következőre fog módosulni: „%s”. A csoport összes tagja értesítést fog kapni. Profil és kiszolgálókapcsolatok Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. A profil aktiválásához koppintson az ikonra. @@ -1462,16 +1462,16 @@ A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Az eszköz neve meg lesz osztva a társított hordozható eszközön használt alkalmazással. A címzettek a beírás közben látják a szövegváltozásokat. - Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni. - A jelmondat a beállításokban egyszerű szövegként lesz tárolva, miután megváltoztatta azt vagy újraindította az alkalmazást. + Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja módosítani. + A jelmondat a beállításokban egyszerű szövegként lesz tárolva, miután módosította azt vagy újraindította az alkalmazást. A jelenlegi csevegőprofiljához tartozó új kapcsolatok kiszolgálói Fogadás a következőn keresztül: Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez. - A szerepkör meg fog változni a következőre: %s. A tag új meghívót fog kapni. + A tag szerepköre a következőre fog módosulni: „%s”. A tag új meghívást fog kapni. profilkép helyőrzője A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek. - A bejegyzés frissítve + Bejegyzés frissítve Használati útmutatóban olvasható.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban @@ -1485,14 +1485,14 @@ Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás. A beillesztett szöveg nem egy SimpleX-hivatkozás. - A meghívó-hivatkozását újra megtekintheti a kapcsolat részleteinél. + A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél. Csevegés indítása? Látható előzmények Alkalmazás jelkód Ismerős hozzáadása Koppintson ide a QR-kód beolvasásához Koppintson ide a hivatkozás beillesztéséhez - Ismerős hozzáadása: új meghívó-hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.]]> + Ismerős hozzáadása: új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.]]> Csoport létrehozása: új csoport létrehozásához.]]> A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. Az előzmények nem lesznek elküldve az új tagok számára. @@ -1503,7 +1503,7 @@ Vagy mutassa meg ezt a kódot Kamera hozzáférés engedélyezése Fel nem használt meghívó megtartása? - Ennek az egyszer használható meghívó-hivatkozásnak a megosztása + Ennek az egyszer használható meghívási hivatkozásnak a megosztása Új csevegés A csevegések betöltése… Hivatkozás létrehozása… @@ -1523,7 +1523,7 @@ Kapcsolat bontva a számítógéppel A kapcsolat megszakadt A kapcsolat megszakadt - Számítógép kapcsolata rossz állapotban van + A kapcsolat a számítógéppel rossz állapotban van Jelentse a fejlesztőknek:\n%s\n\nAz alkalmazás újraindítása javasolt. Jelentse a fejlesztőknek: \n%s @@ -1543,28 +1543,28 @@ Fejlesztői beállítások A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s %s hordozható eszköz elfoglalt]]> - Már nem tag %1$s + (Már nem tag) %1$s ismeretlen állapot - %1$s megváltoztatta a nevét a következőre: %2$s + %1$s a következőre módosította a nevét: %2$s eltávolította a kapcsolattartási címet eltávolította a profilképét - új kapcsolattartási cím beállítása + új kapcsolattartási címet állított be új profilképet állított be frissített profil - %1$s megváltoztatta a nevét a következőre: %2$s + %1$s a következőre módosította a nevét: %2$s Privát jegyzetek Hiba a privát jegyzetek törlésekor Hiba az üzenet létrehozásakor Privát jegyzetek kiürítése? - Létrehozva ekkor: + Létrehozva Mentett üzenet - Megosztva ekkor: %s + Létrehozva: %s Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Továbbfejlesztett üzenetkézbesítés Csatlakozás csoportos beszélgetésekhez Hivatkozás beillesztése a kapcsolódáshoz! Privát jegyzetek - A keresősáv elfogadja a meghívó-hivatkozásokat. + A keresősáv elfogadja a meghívási hivatkozásokat. Titkosított fájlokkal és médiatartalmakkal. Csökkentett akkumulátor-használattal. Magyar és török kezelőfelület @@ -1575,10 +1575,10 @@ letiltva az adminisztrátor által Letiltva az adminisztrátor által letiltotta őt: %s - Letiltás az összes tag számára + Letiltás Az összes tag számára letiltja ezt a tagot? %d üzenetet letiltott az adminisztrátor - Letiltás feloldása az összes tag számára + Feloldás Az összes tag számára feloldja a tag letiltását? Ön letiltotta őt: %s Hiba a tag az összes csoporttag számára való letiltásakor @@ -1644,7 +1644,7 @@ %s feltöltve Sikertelen feltöltés Archívum feltöltése - Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, és üzenetkézbesítési hibákat okozhat + Figyelmeztetés: a csevegés elindítása egyszerre több eszközön nem támogatott, mert üzenetkézbesítési hibákat okoz Importálás ismét szabványos végpontok közötti titkosítás Átköltöztetés ide @@ -1663,7 +1663,7 @@ végpontok közötti kvantumálló titkosítás 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 kvantumálló tikosítással védett. + Ez a csevegés végpontok közötti kvantumálló titkosítással védett. végpontok közötti titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> végpontok közötti kvantumálló titkosítással sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve.]]> Hiba az értesítés megjelenítésekor, lépjen kapcsolatba a fejlesztőkkel. @@ -1693,13 +1693,13 @@ A SimpleX-hivatkozások küldése le van tiltva A fájlok- és médiatartalmak nincsenek engedélyezve A SimpleX-hivatkozások küldése engedélyezve van. - Számukra engedélyezve: + Számukra engedélyezve mentett elmentve innen: %s - Továbbítva innen: + Továbbítva innen A címzett(ek) nem látja(k), hogy kitől származik ez az üzenet. Mentett - Elmentve innen: + Elmentve innen Letöltés Továbbítás Továbbított @@ -1721,11 +1721,11 @@ Profilkép alakzata Négyzet, kör vagy bármi a kettő között. Célkiszolgáló-hiba: %1$s - Továbbító kiszolgáló: %1$s\nHiba: %2$s + Továbbító-kiszolgáló: %1$s\nHiba: %2$s Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. A kiszolgáló verziója nem kompatibilis a hálózati beállításokkal. Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. - Továbbító-kiszolgáló: %1$s\nCélkiszolgáló hiba: %2$s + Továbbító-kiszolgáló: %1$s\nCélkiszolgáló-hiba: %2$s Hiba: %1$s Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. Üzenetkézbesítési figyelmeztetés @@ -1733,7 +1733,7 @@ Soha Ismeretlen kiszolgálók Ha az IP-cím rejtett - Üzenetállapot megjelenítése + Üzenet állapotának megjelenítése Visszafejlesztés engedélyezése Mindig Nem @@ -1799,24 +1799,24 @@ Csökkentett akkumulátor-használattal. Hiba a WebView előkészítésekor. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.\nHiba: %s Felhasználó által létrehozott téma visszaállítása - Üzenet-sorbaállítási információ + Üzenetsorbaállítási információ nincs Kézbesítési hibák felderítése - a kiszolgáló sorbaállítási információi: %1$s\n\nutoljára kézbesített üzenet: %2$s + a kiszolgáló sorbaállítási információi: %1$s\n\nutoljára fogadott üzenet: %2$s Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. - Ideiglenesfájl-hiba - Üzenetállapot - Üzenetállapot: %s + Ideiglenes fájlhiba + Üzenet állapota + Üzenet állapota: %s Fájlhiba A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták. Fájlkiszolgáló-hiba: %1$s - Fájlállapot - Fájlállapot: %s + Fájl állapota + Fájl állapota: %s Másolási hiba Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel. Nem lehet üzenetet küldeni - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegés-beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag @@ -1905,7 +1905,7 @@ Munkamenetek átvitele Összes kapcsolat Statisztikák - Információk megjelenítése a következőhöz: + Információk megjelenítése a következőhöz A kiszolgáló verziója nem kompatibilis az alkalmazással: %1$s. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. Aktív kapcsolatok száma @@ -1987,14 +1987,14 @@ Kapcsolatok- és kiszolgálók állapotának megjelenítése. Kapcsolódjon gyorsabban az ismerőseihez. Folytatás - Ellenőrízze a hálózatát + Ellenőrizze a hálózatát Média- és fájlkiszolgálók Legfeljebb 20 üzenet egyszerre való törlése. Védi az IP-címét és a kapcsolatait. Könnyen elérhető eszköztár Üzenetkiszolgálók SOCKS-proxy - Néhány fájl nem lett exportálva: + Néhány fájl nem lett exportálva Az exportált adatbázist átköltöztetheti. Mentés és újrakapcsolódás Használja az alkalmazást egy kézzel. @@ -2003,7 +2003,7 @@ Az exportált archívumot elmentheti. Tippek visszaállítása Csevegőlista átváltása: - Ezt a „Megjelenés” menüben változtathatja meg. + Ezt a „Megjelenés” menüben módosíthatja. Új médiabeállítások Lejátszás a csevegési listából. Elhomályosítás a jobb adatvédelemért. @@ -2066,19 +2066,19 @@ Továbbfejlesztett hívásélmény Továbbfejlesztett üzenetdátumok. Továbbfejlesztett felhasználói élmény - Testreszabható üzenetbuborékok. + Testre szabható üzenetbuborékok. Legfeljebb 200 üzenet egyszerre való törlése, vagy moderálása. Legfeljebb 20 üzenet egyszerre való továbbítása. Hang/Videó váltása hívás közben. - Csevegési profilváltás az egyszer használható meghívó-hivatkozásokhoz. + Csevegési profilváltás az egyszer használható meghívási hivatkozásokhoz. Továbbfejlesztett biztonság ✅ A SimpleX Chat biztonsága a Trail of Bits által lett felülvizsgálva. Hiba a kiszolgálók mentésekor Nincsenek üzenet-kiszolgálók. - Nincsenek üzenetfogadó-kiszolgálók. + Nincsenek üzenetfogadási kiszolgálók. Nincsenek média- és fájlkiszolgálók. A(z) %s nevű csevegési profilhoz: - Cím vagy egyszer használható meghívó-hivatkozás? + Cím vagy egyszer használható meghívási hivatkozás? Új kiszolgáló Címbeállítások Előre beállított kiszolgálók @@ -2086,7 +2086,7 @@ Feltételek megtekintése Nincsenek kiszolgálók a privát üzenet-útválasztáshoz. Nincsenek fájlküldő-kiszolgálók. - Nincsenek fájlfogadó-kiszolgálók. + Nincsenek fájlfogadási kiszolgálók. Hibák a kiszolgálók konfigurációjában. Hiba a feltételek elfogadásakor Kézbesítetlen üzenetek @@ -2096,19 +2096,19 @@ Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. - Egyszer használható meghívó-hivatkozás megosztása egy baráttal - egyetlen ismerőssel használható – személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> + Egyszer használható meghívási hivatkozás megosztása egy baráttal + egyetlen ismerőssel használható – személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. Kapcsolatbiztonság - A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. + A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat az ismerősével. A közösségi médiához Vagy a privát megosztáshoz - SimpleX-cím vagy egyszer használható meghívó-hivatkozás? - Egyszer használható meghívó-hivatkozás létrehozása + SimpleX-cím vagy egyszer használható meghívási hivatkozás? + Egyszer használható meghívási hivatkozás létrehozása Kiszolgáló-üzemeltetők Hálózati üzemeltetők - Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőt használ. + Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. Jelölje ki a használni kívánt hálózati üzemeltetőket. Felülvizsgálat később @@ -2122,24 +2122,24 @@ A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %s. Az Ön kiszolgálói %s.]]> - %s.]]> + %s.]]> %s kiszolgáló Hálózati üzemeltető Weboldal - Feltételek elfogadva ekkor: %s. + Feltételek elfogadva: %s. A feltételek ekkor lesznek elfogadva: %s. Kiszolgálók használata %s használata A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: %s.]]> - %s.]]> - %s.]]> + %s.]]> + %s.]]> %s.]]> %s.]]> %s.]]> Feltételek elfogadása Használati feltételek - %s kiszolgálók használatához fogadja el a használati feltételeket.]]> + %s kiszolgálóinak használatához fogadja el a használati feltételeket.]]> Használat az üzenetekhez A fogadáshoz A privát útválasztáshoz @@ -2148,10 +2148,10 @@ A küldéshez Hozzáadott média- és fájlkiszolgálók Feltételek megnyitása - Változások megnyitása + Módosítások megtekintése Hiba a kiszolgáló frissítésekor - A kiszolgáló-protokoll megváltozott. - A kiszolgáló üzemeltetője megváltozott. + A kiszolgáló-protokoll módosult. + A kiszolgáló üzemeltetője módosult. Kiszolgáló-üzemeltető Kiszolgáló hozzáadva a következő üzemeltetőhöz: %s. Hiba a kiszolgáló hozzáadásakor @@ -2187,11 +2187,11 @@ A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! Csevegés törlése? Csevegés elhagyása - Csak a csevegés tulajdonosai változtathatják meg a beállításokat. + Csak a csevegés tulajdonosai módosíthatják a csevegés-beállításokat. Könnyen elérhető csevegési eszköztár A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! Csevegés - A szerepkör meg fog változni a következőre: %s. A csevegés tagjai értesítést fognak kapni. + A tag szerepköre a következőre fog módosulni: „%s”. A csevegés összes tagja értesítést fog kapni. A csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára A tagok közötti közvetlen üzenetek le vannak tiltva. Üzleti csevegések @@ -2215,8 +2215,8 @@ Naplózás engedélyezése Hiba az adatbázis mentésekor áthúzott - A következő csevegési profil törlése: - Üzenetek törlése ennyi idő után: + A következő csevegési profil törlése + Üzenetek törlése ennyi idő után a + b A kapcsolat nem áll készen. Megnyitás a következővel: %s @@ -2241,11 +2241,11 @@ Lista törlése? Szerkesztés Listanév… - Az összes lista nevének és emodzsiának különbözőnek kell lennie. + Az összes lista nevének és emodzsijának különbözőnek kell lennie. Nincsenek csevegések a(z) %s nevű listában. Jegyzetek - Lista megváltoztatása - Elrendezés megváltoztatása + Lista módosítása + Elrendezés módosítása Hiba a jelentés létrehozásakor Hiba a beállítások mentésekor A jelentés archiválva lesz az Ön számára. @@ -2295,10 +2295,33 @@ Csevegési üzenetek törlése az Ön eszközéről. Automatikus üzenettörlés módosítása? Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. - A(z) %1$s port használata, amikor nincs port megadva. + A következő TCP-port használata, amikor nincs port megadva: %1$s. TCP-port az üzenetváltáshoz Webport használata - Olvasatlan megemlítések + Olvasatlan említések Összes némítása Legfeljebb %1$s tagot említhet meg egy üzenetben! + Lehetővé teszi az üzenetek jelentését a moderátorok felé. + Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Az összes jelentés archiválása? + %d jelentés archiválása? + Csak magamnak + Jelentések archiválása + Az összes moderátor számára + Az üzenetek jelentése ebben a csoportban le van tiltva. + A tagok jelenthetik az üzeneteket a moderátorok felé. + Az összes jelentés archiválva lesz az Ön számára. + Jelentés: %s + Tagok említése 👋 + Privát jelentések küldése + Ne maradjon le a fontos üzenetekről. + Gyorsabb üzenetküldés. + Gyorsabb csoporttörlés. + Segítsen az adminisztrátoroknak a csoportjaik moderálásában. + Privát nevek a médiafájlokhoz. + Üzenetek lejárati idejének módosítása a csevegésekben. + Továbbfejlesztett, gyorsabb csoportok + Továbbfejlesztett adatvédelem és biztonság + Kapjon értesítést, ha megemlítik. + Csevegések listákba szervezése 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 fca0124b30..14fb1ad156 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2306,4 +2306,15 @@ Gunakan port TCP %1$s jika tidak ada port yang ditentukan. Gunakan port peramban Anda dapat menyebut hingga %1$s anggota per pesan! + Semua laporan akan diarsipkan untuk Anda. + Arsipkan semua laporan? + Untuk semua moderator + Untuk saya + Arsip laporan + Laporan: %s + Izinkan untuk laporkan pesan ke moderator. + Anggota dapat melaporkan pesan ke moderator. + Laporkan pesan dilarang di grup ini. + Dilarang laporkan pesan ke moderator. + Arsipkan %d laporan? 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 efbcb1d77a..3afc376154 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2307,11 +2307,11 @@ Archivia la segnalazione Elimina la segnalazione Segnalazioni - Segnalazioni membro archiviate + Segnalazioni dei membri archiviate 1 segnalazione segnalazione archiviata da %s %d segnalazioni - Segnalazioni membro + Segnalazioni dei membri Spam Connessione bloccata Il file è bloccato dall\'operatore del server:\n%1$s. @@ -2338,4 +2338,27 @@ Silenzia tutto Menzioni non lette Puoi menzionare fino a %1$s membri per messaggio! + I membri possono segnalare messaggi ai moderatori. + Archiviare tutte le segnalazioni? + Archiviare %d segnalazioni? + Archivia segnalazioni + Per tutti i moderatori + Per me + Segnalazione: %s + In questo gruppo è vietato segnalare messaggi. + Consenti di segnalare messaggi ai moderatori. + Tutte le segnalazioni verranno archiviate per te. + Vieta di segnalare messaggi ai moderatori. + Non perdere messaggi importanti. + Eliminazione dei gruppi più veloce. + Ricevi una notifica quando menzionato. + Aiuta gli amministratori a moderare i loro gruppi. + Menziona i membri 👋 + Privacy e sicurezza migliorate + Invio dei messaggi più veloce. + Prestazioni dei gruppi migliorate + Organizza le chat in elenchi + Imposta la scadenza dei messaggi nelle chat. + Invia resoconti privati + Nomi privati dei file multimediali. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 71613f2d8a..edfb18bc89 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1596,7 +1596,7 @@ contactadres verwijderd profielfoto verwijderd nieuw contactadres instellen - nieuwe profielfoto instellen + nieuwe profielfoto Geblokkeerd door beheerder Blokkeren voor iedereen Lid voor iedereen blokkeren? @@ -2335,4 +2335,27 @@ Alles dempen Ongelezen vermeldingen Je kunt maximaal %1$s leden per bericht vermelden! + Hiermee kunt u berichten rapporteren aan moderators. + Alle rapporten worden voor u gearchiveerd. + Alle rapporten archiveren? + %d rapporten archiveren? + Voor mij + Leden kunnen berichten melden bij moderators. + Het melden van berichten in deze groep is niet toegestaan + Rapport: %s + Het melden van berichten aan moderators is niet toegestaan. + Voor alle moderators + Rapporten archiveren + Betere prestaties van groepen + Stel de berichtvervaldatum in chats in. + Betere privacy en veiligheid + ‐Mis geen belangrijke berichten. + Sneller verwijderen van groepen. + Ontvang een melding als u vermeld wordt. + Help beheerders bij het modereren van hun groepen. + Vermeld leden 👋 + Organiseer chats in lijsten + Namen van persoonlijke mediabestanden. + Privérapporten verzenden + Sneller verzenden van berichten. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index d4f9be50aa..92572516b9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -334,7 +334,7 @@ Echipa SimpleX Închizi? Adresă de contact SimpleX - Link de grup SimpleX + Link pentru grup SimpleX Link-uri SimpleX Invitație unică SimpleX Siglă SimpleX @@ -634,7 +634,7 @@ Adresă desktop Dispozitive desktop Desktop - Eroare la descifrare + Eroare de decriptare Șterge imagine Șterge după Șterge pentru toată lumea @@ -739,4 +739,70 @@ Apeluri îmbunătățite Apel audio primit Termină apelul - \ No newline at end of file + marcate şterse + %d mesaje marcate șterse + moderat de %s + %1$d alte erori de fișier. + %1$s mesaje netransmise + ai distribuit un link de unică folosință + ai distribuit un link ascuns de unică folosință + prin link pentru grup + ascuns prin link pentru grup + Prin browser + Reclamă + Conținut inadecvat + Încălcă normelor comunitare + Profil inadecvat + Alt motiv + Eroare la salvarea serverelor SMP + Asigurați-vă că adresele serverelor XFTP sunt în format corect, pe randuri separate și nu sunt duplicate. + Eroare la încărcarea serverlor XFTP + Eroare la actualizarea configurației de rețea + Eșec la încărcarea conversațiilor + Vă rugăm să actualizați aplicația și contactați dezvoltatorii. + Eroare la crearea profilului! + %d mesaje blocate + Eroare de renegociere a criptării + invitat să se conecteze + ascuns prin link pentru adresa de contact + printr-un link de unică folosință + 1 raport + 1 an + înaintat + eroare de afișare a mesajului + eroare de afișat conținutul + criptare cap-coadă cu secretizare înaintată perfecta, repudiere si recuperare în caz de spargere.]]> + criptare cap-coadă rezistentă la algoritmi cuantici cu secretizare înaintată perfecta, repudiere si recuperare în caz de spargere.]]> + Această conversație este protejată prin criptare cap-coadă. + Link întreg + ascuns printr-un link de unică folosință + a + b + prin %1$s + Eroare la încărcarea serverlor SMP + %1$d fișier(e) șterse. + raport arhivat de %s + invitație acceptată + Asigurați-vă că adresele serverelor SMP sunt în format corect, pe randuri separate și nu sunt duplicate. + Eroare la salvarea serverelor XFTP + %1$d fișier(e) încă în descărcare. + %1$d fișier(e) a eșuat să se descărcarce. + %1$d fișier(e) nu au fost descărcate. + prin link pentru adresa de contact + Deschiderea link-ului în browser poate reduce confidențialitatea și securitatea conexiunii. Link-urile SimpleX de neîncredere vor fi roșii. + solicitat să se conecteze + Eșec la încărcarea conversației + Doar tu și moderatorii vedeți asta + Doar expeditorul şi moderatorii văd asta + raport arhivat + %d mesaje blocate de admin + primirea de fișiere nu este acceptată încă + tu + format mesaj necunoscut + format mesaj invalid + LIVE + moderat + conversație nevalidă + date nevalide + Această conversație este protejată prin criptare cap-coadă, rezistentă la algoritmi cuantici. + Notițe private + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 361e126c10..62d3764940 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -704,7 +704,7 @@ Anlık bildirimler Anlık bildirimler Anlık bildirimler devre dışı! - SimpleX in arkaplanda çalışmasına izin ver seçeneğini işaretleyin. Aksi takdirde, bildirimler devre dışı bırakılacaktır.]]> + izin verin. ]]> Kişi ismi Cihaz doğrulaması devre dışı. SimpleX Kilidi Kapatılıyor. Cihaz doğrulaması etkin değil. Cihaz doğrulamasını etkinleştirdikten sonra SimpleX Kilidini Ayarlar üzerinden açabilirsiniz. @@ -2138,4 +2138,25 @@ Listeye ekle %s listesindeki bütün sohbetler kaldırılacak ve liste silinecek İşletmeler + arşivlenmiş rapor + Başka bir sebep + Raporu arşivle + %s tarafından arşivlenen rapor + 1 rapor + Arşivlenmiş üye raporları + %s.]]> + Sor + 1 yıl + Xiaomi cihazları: Bildirimlerin çalışması için lütfen sistem ayarlarında Otomatik Başlat\'ı etkinleştirin.]]> + Tüm raporlar sizin için arşivlenecektir. + Tüm raporlar arşivlensin mi? + Raporları arşivle + %s.]]> + %s operatörü için de geçerli olacaktır.]]> + %s operatörü(leri) için de geçerli olacaktır.]]> + Mesajların moderatörlere bildirilmesine izin verin. + %d raporu arşivleyelim mi? + Arşiv + Raporu arşivleyelim mi? + uçtan uca şifreli olarak gönderilir ve doğrudan mesajlarda kuantum sonrası güvenlik sağlanır.]]> 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 000517d28c..d1afbe4c38 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -58,7 +58,7 @@ Додати сервер адміністратор Додати привітання - Всі члени групи залишаться підключеними. + Всі учасники групи залишаться підключеними. Дозвольте вашим контактам надсилати повідомлення, які зникають. Всі повідомлення будуть видалені - цю дію неможливо скасувати! Повідомлення будуть видалені ЛИШЕ для вас. Версія додатку @@ -1553,7 +1553,7 @@ \n \nРекомендується перезапустити додаток.
Надсилати до 100 останніх повідомлень новим користувачам. - До 100 останніх повідомлень надсилаються новим членам. + До 100 останніх повідомлень надсилаються новим учасникам. З\'єднання перервано Створено: %s Показати внутрішні помилки @@ -2332,4 +2332,18 @@ TCP-порт для повідомлень Використовувати TCP-порт %1$s, якщо порт не вказано. Використовувати веб-порт + Вимкнути звук + Непрочитані згадки + Ви можете згадувати до %1$s учасників у кожному повідомленні! + Усі звіти будуть архівовані для вас. + Архівувати всі звіти? + Архівувати %d звітів? + Архівувати звіти + Для всіх модераторів + Для мене + Звіт: %s + Заборонити повідомлення модераторам. + Повідомлення модераторам заборонено в цій групі. + Учасники можуть повідомляти повідомлення модераторам. + Дозволити повідомляти повідомлення модераторам. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index b7954bcea8..acc3508772 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -2309,4 +2309,15 @@ Bạn có thể nhắc đến tối đa là %1$s thành viên trong một tin nhắn! Tắt thông báo tất cả Những lời nhắc chưa đọc + Các thành viên có thể báo cáo tin nhắn tới các kiểm duyệt viên. + Tất cả các báo cáo sẽ được lưu trữ cho bạn. + Lưu trữ tất cả báo cáo? + Lưu trữ %d báo cáo? + Lưu trữ các báo cáo + Cho tất cả các kiểm duyệt viên + Cho tôi + Báo cáo: %s + Cho phép báo cáo tin nhắn tới các kiểm duyệt viên. + Cấm báo cáo tin nhắn tới các kiểm duyệt viên. + Báo cáo tin nhắn bị cấm trong nhóm này. 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 8c9d0429d2..97f1ef9d2d 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 @@ -2322,4 +2322,27 @@ 全部静音 取消提及的已读状态 每条消息最多提及 %1$s 名成员! + 将为你存档所有举报。 + 存档所有举报? + 存档 %d 份举报? + 存档举报 + 所有 moderators + 仅自己 + 举报:%s + 禁止向 moderators 举报消息。 + 此群禁止消息举报。 + 成员可以向 moderators 举报消息。 + 允许向 moderators 举报消息。 + 提及成员👋 + 更好的群性能 + 更好的隐私和安全 + 不错过重要消息。 + 更快地删除群。 + 更快死发送消息。 + 被提及时收到通知。 + 帮助管理员管理群组。 + 将聊天组织到列表 + 私密媒体文件名。 + 发送私下举报 + 在聊天中设置消息过期时间。 From 981901d587680852c346c82d1a3dd7ed7129c622 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 24 Feb 2025 22:06:52 +0000 Subject: [PATCH 086/289] 6.3-beta.6: ios 266, android 277, desktop 94 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++---------- .../xcschemes/SimpleX SE.xcscheme | 1 + apps/multiplatform/gradle.properties | 8 ++--- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8e9c47850a..8ac80ccbdc 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.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.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 */; }; @@ -531,8 +531,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.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a */, ); path = Libraries; sourceTree = ""; @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2012,7 +2012,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2053,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2073,7 +2073,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2098,7 +2098,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2135,7 +2135,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2172,7 +2172,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2223,7 +2223,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2274,7 +2274,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2308,7 +2308,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme index 1a1fa8b800..a2639eb263 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme @@ -74,6 +74,7 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 02771976c6..ea0516fdaa 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-beta.5 -android.version_code=276 +android.version_name=6.3-beta.6 +android.version_code=277 -desktop.version_name=6.3-beta.5 -desktop.version_code=93 +desktop.version_name=6.3-beta.6 +desktop.version_code=94 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 511ff1d35cf7a6ccf466cf288e8aa517fc04c454 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 25 Feb 2025 09:24:30 +0000 Subject: [PATCH 087/289] cli: remove multiple members (#5656) * cli: remove multiple members * accept all members joining via link as observers (do NOT release) * blocked words * blocked words * XGrpLinkReject * core: 6.3.0.6 (simplexmq 6.3.0.6) --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/simplex-directory-service/Main.hs | 5 +- .../src/Directory/BlockedWords.hs | 64 +++++++++++++++++++ .../src/Directory/Options.hs | 60 ++++++++++++++++- .../src/Directory/Service.hs | 19 +++++- simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 3 + src/Simplex/Chat/Controller.hs | 10 ++- src/Simplex/Chat/Library/Commands.hs | 13 +++- src/Simplex/Chat/Library/Subscriber.hs | 21 ++++-- src/Simplex/Chat/ProfileGenerator.hs | 11 ++++ src/Simplex/Chat/Protocol.hs | 1 + src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 26 ++++++-- src/Simplex/Chat/Store/Messages.hs | 8 ++- .../SQLite/Migrations/chat_query_plans.txt | 29 +++++++-- src/Simplex/Chat/Store/Shared.hs | 7 +- src/Simplex/Chat/Types.hs | 4 +- tests/Bots/DirectoryTests.hs | 5 ++ tests/ChatClient.hs | 10 +-- 19 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 apps/simplex-directory-service/src/Directory/BlockedWords.hs diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 0c6464dbfe..b01f088483 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -6,7 +6,6 @@ import Directory.Options import Directory.Service import Directory.Store import Simplex.Chat.Core -import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do @@ -14,4 +13,6 @@ main = do st <- restoreDirectoryStore directoryLog if runCLI then directoryServiceCLI st opts - else simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts + else do + cfg <- directoryChatConfig opts + simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts diff --git a/apps/simplex-directory-service/src/Directory/BlockedWords.hs b/apps/simplex-directory-service/src/Directory/BlockedWords.hs new file mode 100644 index 0000000000..7477226e53 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/BlockedWords.hs @@ -0,0 +1,64 @@ +module Directory.BlockedWords where + +import Data.Char (isMark, isPunctuation, isSpace) +import Data.List (isPrefixOf) +import Data.Maybe (fromMaybe) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Normalize as TN + +containsBlockedWords :: Map Char [Char] -> [String] -> Text -> Bool +containsBlockedWords spelling blockedWords s = + let normalizedWords = concatMap words $ normalizeText spelling s + -- Fully normalize the entire string (no spaces or punctuation) + fullNorm = normalizeText spelling $ T.filter (not . isSpace) s + -- Check if any individual word is a swear word + wordCheck = any (`elem` blockedWords) normalizedWords + -- Check if the full string, when normalized, matches a swear word exactly + fullCheck = any (\bw -> T.length s <= length bw * 2 && any (bw ==) fullNorm) blockedWords + -- Check if the string is a single word (no spaces) + isSingleWord = not $ T.any isSpace s + in wordCheck || (fullCheck && not isSingleWord) + +normalizeText :: Map Char [Char] -> Text -> [String] +normalizeText spelling = + filter (not . null) + . map (filter (\c -> not (isPunctuation c) && not (isMark c))) + . allSubstitutions spelling + . removeTriples + . T.unpack + . T.toLower + . TN.normalize TN.NFKD + +-- replaces triple and larger occurences with doubles +removeTriples :: String -> String +removeTriples xs = go xs '\0' False + where + go [] _ _ = [] + go (c : cs) prev samePrev + | prev /= c = c : go cs c False + | samePrev = go cs c True + | otherwise = c : go cs c True + +-- Generate all possible strings by substituting each character +allSubstitutions :: Map Char [Char] -> String -> [String] +allSubstitutions spelling = sequence . map substs + where + substs c = fromMaybe [c] $ M.lookup c spelling + +wordVariants :: [(String, [String])] -> String -> [String] +wordVariants [] s = [s] +wordVariants (sub : subs) s = concatMap (wordVariants subs) (replace sub) + where + replace (pat, tos) = go s + where + go [] = [""] + go s'@(c : rest) + | pat `isPrefixOf` s' = + let s'' = drop (length pat) s' + restVariants = go s'' + in map (pat <>) restVariants + <> concatMap (\to -> map (to <>) restVariants) tos + | otherwise = map (c :) (go rest) diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index a62939b6ac..277d332cf6 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -1,5 +1,6 @@ {-# LANGUAGE ApplicativeDo #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -12,9 +13,10 @@ module Directory.Options where import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Options.Applicative import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (updateStr, versionNumber, versionString) +import Simplex.Chat.Controller (AcceptAsObserver (..), updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, coreChatOptsP) data DirectoryOpts = DirectoryOpts @@ -22,6 +24,11 @@ data DirectoryOpts = DirectoryOpts adminUsers :: [KnownContact], superUsers :: [KnownContact], ownersGroup :: Maybe KnownGroup, + blockedWordsFile :: Maybe FilePath, + blockedExtensionRules :: Maybe FilePath, + nameSpellingFile :: Maybe FilePath, + profileNameLimit :: Int, + acceptAsObserver :: Maybe AcceptAsObserver, directoryLog :: Maybe FilePath, serviceName :: T.Text, runCLI :: Bool, @@ -55,6 +62,43 @@ directoryOpts appDir defaultDbName = do <> metavar "OWNERS_GROUP" <> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically" ) + blockedWordsFile <- + optional $ + strOption + ( long "blocked-words-file" + <> metavar "BLOCKED_WORDS_FILE" + <> help "File with the basic forms of words not allowed in profiles and groups" + ) + blockedExtensionRules <- + optional $ + strOption + ( long "blocked-extenstion-rules" + <> metavar "BLOCKED_EXTENSION_RULES" + <> help "Substitions to extend the list of blocked words" + ) + nameSpellingFile <- + optional $ + strOption + ( long "name-spelling-file" + <> metavar "NAME_SPELLING_FILE" + <> help "File with the character substitions to match in profile names" + ) + profileNameLimit <- + option + auto + ( long "profile-name-limit" + <> metavar "PROFILE_NAME_LIMIT" + <> help "Max length of profile name that will be allowed to connect and to join groups" + <> value maxBound + ) + acceptAsObserver <- + optional $ + option + parseAcceptAsObserver + ( long "accept-as-observer" + <> metavar "ACCEPT_AS_OBSERVER" + <> help "Whether to accept all or some of the joining members without posting rights ('all', 'no-image', 'incognito')" + ) directoryLog <- Just <$> strOption @@ -80,6 +124,11 @@ directoryOpts appDir defaultDbName = do adminUsers, superUsers, ownersGroup, + blockedWordsFile, + blockedExtensionRules, + nameSpellingFile, + profileNameLimit, + acceptAsObserver, directoryLog, serviceName = T.pack serviceName, runCLI, @@ -116,3 +165,12 @@ mkChatOpts DirectoryOpts {coreOptions} = markRead = False, maintenance = False } + +parseAcceptAsObserver :: ReadM AcceptAsObserver +parseAcceptAsObserver = eitherReader $ decodeAAO . encodeUtf8 . T.pack + where + decodeAAO = \case + "all" -> Right AOAll + "name-only" -> Right AONameOnly + "incognito" -> Right AOIncognito + _ -> Left "bad AcceptAsObserver" diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index ed51371be3..543ba2c84e 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} @@ -10,6 +11,7 @@ module Directory.Service ( welcomeGetOpts, directoryService, directoryServiceCLI, + directoryChatConfig ) where @@ -18,7 +20,10 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad +import Data.Composition ((.:)) +import Data.Containers.ListUtils (nubOrd) import Data.List (find, intercalate) +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S @@ -26,6 +31,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (diffUTCTime, getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) +import Directory.BlockedWords import Directory.Events import Directory.Options import Directory.Search @@ -97,8 +103,9 @@ directoryServiceCLI st opts = do env <- newServiceState eventQ <- newTQueueIO let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) + cfg <- directoryChatConfig opts race_ - (simplexChatCLI' terminalChatConfig {chatHooks = defaultChatHooks {eventHook}} (mkChatOpts opts) Nothing) + (simplexChatCLI' cfg {chatHooks = defaultChatHooks {eventHook}} (mkChatOpts opts) Nothing) (processEvents eventQ env) where processEvents eventQ env = forever $ do @@ -114,6 +121,16 @@ directoryService st opts@DirectoryOpts {testing} user cc = do (_, _, resp) <- atomically . readTBQueue $ outputQ cc directoryServiceEvent st opts env user cc resp +directoryChatConfig :: DirectoryOpts -> IO ChatConfig +directoryChatConfig DirectoryOpts {blockedWordsFile, nameSpellingFile, blockedExtensionRules, profileNameLimit, acceptAsObserver} = do + blockedWords <- mapM (fmap lines . readFile) blockedWordsFile + spelling <- maybe (pure M.empty) (fmap (M.fromList . read) . readFile) nameSpellingFile + extensionRules <- maybe (pure []) (fmap read . readFile) blockedExtensionRules + let !bws = nubOrd . concatMap (wordVariants extensionRules) <$> blockedWords + !allowedProfileName = not .: containsBlockedWords spelling <$> bws + putStrLn $ "Blocked words: " <> show (maybe 0 length bws) <> ", spelling rules: " <> show (M.size spelling) + pure terminalChatConfig {allowedProfileName, profileNameLimit, acceptAsObserver} + directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 2d7172133d..3f12ebd1af 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -407,6 +407,7 @@ executable simplex-directory-service default-extensions: StrictData other-modules: + Directory.BlockedWords Directory.Events Directory.Options Directory.Search @@ -427,6 +428,7 @@ executable simplex-directory-service , simplexmq >=6.3 , stm ==2.5.* , time ==1.12.* + , unicode-transforms ==0.4.* default-language: Haskell2010 if flag(client_postgres) cpp-options: -DdbPostgres @@ -517,6 +519,7 @@ test-suite simplex-chat-test , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* + , unicode-transforms ==0.4.* , unliftio ==0.2.* default-language: Haskell2010 if flag(client_postgres) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bf07e4ae51..89bd16b273 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -112,6 +112,9 @@ defaultChatConfig = ntf = _defaultNtfServers, netCfg = defaultNetworkConfig }, + allowedProfileName = Nothing, + profileNameLimit = maxBound, + acceptAsObserver = Nothing, tbqSize = 1024, fileChunkSize = 15780, -- do not change xftpDescrPartSize = 14000, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 31f158e780..5b1712b6b4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -137,6 +137,9 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, + allowedProfileName :: Maybe (ContactName -> Bool), + profileNameLimit :: Int, + acceptAsObserver :: Maybe AcceptAsObserver, tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -158,6 +161,11 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +data AcceptAsObserver + = AOAll -- all members + | AONameOnly -- members without image + | AOIncognito -- members with incognito-style names and without image + data RandomAgentServers = RandomAgentServers { smpServers :: NonEmpty (ServerCfg 'PSMP), xftpServers :: NonEmpty (ServerCfg 'PXFTP) @@ -481,7 +489,7 @@ data ChatCommand | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} | MemberRole GroupName ContactName GroupMemberRole | BlockForAll GroupName ContactName Bool - | RemoveMember GroupName ContactName + | RemoveMembers GroupName (NonEmpty ContactName) | LeaveGroup GroupName | DeleteGroup GroupName | ClearGroup GroupName diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 79ff9333c6..23f4406b9c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2116,7 +2116,16 @@ processChatCommand' vr = \case processChatCommand $ APIJoinGroup groupId enableNtfs MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMemberForAll gId gMemberId blocked - RemoveMember gName gMemberName -> withMemberName gName gMemberName APIRemoveMember + RemoveMembers gName gMemberNames -> withUser $ \user -> do + (gId, gMemberIds) <- withStore $ \db -> do + gId <- getGroupIdByName db user gName + gMemberIds <- forM gMemberNames $ getGroupMemberIdByName db user gId + pure (gId, gMemberIds) + rs <- forM (L.zip (L.fromList [1..]) gMemberIds) $ \(i, memId) -> do + r <- processChatCommand (APIRemoveMember gId memId) + when (i < length gMemberIds) $ toView r + pure r + pure $ L.last rs LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APILeaveGroup groupId @@ -3873,7 +3882,7 @@ chatCommandP = ("/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), - ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (L.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',')), ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP), ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP), ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 1530f8e5c4..120e74cb7a 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -48,7 +48,7 @@ import Simplex.Chat.Library.Internal import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events -import Simplex.Chat.ProfileGenerator (generateRandomProfile) +import Simplex.Chat.ProfileGenerator (generateRandomProfile, isRandomName) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -1290,8 +1290,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () where profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> CM () - profileContactRequest invId chatVRange p xContactId_ reqPQSup = do - withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case + profileContactRequest invId chatVRange p@Profile {displayName, image} xContactId_ reqPQSup = do + cfg <- asks config + withAllowedName cfg $ withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do @@ -1320,11 +1321,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo if v >= groupFastLinkJoinVersion then do - mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode + let useRole = userMemberRole gLinkMemRole $ acceptAsObserver cfg + mem <- acceptGroupJoinRequestAsync user gInfo cReq useRole profileMode createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo mem else messageError "processUserContactRequest: chat version range incompatible for accepting group join request" _ -> toView $ CRReceivedContactRequest user cReq + where + withAllowedName ChatConfig {profileNameLimit, allowedProfileName} action + | T.length displayName <= profileNameLimit && maybe True ($ displayName) allowedProfileName = action + | otherwise = liftIO $ putStrLn $ "Joining of " <> T.unpack displayName <> " is blocked" -- TODO send response, maybe event to UI? + userMemberRole linkRole = \case + Just AOAll -> GRObserver + Just AONameOnly | noImage -> GRObserver + Just AOIncognito | noImage && isRandomName displayName -> GRObserver + _ -> linkRole + where + noImage = maybe True (\(ImageData i) -> i == "") image memberCanSend :: GroupMember -> CM () -> CM () memberCanSend GroupMember {memberRole} a diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index 95f5f16207..8380bb58f1 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -2,6 +2,8 @@ module Simplex.Chat.ProfileGenerator where +import qualified Data.Attoparsec.Text as A +import Data.Either (isRight) import Data.Text (Text) import Simplex.Chat.Types (Profile (..)) import System.Random (randomRIO) @@ -23,6 +25,15 @@ generateRandomProfile = do then pickNoun adjective (n - 1) else pure noun +-- This function does not check for exact match with this disctionary, +-- it only checks for the WordWord style. +isRandomName :: Text -> Bool +isRandomName = isRight . A.parseOnly randomNameP + where + randomNameP = A.satisfy upper >> A.takeWhile1 lower >> A.satisfy upper >> A.takeWhile1 lower >> A.endOfInput + upper c = c >= 'A' && c <= 'Z' + lower c = c >= 'a' && c <= 'z' + adjectives :: [Text] adjectives = [ "Abatic", diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 9c8c044630..c19e89a776 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -326,6 +326,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json + -- XGrpLinkReject :: GroupProfile -> RejectionReason -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index dce5ca0a79..d43f7169ea 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -141,9 +141,11 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, -- 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.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 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 204689a7b9..bddbdbcce5 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -173,11 +173,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)) +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) 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 $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) +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 _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -281,9 +281,11 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, -- 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, 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, @@ -469,7 +471,9 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberContactId = Just $ contactId' userOrContact, memberContactProfileId = localProfileId (profile' userOrContact), activeConn = Nothing, - memberChatVRange + memberChatVRange, + createdAt, + updatedAt = createdAt } where memberChatVRange@(VersionRange minV maxV) = vr @@ -744,7 +748,8 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do 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, 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.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 FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -808,6 +813,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, 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, @@ -975,7 +981,9 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, memberContactId = Just contactId, memberContactProfileId = localProfileId profile, activeConn = Nothing, - memberChatVRange = peerChatVRange + memberChatVRange = peerChatVRange, + createdAt, + updatedAt = createdAt } where insertMember_ = @@ -1257,7 +1265,9 @@ createNewMember_ memberContactId, memberContactProfileId, activeConn, - memberChatVRange + memberChatVRange, + createdAt, + updatedAt = createdAt } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1509,9 +1519,11 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, -- 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, 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 f37201c487..22a3aadd34 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -515,7 +515,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe -- 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 + p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at 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 @@ -2521,16 +2522,19 @@ 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, -- 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, -- 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 + dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, + dbm.created_at, dbm.updated_at FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_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 041adbec1d..0adc91d0d4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -42,9 +42,11 @@ Query: mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, -- 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.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 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 @@ -555,7 +557,8 @@ Query: -- 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 + p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at 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 @@ -704,16 +707,19 @@ 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, -- 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, -- 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 + dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, + dbm.created_at, dbm.updated_at FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id @@ -790,9 +796,11 @@ Query: mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, -- 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, 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, @@ -829,7 +837,8 @@ Query: 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, 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.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 FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -4415,7 +4424,8 @@ Query: -- 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 + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at 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 @@ -4436,7 +4446,8 @@ Query: -- 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 + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at 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 @@ -4452,6 +4463,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, 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, @@ -4483,6 +4495,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, 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, @@ -4506,6 +4519,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, 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, @@ -4529,6 +4543,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, 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, @@ -4552,6 +4567,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, 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, @@ -4575,6 +4591,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, 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/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index a28f4251f9..a556fd0a49 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -579,7 +579,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) :. (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)) +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) 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) = @@ -591,7 +591,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} 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)) = +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)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -615,7 +615,8 @@ groupInfoQuery = -- 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 + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at 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 89f8c94d51..e2ee71636b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -784,7 +784,9 @@ data GroupMember = GroupMember -- member chat protocol version range; if member has active connection, its version range is preferred; -- for membership current supportedChatVRange is set, it's not updated on protocol version increase in database, -- but it's correctly set on read (see toGroupInfo) - memberChatVRange :: VersionRangeChat + memberChatVRange :: VersionRangeChat, + createdAt :: UTCTime, + updatedAt :: UTCTime } deriving (Eq, Show) diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 6994a8528a..5bdc379cbf 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -87,6 +87,11 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = superUsers, ownersGroup, directoryLog = Just $ ps "directory_service.log", + blockedWordsFile = Nothing, + blockedExtensionRules = Nothing, + nameSpellingFile = Nothing, + profileNameLimit = maxBound, + acceptAsObserver = Nothing, serviceName = "SimpleX-Directory", runCLI = False, searchResults = 3, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 248d6ead47..03671d1d94 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -390,15 +390,15 @@ withTmpFiles = (removeDirectoryRecursive "tests/tmp") testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> TestParams -> IO () -testChatN cfg opts ps test params = do - tcs <- getTestCCs (zip ps [1 ..]) [] - test tcs - concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' tcs + entTests tcs = do + concurrentlyN_ $ map ( TestCC -> Int -> Expectation ( Date: Tue, 25 Feb 2025 14:05:49 +0400 Subject: [PATCH 088/289] core: communicate group join rejection (#5661) --- src/Simplex/Chat/Controller.hs | 12 ++++ src/Simplex/Chat/Library/Commands.hs | 3 +- src/Simplex/Chat/Library/Internal.hs | 60 ++++++++++++++----- src/Simplex/Chat/Library/Subscriber.hs | 52 ++++++++++------ src/Simplex/Chat/Protocol.hs | 15 ++++- src/Simplex/Chat/Store/Groups.hs | 46 +++++++++----- .../SQLite/Migrations/chat_query_plans.txt | 19 +++++- src/Simplex/Chat/Types.hs | 51 +++++++++++++++- src/Simplex/Chat/View.hs | 7 ++- tests/ChatTests/Groups.hs | 30 ++++++++++ tests/ProtocolTests.hs | 8 +-- 11 files changed, 246 insertions(+), 57 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5b1712b6b4..e64553af98 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -844,6 +844,12 @@ data ChatResponse | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} | CRCustomChatResponse {user_ :: Maybe User, response :: Text} + | CRTerminalEvent TerminalEvent + deriving (Show) + +data TerminalEvent + = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} + | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} deriving (Show) data DeletedRcvQueue = DeletedRcvQueue @@ -1491,6 +1497,10 @@ chatCmdError user = CRChatCmdError user . ChatError . CECommandError throwChatError :: ChatErrorType -> CM a throwChatError = throwError . ChatError +toViewTE :: TerminalEvent -> CM () +toViewTE = toView . CRTerminalEvent +{-# INLINE toViewTE #-} + -- | Emit local events. toView :: ChatResponse -> CM () toView = lift . toView' @@ -1630,6 +1640,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 23f4406b9c..624f6a6dc1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2621,7 +2621,7 @@ processChatCommand' vr = \case contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> - cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft + cId == Just contactId && s /= GSMemRejected && s /= GSMemRemoved && s /= GSMemLeft checkSndFile :: CryptoFile -> CM Integer checkSndFile (CryptoFile f cfArgs) = do fsFilePath <- lift $ toFSFilePath f @@ -2969,6 +2969,7 @@ processChatCommand' vr = \case (Just gInfo, _) -> groupPlan gInfo where groupPlan gInfo@GroupInfo {membership} + | memberStatus membership == GSMemRejected = pure $ CPGroupLink (GLPKnown gInfo) | not (memberActive membership) && not (memberRemoved membership) = pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo) | memberActive membership = pure $ CPGroupLink (GLPKnown gInfo) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 3e96e1e496..4a62c4ccb6 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -218,10 +218,11 @@ prepareGroupMsg db user g@GroupInfo {membership} mc mentions quotedItemId_ itemF updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) updatedMentionNames mc ft_ mentions = case ft_ of - Just ft | not (null ft) && not (null mentions) && not (all sameName $ M.assocs mentions) -> - let (mentions', ft') = mapAccumL update M.empty ft - text = T.concat $ map markdownText ft' - in (mc {text} :: MsgContent, Just ft', mentions') + Just ft + | not (null ft) && not (null mentions) && not (all sameName $ M.assocs mentions) -> + let (mentions', ft') = mapAccumL update M.empty ft + text = T.concat $ map markdownText ft' + in (mc {text} :: MsgContent, Just ft', mentions') _ -> (mc, ft_, mentions) where sameName (name, CIMention {memberRef}) = case memberRef of @@ -261,9 +262,10 @@ getCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of getRcvCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MsgMention -> IO (Map MemberName CIMention) getRcvCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of - Just ft | not (null ft) && not (null mentions) -> - let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft - in mapM (getMentionedMemberByMemberId db user groupId) mentions' + Just ft + | not (null ft) && not (null mentions) -> + let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft + in mapM (getMentionedMemberByMemberId db user groupId) mentions' _ -> pure M.empty -- prevent "invisible" and repeated-with-different-name mentions @@ -274,8 +276,9 @@ uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 go acc seen n (name : rest) | n >= maxMentions = acc | otherwise = case M.lookup name mentions of - Just mm@MsgMention {memberId} | S.notMember memberId seen -> - go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest + Just mm@MsgMention {memberId} + | S.notMember memberId seen -> + go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest _ -> go acc seen n rest getMessageMentions :: DB.Connection -> User -> GroupId -> Text -> IO (Map MemberName GroupMemberId) @@ -827,7 +830,7 @@ acceptGroupJoinRequestAsync gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> do liftIO $ deleteContactRequestRec db user ucr - createAcceptedMember db gVar user gInfo ucr gLinkMemRole + createJoiningMember db gVar user gInfo ucr gLinkMemRole GSMemAccepted currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let Profile {displayName} = profileToSendOnAccept user incognitoProfile True GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -846,7 +849,34 @@ acceptGroupJoinRequestAsync let chatV = vr `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do - liftIO $ createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode + liftIO $ createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode + getGroupMemberById db vr user groupMemberId + +acceptGroupJoinSendRejectAsync :: User -> GroupInfo -> UserContactRequest -> GroupRejectionReason -> CM GroupMember +acceptGroupJoinSendRejectAsync + user + gInfo@GroupInfo {groupProfile, membership} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} + rejectionReason = do + gVar <- asks random + (groupMemberId, memberId) <- withStore $ \db -> do + liftIO $ deleteContactRequestRec db user ucr + createJoiningMember db gVar user gInfo ucr GRObserver GSMemRejected + let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + msg = + XGrpLinkReject $ + GroupLinkRejection + { fromMember = MemberIdRole userMemberId userRole, + invitedMember = MemberIdRole memberId GRObserver, + groupProfile, + rejectionReason + } + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` cReqChatVRange + connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV + withStore $ \db -> do + liftIO $ createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode getGroupMemberById db vr user groupMemberId acceptBusinessJoinRequestAsync :: User -> UserContactRequest -> CM GroupInfo @@ -879,7 +909,7 @@ acceptBusinessJoinRequestAsync subMode <- chatReadVar subscriptionMode let chatV = vr `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV - withStore' $ \db -> createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode + withStore' $ \db -> createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode let cd = CDGroupSnd gInfo createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo @@ -1514,7 +1544,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do (subtract 1 <$> memIdx_,) $ snd $ foldr' addBody (lastRef, memIdsReqs) mbs where addBody :: Either ChatError a -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) - addBody mb (i, (memIds, reqs)) = + addBody mb (i, (memIds, reqs)) = let req = (conn,msgFlags,) . mkMb memIdx_ i <$> mb in (i - 1, (groupMemberId : memIds, req : reqs)) sndMessageMBR :: Maybe Int -> Int -> SndMessage -> (ValueOrRef MsgBody, [MessageId]) @@ -1542,10 +1572,10 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction -memberSendAction gInfo events members m@GroupMember {memberRole} = case memberConn m of +memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = case memberConn m of Nothing -> pendingOrForwarded Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted -> Nothing + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing | connInactive conn -> Just MSAPending | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn | otherwise -> pendingOrForwarded diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 120e74cb7a..44ad4ccc85 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -22,7 +22,6 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import qualified Data.Aeson as J -import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (lefts, partitionEithers, rights) @@ -764,11 +763,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO update member profile pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" - XInfo _ -> pure () -- sent when connecting via group link + -- sent when connecting via group link + XInfo _ -> + -- TODO [group rejection] Keep rejected member record and connection for ability to start dialogue. + when (memberStatus m == GSMemRejected) $ do + deleteMemberConnection' user m True + withStore' $ \db -> deleteGroupMember db user m XOk -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON _pqEnc -> do + CON _pqEnc -> unless (memberStatus m == GSMemRejected) $ do withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ @@ -1291,8 +1295,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> CM () profileContactRequest invId chatVRange p@Profile {displayName, image} xContactId_ reqPQSup = do - cfg <- asks config - withAllowedName cfg $ withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case + withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do @@ -1318,19 +1321,29 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if v >= groupFastLinkJoinVersion - then do - let useRole = userMemberRole gLinkMemRole $ acceptAsObserver cfg - mem <- acceptGroupJoinRequestAsync user gInfo cReq useRole profileMode - createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else messageError "processUserContactRequest: chat version range incompatible for accepting group join request" + cfg <- asks config + case rejectionReason cfg of + Nothing + | v < groupFastLinkJoinVersion -> + messageError "processUserContactRequest: chat version range incompatible for accepting group join request" + | otherwise -> do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + useRole = userMemberRole gLinkMemRole $ acceptAsObserver cfg + mem <- acceptGroupJoinRequestAsync user gInfo cReq useRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + Just rjctReason + | v < groupJoinRejectVersion -> + messageWarning $ "processUserContactRequest (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" + | otherwise -> do + mem <- acceptGroupJoinSendRejectAsync user gInfo cReq rjctReason + toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason _ -> toView $ CRReceivedContactRequest user cReq where - withAllowedName ChatConfig {profileNameLimit, allowedProfileName} action - | T.length displayName <= profileNameLimit && maybe True ($ displayName) allowedProfileName = action - | otherwise = liftIO $ putStrLn $ "Joining of " <> T.unpack displayName <> " is blocked" -- TODO send response, maybe event to UI? + rejectionReason ChatConfig {profileNameLimit, allowedProfileName} + | T.length displayName > profileNameLimit = Just GRRLongName + | maybe False (\f -> not $ f displayName) allowedProfileName = Just GRRBlockedName + | otherwise = Nothing userMemberRole linkRole = \case Just AOAll -> GRObserver Just AONameOnly | noImage -> GRObserver @@ -2475,6 +2488,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv toView $ CRGroupLinkConnecting user gInfo host pure (conn', True) + XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do + (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct + toView $ CRGroupLinkConnecting user gInfo host + toViewTE $ TEGroupLinkRejected user gInfo rejectionReason + pure (conn', True) -- TODO show/log error, other events in SMP confirmation _ -> pure (conn', False) @@ -2828,7 +2846,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createUnknownMember :: GroupInfo -> MemberId -> CM GroupMember createUnknownMember gInfo memberId = do - let name = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId $ memberId + let name = nameFromMemberId memberId withStore $ \db -> createNewUnknownGroupMember db vr user gInfo memberId name directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index c19e89a776..94e08a0897 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -74,12 +74,13 @@ import Simplex.Messaging.Version hiding (version) -- 10 - business chats (2024-11-29) -- 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) -- 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 12 +currentChatVersion = VersionChat 14 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -130,6 +131,10 @@ businessChatPrefsVersion = VersionChat 11 contentReportsVersion :: VersionChat contentReportsVersion = VersionChat 12 +-- support sending and receiving group join rejection (XGrpLinkReject) +groupJoinRejectVersion :: VersionChat +groupJoinRejectVersion = VersionChat 14 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -326,7 +331,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json - -- XGrpLinkReject :: GroupProfile -> RejectionReason -> ChatMsgEvent 'Json + XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json @@ -816,6 +821,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json XGrpLinkInv_ :: CMEventTag 'Json + XGrpLinkReject_ :: CMEventTag 'Json XGrpLinkMem_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json @@ -867,6 +873,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" XGrpLinkInv_ -> "x.grp.link.inv" + XGrpLinkReject_ -> "x.grp.link.reject" XGrpLinkMem_ -> "x.grp.link.mem" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" @@ -919,6 +926,7 @@ instance StrEncoding ACMEventTag where "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ "x.grp.link.inv" -> XGrpLinkInv_ + "x.grp.link.reject" -> XGrpLinkReject_ "x.grp.link.mem" -> XGrpLinkMem_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ @@ -967,6 +975,7 @@ toCMEventTag msg = case msg of XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ XGrpLinkInv _ -> XGrpLinkInv_ + XGrpLinkReject _ -> XGrpLinkReject_ XGrpLinkMem _ -> XGrpLinkMem_ XGrpMemNew _ -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ @@ -1068,6 +1077,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" + XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" @@ -1130,6 +1140,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] + XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index bddbdbcce5..67722ebd0f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -33,6 +33,7 @@ module Simplex.Chat.Store.Groups createGroupInvitation, deleteContactCardKeepConn, createGroupInvitedViaLink, + createGroupRejectedViaLink, setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, @@ -67,8 +68,8 @@ module Simplex.Chat.Store.Groups getGroupInvitation, createNewContactMember, createNewContactMemberAsync, - createAcceptedMember, - createAcceptedMemberConnection, + createJoiningMember, + createJoiningMemberConnection, createBusinessRequestGroup, getContactViaMember, setNewContactMemberConnRequest, @@ -519,18 +520,33 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupInvitedViaLink +createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, business} = do + let fromMemberProfile = profileFromName fromMemberName + createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business GSMemAccepted + +createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupRejectedViaLink db vr user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do + let fromMemberProfile = profileFromName $ nameFromMemberId memberId + createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + +createGroupViaLink' :: DB.Connection -> VersionRangeChat -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupViaLink' db vr user@User {userId, userContactId} Connection {connId, customUserProfileId} - GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, business} = do + fromMember + fromMemberProfile + invitedMember + groupProfile + business + memStatus = 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 GSMemAccepted IBUnknown customUserProfileId currentTs vr + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember memStatus IBUnknown customUserProfileId currentTs vr liftIO $ setViaGroupLinkHash db groupId connId (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where @@ -554,7 +570,6 @@ createGroupInvitedViaLink ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db insertHost_ currentTs groupId = do - let fromMemberProfile = profileFromName fromMemberName (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember liftIO $ do @@ -566,7 +581,7 @@ createGroupInvitedViaLink user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, memberId, memberRole, GCHostMember, memStatus, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -783,9 +798,9 @@ getGroupSummary db User {userId} groupId = do JOIN group_members m USING (group_id) WHERE g.user_id = ? AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?) + AND m.member_status NOT IN (?,?,?,?,?) |] - (userId, groupId, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) + (userId, groupId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] @@ -1026,14 +1041,15 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) -createAcceptedMember +createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} UserContactRequest {cReqChatVRange, localDisplayName, profileId} - memberRole = + memberRole + memberStatus = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt @@ -1051,13 +1067,13 @@ createAcceptedMember peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) :. (minV, maxV) ) -createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () -createAcceptedMemberConnection +createJoiningMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createJoiningMemberConnection db user@User {userId} (cmdId, agentConnId) 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 0adc91d0d4..72f8e4b8fd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1012,7 +1012,7 @@ Query: JOIN group_members m USING (group_id) WHERE g.user_id = ? AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?) + AND m.member_status NOT IN (?,?,?,?,?) Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) @@ -5025,6 +5025,15 @@ SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=? SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +Query: DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ? +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + Query: DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) @@ -5496,6 +5505,10 @@ Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?) +Query: SELECT count(1) FROM group_members +Plan: +SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id + Query: SELECT count(1) FROM pending_group_messages Plan: SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id @@ -5532,6 +5545,10 @@ Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contac Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ? Plan: SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e2ee71636b..94b67d8349 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -26,14 +26,17 @@ module Simplex.Chat.Types where +import Control.Applicative ((<|>)) import Crypto.Number.Serialize (os2ip) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString, pack, unpack) import qualified Data.ByteString.Lazy as LB +import Data.Functor (($>)) import Data.Int (Int64) import Data.Maybe (isJust) import Data.Text (Text) @@ -670,6 +673,41 @@ data GroupLinkInvitation = GroupLinkInvitation } deriving (Eq, Show) +data GroupLinkRejection = GroupLinkRejection + { fromMember :: MemberIdRole, + invitedMember :: MemberIdRole, + groupProfile :: GroupProfile, + rejectionReason :: GroupRejectionReason + } + deriving (Eq, Show) + +data GroupRejectionReason + = GRRLongName + | GRRBlockedName + | GRRUnknown {text :: Text} + deriving (Eq, Show) + +instance FromField GroupRejectionReason where fromField = blobFieldDecoder strDecode + +instance ToField GroupRejectionReason where toField = toField . strEncode + +instance StrEncoding GroupRejectionReason where + strEncode = \case + GRRLongName -> "long_name" + GRRBlockedName -> "blocked_name" + GRRUnknown text -> encodeUtf8 text + strP = + "long_name" $> GRRLongName + <|> "blocked_name" $> GRRBlockedName + <|> GRRUnknown . safeDecodeUtf8 <$> A.takeByteString + +instance FromJSON GroupRejectionReason where + parseJSON = strParseJSON "GroupRejectionReason" + +instance ToJSON GroupRejectionReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole @@ -862,6 +900,9 @@ instance ToJSON MemberId where toJSON = strToJSON toEncoding = strToJEncoding +nameFromMemberId :: MemberId -> ContactName +nameFromMemberId = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId + data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show) @@ -950,7 +991,8 @@ instance TextEncoding GroupMemberCategory where GCPostMember -> "post" data GroupMemberStatus - = GSMemRemoved -- member who was removed from the group + = GSMemRejected -- joining member who was rejected by the host, or host that rejected the join + | GSMemRemoved -- member who was removed from the group | GSMemLeft -- member who left the group | GSMemGroupDeleted -- user member of the deleted group | 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) @@ -977,6 +1019,7 @@ instance ToJSON GroupMemberStatus where memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of + GSMemRejected -> False GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False @@ -996,6 +1039,7 @@ memberCurrent = memberCurrent' . memberStatus -- update getGroupSummary if this is changed memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case + GSMemRejected -> False GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False @@ -1011,6 +1055,7 @@ memberCurrent' = \case memberRemoved :: GroupMember -> Bool memberRemoved m = case memberStatus m of + GSMemRejected -> True GSMemRemoved -> True GSMemLeft -> True GSMemGroupDeleted -> True @@ -1026,6 +1071,7 @@ memberRemoved m = case memberStatus m of instance TextEncoding GroupMemberStatus where textDecode = \case + "rejected" -> Just GSMemRejected "removed" -> Just GSMemRemoved "left" -> Just GSMemLeft "deleted" -> Just GSMemGroupDeleted @@ -1040,6 +1086,7 @@ instance TextEncoding GroupMemberStatus where "creator" -> Just GSMemCreator _ -> Nothing textEncode = \case + GSMemRejected -> "rejected" GSMemRemoved -> "removed" GSMemLeft -> "left" GSMemGroupDeleted -> "deleted" @@ -1793,6 +1840,8 @@ $(JQ.deriveJSON defaultJSON ''GroupInvitation) $(JQ.deriveJSON defaultJSON ''GroupLinkInvitation) +$(JQ.deriveJSON defaultJSON ''GroupLinkRejection) + $(JQ.deriveJSON defaultJSON ''IntroInvitation) $(JQ.deriveJSON defaultJSON ''MemberRestrictions) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 69f9799948..a8c2d215a8 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -444,6 +444,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRAppSettings as -> ["app settings: " <> viewJSON as] CRTimedAction _ _ -> [] CRCustomChatResponse u r -> ttyUser' u $ map plain $ T.lines r + 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] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss @@ -1128,7 +1131,7 @@ showRole = plain . strEncode viewGroupMembers :: Group -> [StyledString] viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members where - removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft + removedOrLeft m = let s = memberStatus m in s == GSMemRejected || s == GSMemRemoved || s == GSMemLeft groupMember m = memIncognito m <> ttyFullMember m <> ": " <> plain (intercalate ", " $ [role m] <> category m <> status m <> muted m) role :: GroupMember -> String role GroupMember {memberRole} = B.unpack $ strEncode memberRole @@ -1138,6 +1141,7 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt GCHostMember -> ["host"] _ -> [] status m = case memberStatus m of + GSMemRejected -> ["rejected"] GSMemRemoved -> ["removed"] GSMemLeft -> ["left"] GSMemUnknown -> ["status unknown"] @@ -1178,6 +1182,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g where viewMemberStatus = \case + GSMemRejected -> delete "you are rejected" GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bdb3b443b3..6625fb8094 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -98,6 +98,7 @@ chatGroupTests = do it "group link member role" testGroupLinkMemberRole it "host profile received" testGroupLinkHostProfileReceived it "existing contact merged" testGroupLinkExistingContactMerged + it "reject member joining via group link - blocked name" testGroupLinkRejectBlockedName describe "group link connection plan" $ do it "ok to connect; known group" testPlanGroupLinkKnown it "own group link" testPlanGroupLinkOwn @@ -2871,6 +2872,35 @@ testGroupLinkExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" +testGroupLinkRejectBlockedName :: HasCallStack => TestParams -> IO () +testGroupLinkRejectBlockedName = + testChatCfg2 cfg aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): rejecting request to join group #team, reason: GRRBlockedName" + bob <## "#team: joining the group..." + bob <## "#team: join rejected, reason: GRRBlockedName" + + threadDelay 100000 + + alice `hasContactProfiles` ["alice"] + memCount <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM group_members" :: IO [[Int]] + memCount `shouldBe` [[1]] + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + where + cfg = testCfg {allowedProfileName = Just (const False)} + testPlanGroupLinkKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkKnown = testChat2 aliceProfile bobProfile $ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index cb293895a9..83087a89a8 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-12\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-14\",\"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\"}}}}" @@ -249,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-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} 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-12\",\"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-14\",\"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-12\",\"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-14\",\"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 1fcb352db471b583cafe9e94e8adc65b53878765 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 25 Feb 2025 19:15:56 +0400 Subject: [PATCH 089/289] ui: rejected group previews (#5665) --- apps/ios/Shared/Views/ChatList/ChatPreviewView.swift | 4 +++- apps/ios/SimpleXChat/ChatTypes.swift | 5 +++++ .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 5 +++++ .../chat/simplex/common/views/chatlist/ChatPreviewView.kt | 4 +++- .../common/src/commonMain/resources/MR/base/strings.xml | 2 ++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 6266395905..6969ae325c 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -143,6 +143,7 @@ struct ChatPreviewView: View { } case let .group(groupInfo): switch (groupInfo.membership.memberStatus) { + case .memRejected: inactiveIcon() case .memLeft: inactiveIcon() case .memRemoved: inactiveIcon() case .memGroupDeleted: inactiveIcon() @@ -168,7 +169,7 @@ struct ChatPreviewView: View { let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary) - case .memAccepted: v.foregroundColor(theme.colors.secondary) + case .memAccepted, .memRejected: v.foregroundColor(theme.colors.secondary) default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v } } default: previewTitle(t) @@ -336,6 +337,7 @@ struct ChatPreviewView: View { } case let .group(groupInfo): switch (groupInfo.membership.memberStatus) { + case .memRejected: chatPreviewInfoText("rejected") case .memInvited: groupInvitationPreviewText(groupInfo) case .memAccepted: chatPreviewInfoText("connecting…") default: EmptyView() diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 554207e90d..169a7c21de 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2141,6 +2141,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberActive: Bool { switch memberStatus { + case .memRejected: return false case .memRemoved: return false case .memLeft: return false case .memGroupDeleted: return false @@ -2158,6 +2159,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberCurrent: Bool { switch memberStatus { + case .memRejected: return false case .memRemoved: return false case .memLeft: return false case .memGroupDeleted: return false @@ -2288,6 +2290,7 @@ public enum GroupMemberCategory: String, Decodable, Hashable { } public enum GroupMemberStatus: String, Decodable, Hashable { + case memRejected = "rejected" case memRemoved = "removed" case memLeft = "left" case memGroupDeleted = "deleted" @@ -2303,6 +2306,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { public var text: LocalizedStringKey { switch self { + case .memRejected: return "rejected" case .memRemoved: return "removed" case .memLeft: return "left" case .memGroupDeleted: return "group deleted" @@ -2320,6 +2324,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { public var shortText: LocalizedStringKey { switch self { + case .memRejected: return "rejected" case .memRemoved: return "removed" case .memLeft: return "left" case .memGroupDeleted: return "group deleted" 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 6424397531..7ef8123d2f 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 @@ -1910,6 +1910,7 @@ data class GroupMember ( } val memberActive: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemRejected -> false GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false GroupMemberStatus.MemGroupDeleted -> false @@ -1925,6 +1926,7 @@ data class GroupMember ( } val memberCurrent: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemRejected -> false GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false GroupMemberStatus.MemGroupDeleted -> false @@ -2028,6 +2030,7 @@ enum class GroupMemberCategory { @Serializable enum class GroupMemberStatus { + @SerialName("rejected") MemRejected, @SerialName("removed") MemRemoved, @SerialName("left") MemLeft, @SerialName("deleted") MemGroupDeleted, @@ -2042,6 +2045,7 @@ enum class GroupMemberStatus { @SerialName("creator") MemCreator; val text: String get() = when (this) { + MemRejected -> generalGetString(MR.strings.group_member_status_rejected) MemRemoved -> generalGetString(MR.strings.group_member_status_removed) MemLeft -> generalGetString(MR.strings.group_member_status_left) MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) @@ -2057,6 +2061,7 @@ enum class GroupMemberStatus { } val shortText: String get() = when (this) { + MemRejected -> generalGetString(MR.strings.group_member_status_rejected) MemRemoved -> generalGetString(MR.strings.group_member_status_removed) MemLeft -> generalGetString(MR.strings.group_member_status_left) MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) 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 d379131171..93d512507a 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 @@ -70,6 +70,7 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemRejected -> inactiveIcon() GroupMemberStatus.MemLeft -> inactiveIcon() GroupMemberStatus.MemRemoved -> inactiveIcon() GroupMemberStatus.MemGroupDeleted -> inactiveIcon() @@ -156,7 +157,7 @@ fun ChatPreviewView( else if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary ) - GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary) + GroupMemberStatus.MemAccepted, GroupMemberStatus.MemRejected -> chatPreviewTitleText(MaterialTheme.colors.secondary) else -> chatPreviewTitleText( if (deleting) MaterialTheme.colors.secondary @@ -237,6 +238,7 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { + 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) else -> {} 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 1d445f710f..aa0f49ee1d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -418,6 +418,7 @@ send direct message you are invited to group join as %s + rejected connecting… Tap to start a new chat Chat with the developers @@ -1622,6 +1623,7 @@ owner + rejected removed left group deleted From b482d4d8127d064aab0bea6437c0d98ee33c4a11 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 27 Feb 2025 07:38:40 +0000 Subject: [PATCH 090/289] core: fix search in chat (#5677) --- src/Simplex/Chat/Store/Messages.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 22a3aadd34..1902fd002e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1260,7 +1260,7 @@ getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range cou (orParams ts itemId) " ORDER BY item_ts DESC, chat_item_id DESC " where - orCond c1 c2 = " (" <> c <> " AND " <> c1 <> ") OR (" <> c <> " AND " <> c2 <> ") " + orCond c1 c2 = " ((" <> c <> " AND " <> c1 <> ") OR (" <> c <> " AND " <> c2 <> ")) " orParams ts itemId = (p :. (Only ts) :. p :. (ts, itemId)) rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] rangeQuery c p ob From fefddb3b5a6240df853a2bdecf8b767bfbc4ad91 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 28 Feb 2025 21:45:24 +0700 Subject: [PATCH 091/289] ios: go to forwarded item or search result (#5679) * ios: go to forwarded item or search result * react on touch * changes --- apps/ios/Shared/Model/ChatModel.swift | 17 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 24 ++- .../Shared/Views/Chat/ChatScrollHelpers.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 182 +++++++++++++----- .../Chat/ComposeMessage/ComposeView.swift | 4 + .../ComposeMessage/NativeTextEditor.swift | 9 +- .../Chat/ComposeMessage/SendMessageView.swift | 8 +- .../Shared/Views/Chat/EndlessScrollView.swift | 24 ++- apps/ios/Shared/Views/TerminalView.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 14 ++ 11 files changed, 228 insertions(+), 64 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 9171bf5073..a1c5a55c3b 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -97,6 +97,22 @@ class ItemsModel: ObservableObject { } } } + + func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { + navigationTimeoutTask?.cancel() + loadChatTask?.cancel() + loadChatTask = Task { + // try? await Task.sleep(nanoseconds: 1000_000000) + await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) + if !Task.isCancelled { + await MainActor.run { + if openAroundItemId == nil { + ChatModel.shared.chatId = chatId + } + } + } + } + } } class ChatTagsModel: ObservableObject { @@ -259,6 +275,7 @@ final class ChatModel: ObservableObject { @Published var deletedChats: Set = [] // current chat @Published var chatId: String? + @Published var openAroundItemId: ChatItem.ID? = nil var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0ef2a87aa7..354f8243fc 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -338,7 +338,7 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) } -func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async { +func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { let m = ChatModel.shared let im = ItemsModel.shared m.chatItemStatuses = [:] @@ -348,7 +348,7 @@ func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) asyn ItemsModel.shared.chatItemsChangesListener.cleared() } } - await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 }) + await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 }) } func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 4253a4f1e4..add28cd7f9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -16,6 +16,7 @@ func apiLoadMessages( _ pagination: ChatPagination, _ chatState: ActiveChatState, _ search: String = "", + _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } ) async { let chat: Chat @@ -32,7 +33,8 @@ func apiLoadMessages( // 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 let paginationIsInitial = switch pagination { case .initial: true; default: false } let paginationIsLast = switch pagination { case .last: true; default: false } - if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled { + // When openAroundItemId is provided, chatId can be different too + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled { return } @@ -102,8 +104,13 @@ func apiLoadMessages( } } case .around: - newItems.append(contentsOf: oldItems) - let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + let newSplits: [Int64] + if openAroundItemId == nil { + newItems.append(contentsOf: oldItems) + newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + } else { + newSplits = [] + } // currently, items will always be added on top, which is index 0 newItems.insert(contentsOf: chat.chatItems, at: 0) let newReversed: [ChatItem] = newItems.reversed() @@ -114,8 +121,15 @@ func apiLoadMessages( chatState.totalAfter = navInfo.afterTotal chatState.unreadTotal = chat.chatStats.unreadCount chatState.unreadAfter = navInfo.afterUnread - // no need to set it, count will be wrong - // unreadAfterNewestLoaded.value = navInfo.afterUnread + + if let openAroundItemId { + chatState.unreadAfterNewestLoaded = navInfo.afterUnread + ChatModel.shared.openAroundItemId = openAroundItemId + ChatModel.shared.chatId = chatId + } else { + // no need to set it, count will be wrong + // chatState.unreadAfterNewestLoaded = navInfo.afterUnread + } } case .last: newItems.append(contentsOf: oldItems) diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index 094c6e13e1..873f24d5c3 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -46,7 +46,7 @@ func preloadIfNeeded( loadItems: @escaping (Bool, ChatPagination) async -> Bool ) { let state = PreloadState.shared - guard !listState.isScrolling, + guard !listState.isScrolling && !listState.isAnimatedScrolling, state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count, !state.preloading, listState.totalItemsCount > 0 diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index f3bfa294b4..90c9629352 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -29,6 +29,7 @@ struct ChatView: View { @State private var composeState = ComposeState() @State private var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? @@ -37,7 +38,7 @@ struct ChatView: View { @State private var requestedTopScroll = false @State private var loadingBottomItems = false @State private var requestedBottomScroll = false - @State private var searchMode = false + @State private var showSearch = false @State private var searchText: String = "" @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @@ -54,11 +55,8 @@ struct ChatView: View { @State private var allowLoadMoreItems: Bool = false @State private var ignoreLoadingRequests: Int64? = nil @State private var animatedScrollingInProgress: Bool = false - @State private var updateMergedItemsTask: Task? = nil @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() - private let useItemsUpdateTask = false - @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @@ -101,6 +99,7 @@ struct ChatView: View { chat: chat, composeState: $composeState, keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, selectedRange: $selectedRange ) .disabled(!cInfo.sendMsgEnabled) @@ -131,7 +130,7 @@ struct ChatView: View { } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { - if searchMode { searchToolbar() } + if showSearch { searchToolbar() } Divider() } .background(ToolbarMaterial.material(toolbarMaterial)) @@ -232,15 +231,45 @@ struct ChatView: View { scrollView.listState.onUpdateListener = onChatItemsUpdated initChatView() theme = buildTheme() - if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { + closeSearch() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + + if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { scrollView.scrollToItem(unreadIndex) } else { scrollView.scrollToBottom() } + if chatModel.openAroundItemId != nil { + chatModel.openAroundItemId = nil + } } else { dismiss() } } + .onChange(of: chatModel.openAroundItemId) { openAround in + if let openAround { + closeSearch() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + chatModel.openAroundItemId = nil + + if let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } + + // this may already being loading because of changed chat id (see .onChange(of: chat.id) + if !loadingBottomItems { + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + allowLoadMoreItems = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } + } + } .onDisappear { VideoPlayerView.players.removeAll() stopAudioPlayer() @@ -429,9 +458,13 @@ struct ChatView: View { index = mergedItems.boxedValue.indexInParentItems[itemId] } if let index { - await MainActor.run { animatedScrollingInProgress = true } - await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) - await MainActor.run { animatedScrollingInProgress = false } + closeKeyboardAndRun { + Task { + await MainActor.run { animatedScrollingInProgress = true } + await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) + await MainActor.run { animatedScrollingInProgress = false } + } + } } } catch { logger.error("Error scrolling to item: \(error)") @@ -460,9 +493,7 @@ struct ChatView: View { .cornerRadius(10.0) Button ("Cancel") { - searchText = "" - searchMode = false - searchFocussed = false + closeSearch() Task { await loadChat(chat: chat) } } } @@ -517,7 +548,9 @@ struct ChatView: View { showChatInfoSheet: $showChatInfoSheet, revealedItems: $revealedItems, selectedChatItems: $selectedChatItems, - forwardedChatItems: $forwardedChatItems + forwardedChatItems: $forwardedChatItems, + searchText: $searchText, + closeKeyboardAndRun: closeKeyboardAndRun ) // crashes on Cell size calculation without this line .environmentObject(ChatModel.shared) @@ -535,25 +568,10 @@ struct ChatView: View { } } .onChange(of: im.reversedChatItems) { items in - updateMergedItemsTask?.cancel() - if useItemsUpdateTask { - updateMergedItemsTask = Task { - let items = MergedItems.create(items, revealedItems, im.chatState) - if Task.isCancelled { - return - } - await MainActor.run { - mergedItems.boxedValue = items - scrollView.updateItems(mergedItems.boxedValue.items) - } - } - } else { - mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) - scrollView.updateItems(mergedItems.boxedValue.items) - } + mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: revealedItems) { revealed in - updateMergedItemsTask?.cancel() mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) scrollView.updateItems(mergedItems.boxedValue.items) } @@ -567,6 +585,7 @@ struct ChatView: View { .padding(.vertical, -100) .onTapGesture { hideKeyboard() } .onChange(of: searchText) { s in + guard showSearch else { return } Task { await loadChat(chat: chat, search: s) mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) @@ -880,11 +899,29 @@ struct ChatView: View { } private func focusSearch() { - searchMode = true + showSearch = true searchFocussed = true searchText = "" } + private func closeSearch() { + showSearch = false + searchText = "" + searchFocussed = false + } + + private func closeKeyboardAndRun(_ action: @escaping () -> Void) { + var delay: TimeInterval = 0 + if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch { + delay = 0.5 + closeSearch() + hideKeyboard() + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + action() + } + } + private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { @@ -1079,6 +1116,7 @@ struct ChatView: View { pagination, im.chatState, searchText, + nil, { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) } ) return true @@ -1136,10 +1174,14 @@ struct ChatView: View { @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var msgWidth: CGFloat = 0 + @State private var touchInProgress: Bool = false @Binding var selectedChatItems: Set? @Binding var forwardedChatItems: [ChatItem] + @Binding var searchText: String + var closeKeyboardAndRun: (@escaping () -> Void) -> Void + @State private var allowMenu: Bool = true @State private var markedRead = false @State private var markReadTask: Task? = nil @@ -1257,6 +1299,16 @@ struct ChatView: View { markedRead = false } .actionSheet(item: $actionSheet) { $0.actionSheet } + // skip updating struct on touch if no need to show GoTo button + .if(touchInProgress || searchIsNotBlank || (chatItem.meta.itemForwarded != nil && chatItem.meta.itemForwarded != .unknown)) { + // long press listener steals taps from top-level listener, so repeating it's logic here as well + $0.onTapGesture { + hideKeyboard() + } + .onLongPressGesture(minimumDuration: .infinity, perform: {}, onPressingChanged: { pressing in + touchInProgress = pressing + }) + } } private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { @@ -1290,6 +1342,11 @@ struct ChatView: View { } } + private var searchIsNotBlank: Bool { + get { + searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } @available(iOS 16.0, *) struct MemberLayout: Layout { @@ -1459,18 +1516,26 @@ struct ChatView: View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading VStack(alignment: alignment.horizontal, spacing: 3) { - ChatItemView( - chat: chat, - chatItem: ci, - scrollToItemId: scrollToItemId, - maxWidth: maxWidth, - allowMenu: $allowMenu - ) - .environment(\.revealed, revealed) - .environment(\.showTimestamp, itemSeparation.timestamp) - .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) - .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } - .accessibilityLabel("") + HStack { + if ci.chatDir.sent { + goToItemButton(true) + } + ChatItemView( + chat: chat, + chatItem: ci, + scrollToItemId: scrollToItemId, + maxWidth: maxWidth, + allowMenu: $allowMenu + ) + .environment(\.revealed, revealed) + .environment(\.showTimestamp, itemSeparation.timestamp) + .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) + .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } + .accessibilityLabel("") + if !ci.chatDir.sent { + goToItemButton(false) + } + } if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { chatItemReactions(ci) .padding(.bottom, 4) @@ -2133,6 +2198,37 @@ struct ChatView: View { } } + func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View { + Button { + onClick() + } label: { + Image(systemName: image) + .resizable() + .frame(width: 13, height: 13) + .padding([alignStart ? .trailing : .leading], 10) + .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4)) + } + } + + @ViewBuilder + func goToItemButton(_ alignStart: Bool) -> some View { + let chatTypeApiIdMsgId = chatItem.meta.itemForwarded?.chatTypeApiIdMsgId + if searchIsNotBlank { + goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) { + closeKeyboardAndRun { + ItemsModel.shared.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) + } + } + } + } + private struct SelectedChatItem: View { @EnvironmentObject var theme: AppTheme var ciId: Int64 diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 80c5973211..6ded9cae72 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -325,6 +325,7 @@ struct ComposeView: View { @ObservedObject var chat: Chat @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date @Binding var selectedRange: NSRange @State var linkUrl: URL? = nil @@ -434,6 +435,7 @@ struct ComposeView: View { timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, sendButtonColor: chat.chatInfo.incognito ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) : theme.colors.primary @@ -1280,6 +1282,7 @@ struct ComposeView_Previews: PreviewProvider { chat: chat, composeState: $composeState, keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), selectedRange: $selectedRange ) .environmentObject(ChatModel()) @@ -1287,6 +1290,7 @@ struct ComposeView_Previews: PreviewProvider { chat: chat, composeState: $composeState, keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), selectedRange: $selectedRange ) .environmentObject(ChatModel()) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 3eeb7ba7f5..d809fd7b76 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var lastUnfocusedDate: Date @Binding var placeholder: String? @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void @@ -42,7 +43,12 @@ struct NativeTextEditor: UIViewRepresentable { onImagesAdded(images) } } - field.setOnFocusChangedListener { focused = $0 } + field.setOnFocusChangedListener { + focused = $0 + if !focused { + lastUnfocusedDate = .now + } + } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.setPlaceholderView() @@ -266,6 +272,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + lastUnfocusedDate: Binding.constant(.now), placeholder: Binding.constant("Placeholder"), selectedRange: Binding.constant(NSRange(location: 0, length: 0)), onImagesAdded: { _ in } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 9013bb6a88..30767d66ec 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,6 +32,7 @@ struct SendMessageView: View { @State private var holdingVMR = false @Namespace var namespace @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date var sendButtonColor = Color.accentColor @State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teFont: Font = .body @@ -61,6 +62,7 @@ struct SendMessageView: View { disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, + lastUnfocusedDate: $keyboardHiddenDate, placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), selectedRange: $selectedRange, onImagesAdded: onMediaAdded @@ -441,7 +443,8 @@ struct SendMessageView_Previews: PreviewProvider { selectedRange: $selectedRange, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } VStack { @@ -452,7 +455,8 @@ struct SendMessageView_Previews: PreviewProvider { selectedRange: $selectedRangeEditing, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } } diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 81345e4a03..3d4fb381a6 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -171,8 +171,10 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu visibleItems.last?.index ?? 0 } - /// Whether there is scroll to item in progress or not + /// Whether there is a non-animated scroll to item in progress or not var isScrolling: Bool = false + /// Whether there is an animated scroll to item in progress or not + var isAnimatedScrolling: Bool = false override init() { super.init() @@ -349,11 +351,11 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu if // there is auto scroll in progress and the first item has a higher offset than bottom part // of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to // re-make the first visible item - (listState.isScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || + (listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || // the fist visible item previously is hidden now, remove it and move on !isVisible(vis.view) { let newIndex: Int - if listState.isScrolling { + if listState.isAnimatedScrolling { // skip many items to make the scrolling take less time var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0 // if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled @@ -471,7 +473,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } func scrollToItem(_ index: Int, top: Bool = true) { - if index >= listState.items.count || listState.isScrolling { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { return } if bounds.height == 0 { @@ -498,7 +500,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) var stepSlowdownMultiplier: CGFloat = 1 - while true { + while i < 200 { let up = index > listState.firstVisibleItemIndex if upPrev != up { stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 @@ -522,18 +524,22 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu break } contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + adaptItems(listState.items, false) + snapToContent(animated: false) i += 1 } + adaptItems(listState.items, false) + snapToContent(animated: false) estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) } func scrollToItemAnimated(_ index: Int, top: Bool = true) async { - if index >= listState.items.count || listState.isScrolling { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { return } - listState.isScrolling = true + listState.isAnimatedScrolling = true defer { - listState.isScrolling = false + listState.isAnimatedScrolling = false } var adjustedOffset = self.contentOffset.y var i = 0 @@ -543,7 +549,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) var stepSlowdownMultiplier: CGFloat = 1 - while true { + while i < 200 { let up = index > listState.firstVisibleItemIndex if upPrev != up { stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 9885811051..2b58abef65 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -20,6 +20,7 @@ struct TerminalView: View { @State var composeState: ComposeState = ComposeState() @State var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var terminalItem: TerminalItem? @State private var scrolled = false @@ -101,7 +102,8 @@ struct TerminalView: View { sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate ) .padding(.horizontal, 12) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 169a7c21de..a601e60d5f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3261,6 +3261,20 @@ public enum CIForwardedFrom: Decodable, Hashable { } } + public var chatTypeApiIdMsgId: (ChatType, Int64, ChatItem.ID?)? { + switch self { + case .unknown: nil + case let .contact(_, _, contactId, msgId): + if let contactId { + (ChatType.direct, contactId, msgId) + } else { nil } + case let .group(_, _, groupId, msgId): + if let groupId { + (ChatType.group, groupId, msgId) + } else { nil } + } + } + public func text(_ chatType: ChatType) -> LocalizedStringKey { chatType == .local ? (chatName == "" ? "saved" : "saved from \(chatName)") From dce85021650dcaddf156a10f81a91e44f0e86805 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 28 Feb 2025 22:57:41 +0700 Subject: [PATCH 092/289] android: allow to enter passphrase in case of error reading it (#5683) * android: allow to enter passphrase in case of error reading it * change * refactor * strings --------- Co-authored-by: Evgeny Poberezkin --- .../common/platform/Cryptor.android.kt | 29 ++++++++++++++++--- .../commonMain/resources/MR/base/strings.xml | 5 +++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt index dc6c53ecbc..d9a5fb59e3 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -30,10 +30,31 @@ internal class Cryptor: CryptorInterface { } return null } - val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull() + + try { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + return String(cipher.doFinal(data)) + } catch (e: Throwable) { + Log.e(TAG, "cipher.init: ${e.stackTraceToString()}") + val randomPassphrase = appPreferences.initialRandomDBPassphrase.get() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_reading_passphrase), + text = generalGetString(if (randomPassphrase) { + MR.strings.restore_passphrase_can_not_be_read_desc + } else { + MR.strings.restore_passphrase_can_not_be_read_enter_manually_desc + } + ) + .plus("\n\n").plus(e.stackTraceToString()) + ) + if (randomPassphrase) { + // do not allow to override initial random passphrase in case of such error + throw e + } + return null + } } override fun encryptText(text: String, alias: String): Pair { 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 aa0f49ee1d..83f085fe3a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1471,6 +1471,7 @@ Wrong database passphrase + Error reading database passphrase Encrypted database Database error Keychain error @@ -1492,7 +1493,9 @@ Please enter the previous password after restoring database backup. This action can not be undone. Restore Restore database error - Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers. + Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please contact developers. + Passphrase in Keystore can\'t be read. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers. + Passphrase in Keystore can\'t be read, please enter it manually. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers. Database upgrade Database downgrade Incompatible database version From dcea008fb959972ff9bdf5d555133b8c141fd9ea Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 28 Feb 2025 22:43:39 +0400 Subject: [PATCH 093/289] core: batch apis - remove, block, change role of members (#5674) * core: core: batch remove members * order * foldr * list * style * batch block * change role * test * if --- src/Simplex/Chat/Controller.hs | 12 +- src/Simplex/Chat/Library/Commands.hs | 247 ++++++++++++------ src/Simplex/Chat/Library/Internal.hs | 13 +- src/Simplex/Chat/Library/Subscriber.hs | 14 +- src/Simplex/Chat/Messages/CIContent/Events.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 7 +- src/Simplex/Chat/View.hs | 26 +- tests/Bots/DirectoryTests.hs | 22 +- tests/ChatTests/Groups.hs | 135 +++++++--- tests/ChatTests/Profiles.hs | 2 +- 10 files changed, 320 insertions(+), 162 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e64553af98..71b57c72b2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -366,9 +366,9 @@ data ChatCommand | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} - | APIMemberRole GroupId GroupMemberId GroupMemberRole - | APIBlockMemberForAll GroupId GroupMemberId Bool - | APIRemoveMember GroupId GroupMemberId + | APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole + | APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool + | APIRemoveMembers GroupId (NonEmpty GroupMemberId) | APILeaveGroup GroupId | APIListMembers GroupId | APIUpdateGroupProfile GroupId GroupProfile @@ -673,7 +673,7 @@ data ChatResponse | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} - | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus @@ -758,9 +758,9 @@ data ChatResponse | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} | CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRMemberRoleUser {user :: User, groupInfo :: GroupInfo, 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} - | CRMemberBlockedForAllUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, blocked :: Bool} + | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 624f6a6dc1..f44dca9026 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2023,75 +2023,170 @@ 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 - APIMemberRole groupId memberId memRole -> withUser $ \user -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId - if memberId == groupMemberId' membership - then changeMemberRole user gInfo members membership $ SGEUserRole memRole - else case find ((== memberId) . groupMemberId') members of - Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole - _ -> throwChatError CEGroupMemberNotFound + APIMembersRole groupId memberIds newRole -> withUser $ \user -> + withGroupLock "memberRole" groupId . procCmd $ do + g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + when (selfSelected gInfo) $ throwChatError $ CECommandError "can't change role for self" + let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin) = selectMembers members + when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ + throwChatError $ CECommandError "can't change role of multiple members when admins selected, or new role is admin" + assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) + (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems + (errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems + unless (null acis) $ toView $ CRNewChatItems user acis + let errs = errs1 <> errs2 + unless (null errs) $ toView $ CRChatErrors (Just user) errs + pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed where - changeMemberRole user gInfo members m gEvent = do - let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m - assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole]) - withGroupLock "memberRole" groupId . procCmd $ do - unless (mRole == memRole) $ do - withFastStore' $ \db -> updateGroupMemberRole db user m memRole - case mStatus of - GSMemInvited -> do - withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case - (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq - _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} - APIBlockMemberForAll groupId memberId blocked -> withUser $ \user -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId - when (memberId == groupMemberId' membership) $ throwChatError $ CECommandError "can't block/unblock self" - case splitMember memberId members of - Nothing -> throwChatError $ CEException "expected to find a single blocked member" - Just (bm, remainingMembers) -> do - let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm - -- TODO GRModerator when most users migrate - assertUserGroupRole gInfo $ max GRAdmin bmRole - when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" - withGroupLock "blockForAll" groupId . procCmd $ do - let mrs = if blocked then MRSBlocked else MRSUnrestricted - event = XGrpMemRestrict bmMemberId MemberRestrictions {restriction = mrs} - msg <- sendGroupMessage' user gInfo remainingMembers event - let ciContent = CISndGroupEvent $ SGEMemberBlocked memberId (fromLocalProfile bmp) blocked - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - bm' <- withFastStore $ \db -> do - liftIO $ updateGroupMemberBlocked db user groupId memberId mrs - getGroupMember db vr user groupId memberId - toggleNtf user bm' (not blocked) - pure CRMemberBlockedForAllUser {user, groupInfo = gInfo, member = bm', blocked} + selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldr' addMember ([], [], [], GRObserver, False) + where + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + in + if + | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin') + | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin') + | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin') + | otherwise = (invited, current, unchanged, maxRole, anyAdmin) + changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) + changeRoleInvitedMems user gInfo memsToChange = do + -- not batched, as we need to send different invitations to different connections anyway + mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchChatError` (pure . Left) + pure $ partitionEithers mems_ + where + changeRole :: GroupMember -> CM GroupMember + changeRole m@GroupMember {groupMemberId, memberContactId, localDisplayName = cName} = do + withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case + (Just ct, Just cReq) -> do + sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq + withFastStore' $ \db -> updateGroupMemberRole db user m newRole + pure (m :: GroupMember) {memberRole = newRole} + _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName + changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of + Nothing -> pure ([], [], []) + Just memsToChange' -> do + let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' + (msgs_, _gsr) <- sendGroupMessages user gInfo members events + let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) 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_ + pure (errs, changed, acis) + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberRole groupMemberId (fromLocalProfile memberProfile) newRole + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + updMember db m = do + updateGroupMemberRole db user m newRole + pure (m :: GroupMember) {memberRole = newRole} + APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> + withGroupLock "blockForAll" groupId . procCmd $ do + Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + when (selfSelected gInfo) $ throwChatError $ CECommandError "can't block/unblock self" + let (blockMems, remainingMems, maxRole, anyAdmin) = selectMembers members + when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't block/unblock multiple members when admins selected" + assertUserGroupRole gInfo $ max GRModerator maxRole + blockMembers user gInfo blockMems remainingMems where - splitMember mId ms = case break ((== mId) . groupMemberId') ms of - (_, []) -> Nothing - (ms1, bm : ms2) -> Just (bm, ms1 <> ms2) - APIRemoveMember groupId memberId -> withUser $ \user -> do - Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId - case find ((== memberId) . groupMemberId') members of - Nothing -> throwChatError CEGroupMemberNotFound - Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do - assertUserGroupRole gInfo $ max GRAdmin mRole - withGroupLock "removeMember" groupId . procCmd $ do - case mStatus of - GSMemInvited -> do - deleteMemberConnection user m - withFastStore' $ \db -> deleteGroupMember db user m - _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - deleteMemberConnection' user m True - -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user m - pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} + selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldr' addMember ([], [], GRObserver, False) + where + addMember m@GroupMember {groupMemberId, memberRole} (block, remaining, maxRole, anyAdmin) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + in (m : block, remaining, maxRole', anyAdmin') + | otherwise = (block, m : remaining, maxRole, anyAdmin) + blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse + blockMembers user gInfo blockMems remainingMems = case L.nonEmpty blockMems of + Nothing -> throwChatError $ CECommandError "no members to block/unblock" + 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 + let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) 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_ + 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 + -- TODO not batched - requires agent batch api + forM_ blocked $ \m -> toggleNtf user m (not blockFlag) + pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag} + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + APIRemoveMembers groupId memberIds -> withUser $ \user -> + withGroupLock "removeMembers" groupId . procCmd $ do + g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + let (invitedMems, currentMems, maxRole, anyAdmin) = selectMembers members + when (length invitedMems + length currentMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't remove multiple members when admins selected" + assertUserGroupRole gInfo $ max GRAdmin maxRole + (errs1, deleted1) <- deleteInvitedMems user invitedMems + (errs2, deleted2, acis) <- deleteCurrentMems user g currentMems + unless (null acis) $ toView $ CRNewChatItems user acis + let errs = errs1 <> errs2 + unless (null errs) $ toView $ CRChatErrors (Just user) errs + pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2) -- same order is not guaranteed + where + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldr' addMember ([], [], GRObserver, False) + where + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, maxRole, anyAdmin) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + in + if memberStatus == GSMemInvited + then (m : invited, current, maxRole', anyAdmin') + else (invited, m : current, maxRole', anyAdmin') + | otherwise = (invited, current, maxRole, anyAdmin) + deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) + deleteInvitedMems user memsToDelete = do + deleteMembersConnections user memsToDelete + lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) + where + delMember db m = do + deleteGroupMember db user m + pure m {memberStatus = GSMemRemoved} + deleteCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + deleteCurrentMems user (Group gInfo members) memsToDelete = case L.nonEmpty memsToDelete of + Nothing -> pure ([], [], []) + Just memsToDelete' -> do + let events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId) memsToDelete' + (msgs_, _gsr) <- sendGroupMessages user gInfo members events + let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) 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_ + pure (errs, deleted, acis) + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + delMember db m = do + deleteOrUpdateMemberRecordIO db user m + pure m {memberStatus = GSMemRemoved} 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 @@ -2114,18 +2209,14 @@ processChatCommand' vr = \case JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIJoinGroup groupId enableNtfs - MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole - BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMemberForAll gId gMemberId blocked + 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 -> withUser $ \user -> do (gId, gMemberIds) <- withStore $ \db -> do gId <- getGroupIdByName db user gName gMemberIds <- forM gMemberNames $ getGroupMemberIdByName db user gId pure (gId, gMemberIds) - rs <- forM (L.zip (L.fromList [1..]) gMemberIds) $ \(i, memId) -> do - r <- processChatCommand (APIRemoveMember gId memId) - when (i < length gMemberIds) $ toView r - pure r - pure $ L.last rs + processChatCommand $ APIRemoveMembers gId gMemberIds LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APILeaveGroup groupId @@ -3090,7 +3181,7 @@ processChatCommand' vr = \case (msgs_, gsr) <- sendGroupMessages user gInfo ms chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live - when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" + when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ processSendErrs user r @@ -3795,9 +3886,9 @@ chatCommandP = "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI - "/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole), - "/_block #" *> (APIBlockMemberForAll <$> A.decimal <* A.space <*> A.decimal <* A.space <* "blocked=" <*> onOffP), - "/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal), + "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), + "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* A.space <* "blocked=" <*> onOffP), + "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP), "/_leave #" *> (APILeaveGroup <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal), "/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 4a62c4ccb6..eba1bf169f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1251,11 +1251,14 @@ deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: User -> GroupMember -> CM () -deleteOrUpdateMemberRecord user@User {userId} member = - withStore' $ \db -> - checkGroupMemberHasItems db user member >>= \case - Just _ -> updateGroupMemberStatus db userId member GSMemRemoved - Nothing -> deleteGroupMember db user member +deleteOrUpdateMemberRecord user member = + withStore' $ \db -> deleteOrUpdateMemberRecordIO db user member + +deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupMember -> IO () +deleteOrUpdateMemberRecordIO db user@User {userId} member = + checkGroupMemberHasItems db user member >>= \case + Just _ -> updateGroupMemberStatus db userId member GSMemRemoved + Nothing -> deleteGroupMember db user member 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 44ad4ccc85..087d49e49a 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2608,7 +2608,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM () xGrpMemRestrict - gInfo@GroupInfo {groupId, membership = GroupMember {memberId = membershipMemId}} + gInfo@GroupInfo {membership = GroupMember {memberId = membershipMemId}} m@GroupMember {memberRole = senderRole} memId MemberRestrictions {restriction} @@ -2619,10 +2619,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.restrict: admin blocks you" | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} + Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} + | blockedByAdmin == mrsBlocked restriction -> pure () | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do - bm' <- setMemberBlocked bmId + 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 @@ -2630,14 +2631,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView CRMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId - bm' <- setMemberBlocked $ groupMemberId' bm + bm' <- setMemberBlocked bm toView $ CRUnknownMemberBlocked user gInfo m bm' Left e -> throwError $ ChatErrorStore e where - setMemberBlocked bmId = - withStore $ \db -> do - liftIO $ updateGroupMemberBlocked db user groupId bmId restriction - getGroupMember db vr user groupId bmId + setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 74f7d94399..054530e06f 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -33,9 +33,9 @@ data RcvGroupEvent data SndGroupEvent = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMemberBlockedForAllUser + | SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMembersBlockedForAllUser | SGEUserRole {role :: GroupMemberRole} - | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember + | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMembers | SGEUserLeft -- CRLeftMemberUser | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated deriving (Show) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 67722ebd0f..a1ce3ab269 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2062,8 +2062,8 @@ updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {sh |] (BI showMessages, currentTs, userId, gId, gMemberId) -updateGroupMemberBlocked :: DB.Connection -> User -> GroupId -> GroupMemberId -> MemberRestrictionStatus -> IO () -updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do +updateGroupMemberBlocked :: DB.Connection -> User -> GroupInfo -> MemberRestrictionStatus -> GroupMember -> IO GroupMember +updateGroupMemberBlocked db User {userId} GroupInfo {groupId} mrs m@GroupMember {groupMemberId} = do currentTs <- getCurrentTime DB.execute db @@ -2072,7 +2072,8 @@ updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do SET member_restriction = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND group_member_id = ? |] - (memberBlocked, currentTs, userId, gId, gMemberId) + (mrs, currentTs, userId, groupId, groupMemberId) + pure m {blockedByAdmin = mrsBlocked mrs} getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index a8c2d215a8..7a20cb2fb0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -220,7 +220,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRUserDeletedMembers u g members -> case members of + [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group"] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] @@ -301,9 +303,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe 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"] CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' - CRMemberRoleUser u g m r r' -> ttyUser u $ viewMemberRoleUserChanged g 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 - CRMemberBlockedForAllUser u g m blocked -> ttyUser u $ viewMemberBlockedForAllUser g m blocked + CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] @@ -1109,21 +1111,19 @@ viewMemberRoleChanged g@GroupInfo {membership} by m r r' memId = groupMemberId' m view s = [ttyGroup' g <> ": " <> ttyMember by <> " changed " <> s <> " from " <> showRole r <> " to " <> showRole r'] -viewMemberRoleUserChanged :: GroupInfo -> GroupMember -> GroupMemberRole -> GroupMemberRole -> [StyledString] -viewMemberRoleUserChanged g@GroupInfo {membership} m r r' - | r == r' = [ttyGroup' g <> ": member role did not change"] - | groupMemberId' membership == groupMemberId' m = view "your role" - | otherwise = view $ "the role of " <> ttyMember m - where - view s = [ttyGroup' g <> ": you changed " <> s <> " from " <> showRole r <> " to " <> showRole r'] +viewMemberRoleUserChanged :: GroupInfo -> [GroupMember] -> GroupMemberRole -> [StyledString] +viewMemberRoleUserChanged g members r = case members of + [m] -> [ttyGroup' g <> ": you changed the role of " <> ttyMember m <> " to " <> showRole r] + mems' -> [ttyGroup' g <> ": you changed the role of " <> sShow (length mems') <> " members to " <> showRole r] viewMemberBlockedForAll :: GroupInfo -> GroupMember -> GroupMember -> Bool -> [StyledString] viewMemberBlockedForAll g by m blocked = [ttyGroup' g <> ": " <> ttyMember by <> " " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] -viewMemberBlockedForAllUser :: GroupInfo -> GroupMember -> Bool -> [StyledString] -viewMemberBlockedForAllUser g m blocked = - [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] +viewMembersBlockedForAllUser :: GroupInfo -> [GroupMember] -> Bool -> [StyledString] +viewMembersBlockedForAllUser g members blocked = case members of + [m] -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] + mems' -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> sShow (length mems') <> " members"] showRole :: GroupMemberRole -> StyledString showRole = plain . strEncode diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 5bdc379cbf..2a9ad30dd2 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -122,7 +122,7 @@ testDirectoryService ps = bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" bob ##> "/mr PSA SimpleX-Directory admin" -- putStrLn "*** discover service joins group and creates the link for profile" - bob <## "#PSA: you changed the role of SimpleX-Directory from member to admin" + bob <## "#PSA: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> Joining the group PSA…" bob <## "#PSA: SimpleX-Directory joined the group" bob <# "SimpleX-Directory> Joined the group PSA, creating the link…" @@ -579,7 +579,7 @@ testDelistedRoleChanges ps = groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" cath <## "#privacy: bob changed the role of SimpleX-Directory from admin to member" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to member." bob <## "" @@ -588,7 +588,7 @@ testDelistedRoleChanges ps = groupNotFound cath "privacy" -- re-listed if service role changed back without profile changes cath ##> "/mr privacy SimpleX-Directory admin" - cath <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + cath <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <## "#privacy: cath changed the role of SimpleX-Directory from member to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" @@ -597,7 +597,7 @@ testDelistedRoleChanges ps = groupFoundN 3 cath "privacy" -- de-listed if owner role changed cath ##> "/mr privacy bob admin" - cath <## "#privacy: you changed the role of bob from owner to admin" + cath <## "#privacy: you changed the role of bob to admin" bob <## "#privacy: cath changed your role from owner to admin" bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to admin." bob <## "" @@ -606,7 +606,7 @@ testDelistedRoleChanges ps = groupNotFound cath "privacy" -- re-listed if owner role changed back without profile changes cath ##> "/mr privacy bob owner" - cath <## "#privacy: you changed the role of bob from admin to owner" + cath <## "#privacy: you changed the role of bob to owner" bob <## "#privacy: cath changed your role from admin to owner" bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to owner." bob <## "" @@ -627,7 +627,7 @@ testNotDelistedMemberRoleChanged ps = cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" - bob <## "#privacy: you changed the role of cath from owner to member" + bob <## "#privacy: you changed the role of cath to member" cath <## "#privacy: bob changed your role from owner to member" groupFoundN 3 cath "privacy" @@ -641,11 +641,11 @@ testNotSentApprovalBadRoles ps = submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" updateProfileWithLink bob "privacy" welcomeWithLink 1 bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -666,14 +666,14 @@ testNotApprovedBadRoles ps = updateProfileWithLink bob "privacy" welcomeWithLink 1 notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" let approve = "/approve 1:privacy 1" superUser #> ("@SimpleX-Directory " <> approve) superUser <# ("SimpleX-Directory> > " <> approve) superUser <## " Group is not approved: SimpleX-Directory is not an admin." groupNotFound cath "privacy" bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -940,7 +940,7 @@ testListUserGroups ps = -- with de-listed group groupFound cath "anonymity" cath ##> "/mr anonymity SimpleX-Directory member" - cath <## "#anonymity: you changed the role of SimpleX-Directory from admin to member" + cath <## "#anonymity: you changed the role of SimpleX-Directory to member" cath <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (anonymity) is changed to member." cath <## "" cath <## "The group is no longer listed in the directory." diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6625fb8094..14539ac219 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -173,7 +173,8 @@ chatGroupTests = do it "messages are fully deleted" testBlockForAllFullDelete it "another admin can unblock" testBlockForAllAnotherAdminUnblocks it "member was blocked before joining group" testBlockForAllBeforeJoining - it "can't repeat block, unblock" testBlockForAllCantRepeat + it "repeat block, unblock" testBlockForAllRepeat + it "block multiple members" testBlockForAllMultipleMembers describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive describe "group member reports" $ do @@ -265,7 +266,7 @@ testGroupShared alice bob cath checkMessages = do -- test observer role alice ##> "/mr team bob observer" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from admin to observer", + [ alice <## "#team: you changed the role of bob to observer", bob <## "#team: alice changed your role from admin to observer", cath <## "#team: alice changed the role of bob from admin to observer" ] @@ -280,7 +281,7 @@ testGroupShared alice bob cath checkMessages = do ] alice ##> "/mr team bob admin" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from observer to admin", + [ alice <## "#team: you changed the role of bob to admin", bob <## "#team: alice changed your role from observer to admin", cath <## "#team: alice changed the role of bob from observer to admin" ] @@ -1460,7 +1461,7 @@ testUpdateMemberRole = alice <## "to add members use /a team or /create link #team" addMember "team" alice bob GRAdmin alice ##> "/mr team bob member" - alice <## "#team: you changed the role of bob from admin to member" + alice <## "#team: you changed the role of bob to member" bob <## "#team: alice invites you to join the group as member" bob <## "use /j team to accept" bob ##> "/j team" @@ -1472,7 +1473,7 @@ testUpdateMemberRole = bob <## "#team: you have insufficient permissions for this action, the required role is admin" alice ##> "/mr team bob admin" concurrently_ - (alice <## "#team: you changed the role of bob from member to admin") + (alice <## "#team: you changed the role of bob to admin") (bob <## "#team: alice changed your role from member to admin") bob ##> "/a team cath owner" bob <## "#team: you have insufficient permissions for this action, the required role is owner" @@ -1488,13 +1489,7 @@ testUpdateMemberRole = alice <## "#team: new member cath is connected" ] alice ##> "/mr team alice admin" - concurrentlyN_ - [ alice <## "#team: you changed your role from owner to admin", - bob <## "#team: alice changed the role from owner to admin", - cath <## "#team: alice changed the role from owner to admin" - ] - alice ##> "/d #team" - alice <## "#team: you have insufficient permissions for this action, the required role is owner" + alice <## "bad chat command: can't change role for self" testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -1579,7 +1574,7 @@ testGroupModerate = -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -1662,7 +1657,7 @@ testGroupModerateFullDelete = -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -2691,7 +2686,7 @@ testGroupLinkMemberRole = bob <## "#team: you don't have permission to send messages" alice ##> "/mr #team bob member" - alice <## "#team: you changed the role of bob from observer to member" + alice <## "#team: you changed the role of bob to member" bob <## "#team: alice changed your role from observer to member" bob #> "#team hey now" @@ -2721,7 +2716,7 @@ testGroupLinkMemberRole = cath <## "#team: you don't have permission to send messages" alice ##> "/mr #team cath admin" - alice <## "#team: you changed the role of cath from observer to admin" + alice <## "#team: you changed the role of cath to admin" cath <## "#team: alice changed your role from observer to admin" bob <## "#team: alice changed the role of cath from observer to admin" @@ -2730,7 +2725,7 @@ testGroupLinkMemberRole = bob <# "#team cath> hey" cath ##> "/mr #team bob admin" - cath <## "#team: you changed the role of bob from member to admin" + cath <## "#team: you changed the role of bob to admin" bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" @@ -4132,14 +4127,14 @@ testGroupMsgForwardReport = alice ##> "/mr team bob moderator" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from admin to moderator", + [ alice <## "#team: you changed the role of bob to moderator", bob <## "#team: alice changed your role from admin to moderator", cath <## "#team: alice changed the role of bob from admin to moderator" ] alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -4157,7 +4152,7 @@ testGroupMsgForwardReport = alice ##> "/mr team bob member" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from moderator to member", + [ alice <## "#team: you changed the role of bob to member", bob <## "#team: alice changed your role from moderator to member", cath <## "#team: alice changed the role of bob from moderator to member" ] @@ -4315,7 +4310,7 @@ testGroupMsgForwardChangeRole = setupGroupForwarding3 "team" alice bob cath cath ##> "/mr #team bob member" - cath <## "#team: you changed the role of bob from admin to member" + cath <## "#team: you changed the role of bob to member" alice <## "#team: cath changed the role of bob from admin to member" bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded @@ -5942,19 +5937,13 @@ testBlockForAllBeforeJoining = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testBlockForAllCantRepeat :: HasCallStack => TestParams -> IO () -testBlockForAllCantRepeat = +testBlockForAllRepeat :: HasCallStack => TestParams -> IO () +testBlockForAllRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath -- disableFullDeletion3 "team" alice bob cath - alice ##> "/unblock for all #team bob" - alice <## "bad chat command: already unblocked" - - cath ##> "/unblock for all #team bob" - cath <## "bad chat command: already unblocked" - bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -5964,10 +5953,10 @@ testBlockForAllCantRepeat = bob "/block for all #team bob" - alice <## "bad chat command: already blocked" + alice <## "#team: you blocked bob" cath ##> "/block for all #team bob" - cath <## "bad chat command: already blocked" + cath <## "#team: you blocked bob" bob #> "#team 2" alice <# "#team bob> 2 [blocked by admin] " @@ -5979,16 +5968,92 @@ testBlockForAllCantRepeat = bob "/unblock for all #team bob" - alice <## "bad chat command: already unblocked" + alice <## "#team: you unblocked bob" cath ##> "/unblock for all #team bob" - cath <## "bad chat command: already unblocked" + cath <## "#team: you unblocked bob" bob #> "#team 3" [alice, cath] *<# "#team bob> 3" bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) +testBlockForAllMultipleMembers :: HasCallStack => TestParams -> IO () +testBlockForAllMultipleMembers = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "team" alice bob cath + + connectUsers alice dan + addMember "team" alice dan GRMember + dan ##> "/j team" + concurrentlyN_ + [ alice <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ], + do + bob <## "#team: alice added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected", + do + cath <## "#team: alice added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected" + ] + + -- lower roles to for batch block to be allowed (can't batch block if admins are selected) + alice ##> "/mr team bob member" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob to member", + bob <## "#team: alice changed your role from admin to member", + cath <## "#team: alice changed the role of bob from admin to member", + dan <## "#team: alice changed the role of bob from admin to member" + ] + alice ##> "/mr team cath member" + concurrentlyN_ + [ alice <## "#team: you changed the role of cath to member", + bob <## "#team: alice changed the role of cath from admin to member", + cath <## "#team: alice changed your role from admin to member", + dan <## "#team: alice changed the role of cath from admin to member" + ] + + bob #> "#team 1" + [alice, cath, dan] *<# "#team bob> 1" + + cath #> "#team 2" + [alice, bob, dan] *<# "#team cath> 2" + + alice ##> "/_block #1 2,3 blocked=on" + alice <## "#team: you blocked 2 members" + dan <## "#team: alice blocked bob" + dan <## "#team: alice blocked cath" + bob "#team 3" + [alice, dan] *<# "#team bob> 3 [blocked by admin] " + cath <# "#team bob> 3" + + cath #> "#team 4" + [alice, dan] *<# "#team cath> 4 [blocked by admin] " + bob <# "#team cath> 4" + + alice ##> "/_block #1 2,3 blocked=off" + alice <## "#team: you unblocked 2 members" + dan <## "#team: alice unblocked bob" + dan <## "#team: alice unblocked cath" + bob "#team 5" + [alice, cath, dan] *<# "#team bob> 5" + + cath #> "#team 6" + [alice, bob, dan] *<# "#team cath> 6" + testGroupMemberInactive :: HasCallStack => TestParams -> IO () testGroupMemberInactive ps = do withSmpServer' serverCfg' $ do @@ -6067,13 +6132,13 @@ testGroupMemberReports = -- disableFullDeletion3 "jokes" alice bob cath alice ##> "/mr jokes bob moderator" concurrentlyN_ - [ alice <## "#jokes: you changed the role of bob from admin to moderator", + [ alice <## "#jokes: you changed the role of bob to moderator", bob <## "#jokes: alice changed your role from admin to moderator", cath <## "#jokes: alice changed the role of bob from admin to moderator" ] alice ##> "/mr jokes cath member" concurrentlyN_ - [ alice <## "#jokes: you changed the role of cath from admin to member", + [ alice <## "#jokes: you changed the role of cath to member", bob <## "#jokes: alice changed the role of cath from admin to member", cath <## "#jokes: alice changed your role from admin to member" ] diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 4ab5e8fbd3..5d2b9f5ba8 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -773,7 +773,7 @@ testBusinessUpdateProfiles = withTestOutput $ testChat4 businessProfile alicePro biz <# "#alisa alisa_1> hello again" -- customer can invite members too, if business allows biz ##> "/mr alisa alisa_1 admin" - biz <## "#alisa: you changed the role of alisa_1 from member to admin" + biz <## "#alisa: you changed the role of alisa_1 to admin" alice <## "#biz: biz_1 changed your role from member to admin" connectUsers alice bob alice ##> "/a #biz bob" From 1b757911fa0bcb605a90e45643b9538626023d84 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 1 Mar 2025 01:55:17 +0700 Subject: [PATCH 094/289] ui: batch apis for members (#5681) * ui: batch apis for members * ios --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 18 ++--- .../Views/Chat/Group/GroupChatInfoView.swift | 8 ++- .../Chat/Group/GroupMemberInfoView.swift | 24 ++++--- apps/ios/SimpleXChat/APITypes.swift | 36 +++++----- .../chat/simplex/common/model/SimpleXAPI.kt | 70 ++++++++++--------- .../views/chat/group/GroupChatInfoView.kt | 12 ++-- .../views/chat/group/GroupMemberInfoView.kt | 32 ++++++--- 7 files changed, 114 insertions(+), 86 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 354f8243fc..545e90763f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1588,21 +1588,21 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } -func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember { - let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false) - if case let .userDeletedMember(_, _, member) = r { return member } +func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64]) async throws -> [GroupMember] { + let r = await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds), bgTask: false) + if case let .userDeletedMembers(_, _, members) = r { return members } throw r } -func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { - let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false) - if case let .memberRoleUser(_, _, member, _, _) = r { return member } +func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] { + let r = await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false) + if case let .membersRoleUser(_, _, members, _) = r { return members } throw r } -func apiBlockMemberForAll(_ groupId: Int64, _ memberId: Int64, _ blocked: Bool) async throws -> GroupMember { - let r = await chatSendCmd(.apiBlockMemberForAll(groupId: groupId, memberId: memberId, blocked: blocked), bgTask: false) - if case let .memberBlockedForAllUser(_, _, member, _) = r { return member } +func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] { + let r = await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false) + if case let .membersBlockedForAllUser(_, _, members, _) = r { return members } throw r } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 2a827de195..56d994b397 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -681,12 +681,14 @@ struct GroupChatInfoView: View { primaryButton: .destructive(Text("Remove")) { Task { do { - let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } } } catch let error { - logger.error("apiRemoveMember error: \(responseError(error))") + logger.error("apiRemoveMembers error: \(responseError(error))") let a = getErrorAlert(error, "Error removing member") alert = .error(title: a.title, error: a.message) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index f985356289..4a931ed23f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -610,13 +610,15 @@ struct GroupMemberInfoView: View { primaryButton: .destructive(Text("Remove")) { Task { do { - let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } dismiss() } } catch let error { - logger.error("apiRemoveMember error: \(responseError(error))") + logger.error("apiRemoveMembers error: \(responseError(error))") let a = getErrorAlert(error, "Error removing member") alert = .error(title: a.title, error: a.message) } @@ -641,14 +643,16 @@ struct GroupMemberInfoView: View { primaryButton: .default(Text("Change")) { Task { do { - let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) + let updatedMembers = try await apiMembersRole(groupInfo.groupId, [mem.groupMemberId], newRole) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } } } catch let error { newRole = mem.memberRole - logger.error("apiMemberRole error: \(responseError(error))") + logger.error("apiMembersRole error: \(responseError(error))") let a = getErrorAlert(error, "Error changing role") alert = .error(title: a.title, error: a.message) } @@ -800,12 +804,14 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { Task { do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + let updatedMembers = try await apiBlockMembersForAll(gInfo.groupId, [member.groupMemberId], blocked) await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + } } } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") + logger.error("apiBlockMembersForAll error: \(responseError(error))") } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index f331f98a67..20c8785b3a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -71,9 +71,9 @@ public enum ChatCommand { case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) - case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) - case apiBlockMemberForAll(groupId: Int64, memberId: Int64, blocked: Bool) - case apiRemoveMember(groupId: Int64, memberId: Int64) + case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) + case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) + case apiRemoveMembers(groupId: Int64, memberIds: [Int64]) case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) @@ -250,9 +250,9 @@ public enum ChatCommand { case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" - case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" - case let .apiBlockMemberForAll(groupId, memberId, blocked): return "/_block #\(groupId) \(memberId) blocked=\(onOff(blocked))" - case let .apiRemoveMember(groupId, memberId): return "/_remove #\(groupId) \(memberId)" + 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): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ","))" case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" @@ -424,9 +424,9 @@ public enum ChatCommand { case .apiNewGroup: return "apiNewGroup" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" - case .apiMemberRole: return "apiMemberRole" - case .apiBlockMemberForAll: return "apiBlockMemberForAll" - case .apiRemoveMember: return "apiRemoveMember" + case .apiMembersRole: return "apiMembersRole" + case .apiBlockMembersForAll: return "apiBlockMembersForAll" + case .apiRemoveMembers: return "apiRemoveMembers" case .apiLeaveGroup: return "apiLeaveGroup" case .apiListMembers: return "apiListMembers" case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" @@ -681,16 +681,16 @@ public enum ChatResponse: Decodable, Error { case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) - case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember]) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: Group) 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 memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case memberRoleUser(user: UserRef, groupInfo: GroupInfo, 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) - case memberBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, blocked: Bool) + case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) @@ -861,16 +861,16 @@ public enum ChatResponse: Decodable, Error { case .userAcceptedGroupSent: return "userAcceptedGroupSent" case .groupLinkConnecting: return "groupLinkConnecting" case .businessLinkConnecting: return "businessLinkConnecting" - case .userDeletedMember: return "userDeletedMember" + case .userDeletedMembers: return "userDeletedMembers" case .leftMemberUser: return "leftMemberUser" case .groupMembers: return "groupMembers" case .receivedGroupInvitation: return "receivedGroupInvitation" case .groupDeletedUser: return "groupDeletedUser" case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" case .memberRole: return "memberRole" - case .memberRoleUser: return "memberRoleUser" + case .membersRoleUser: return "membersRoleUser" case .memberBlockedForAll: return "memberBlockedForAll" - case .memberBlockedForAllUser: return "memberBlockedForAllUser" + case .membersBlockedForAllUser: return "membersBlockedForAllUser" case .deletedMemberUser: return "deletedMemberUser" case .deletedMember: return "deletedMember" case .leftMember: return "leftMember" @@ -1048,16 +1048,16 @@ public enum ChatResponse: Decodable, Error { case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") - case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .userDeletedMembers(u, groupInfo, members): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)") case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) 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 .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") - case let .memberRoleUser(u, groupInfo, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\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)") - case let .memberBlockedForAllUser(u, groupInfo, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nblocked: \(blocked)") + case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)") case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") 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 208a5557a5..c221e3c15e 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 @@ -1995,34 +1995,34 @@ object ChatController { } } - suspend fun apiRemoveMember(rh: Long?, groupId: Long, memberId: Long): GroupMember? = - when (val r = sendCmd(rh, CC.ApiRemoveMember(groupId, memberId))) { - is CR.UserDeletedMember -> r.member + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List): List? = + when (val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds))) { + is CR.UserDeletedMembers -> r.members else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiRemoveMember", generalGetString(MR.strings.error_removing_member), r) + apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r) } null } } - suspend fun apiMemberRole(rh: Long?, groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = - when (val r = sendCmd(rh, CC.ApiMemberRole(groupId, memberId, memberRole))) { - is CR.MemberRoleUser -> r.member + suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List = + when (val r = sendCmd(rh, CC.ApiMembersRole(groupId, memberIds, memberRole))) { + is CR.MembersRoleUser -> r.members else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiMemberRole", generalGetString(MR.strings.error_changing_role), r) + apiErrorAlert("apiMembersRole", generalGetString(MR.strings.error_changing_role), r) } throw Exception("failed to change member role: ${r.responseType} ${r.details}") } } - suspend fun apiBlockMemberForAll(rh: Long?, groupId: Long, memberId: Long, blocked: Boolean): GroupMember = - when (val r = sendCmd(rh, CC.ApiBlockMemberForAll(groupId, memberId, blocked))) { - is CR.MemberBlockedForAllUser -> r.member + suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List = + when (val r = sendCmd(rh, CC.ApiBlockMembersForAll(groupId, memberIds, blocked))) { + is CR.MembersBlockedForAllUser -> r.members else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiBlockMemberForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) + apiErrorAlert("apiBlockMembersForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) } throw Exception("failed to block member for all: ${r.responseType} ${r.details}") } @@ -2723,13 +2723,17 @@ object ChatController { upsertGroupMember(rhId, r.groupInfo, r.member) } } - is CR.MemberRoleUser -> + is CR.MembersRoleUser -> if (active(r.user)) { withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + r.members.forEach { member -> + upsertGroupMember(rhId, r.groupInfo, member) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + r.members.forEach { member -> + upsertGroupMember(rhId, r.groupInfo, member) + } } } is CR.MemberBlockedForAll -> @@ -3406,9 +3410,9 @@ sealed class CC { class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() - class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() - class ApiBlockMemberForAll(val groupId: Long, val memberId: Long, val blocked: Boolean): CC() - class ApiRemoveMember(val groupId: Long, val memberId: 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): CC() class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() @@ -3591,9 +3595,9 @@ sealed class CC { is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" - is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" - is ApiBlockMemberForAll -> "/_block #$groupId $memberId blocked=${onOff(blocked)}" - is ApiRemoveMember -> "/_remove #$groupId $memberId" + is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" + is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" + is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")}" is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" @@ -3754,9 +3758,9 @@ sealed class CC { is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" - is ApiMemberRole -> "apiMemberRole" - is ApiBlockMemberForAll -> "apiBlockMemberForAll" - is ApiRemoveMember -> "apiRemoveMember" + is ApiMembersRole -> "apiMembersRole" + is ApiBlockMembersForAll -> "apiBlockMembersForAll" + is ApiRemoveMembers -> "apiRemoveMembers" is ApiLeaveGroup -> "apiLeaveGroup" is ApiListMembers -> "apiListMembers" is ApiUpdateGroupProfile -> "apiUpdateGroupProfile" @@ -5801,16 +5805,16 @@ sealed class CR { @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("businessLinkConnecting") class BusinessLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val fromContact: Contact): CR() - @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("userDeletedMembers") class UserDeletedMembers(val user: UserRef, val groupInfo: GroupInfo, val members: List): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @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("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() - @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: UserRef, val groupInfo: GroupInfo, 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() - @Serializable @SerialName("memberBlockedForAllUser") class MemberBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val blocked: Boolean): CR() + @Serializable @SerialName("membersBlockedForAllUser") class MembersBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val blocked: Boolean): CR() @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -5986,16 +5990,16 @@ sealed class CR { is UserAcceptedGroupSent -> "userAcceptedGroupSent" is GroupLinkConnecting -> "groupLinkConnecting" is BusinessLinkConnecting -> "businessLinkConnecting" - is UserDeletedMember -> "userDeletedMember" + is UserDeletedMembers -> "userDeletedMembers" is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" is ReceivedGroupInvitation -> "receivedGroupInvitation" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberRole -> "memberRole" - is MemberRoleUser -> "memberRoleUser" + is MembersRoleUser -> "membersRoleUser" is MemberBlockedForAll -> "memberBlockedForAll" - is MemberBlockedForAllUser -> "memberBlockedForAllUser" + is MembersBlockedForAllUser -> "membersBlockedForAllUser" is DeletedMemberUser -> "deletedMemberUser" is DeletedMember -> "deletedMember" is LeftMember -> "leftMember" @@ -6164,16 +6168,16 @@ sealed class CR { is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") is BusinessLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nfromContact: $fromContact") - is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is UserDeletedMembers -> withUser(user, "groupInfo: $groupInfo\nmembers: $members") is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) 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 MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") - is MemberRoleUser -> withUser(user, "groupInfo: $groupInfo\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") - is MemberBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nblocked: $blocked") + is MembersBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\nblocked: $blocked") is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember") is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") 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 b3dc333ace..441fe313d4 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 @@ -213,13 +213,17 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withBGApi { - val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId) - if (updatedMember != null) { + val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, listOf(mem.groupMemberId)) + if (updatedMembers != null) { withChats { - upsertGroupMember(rhId, groupInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, groupInfo, updatedMember) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, groupInfo, updatedMember) + } } } } 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 ef1c69a5bb..41e4df8792 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 @@ -142,12 +142,16 @@ fun GroupMemberInfoView( }) { withBGApi { kotlin.runCatching { - val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it) + val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, listOf(member.groupMemberId), it) withChats { - upsertGroupMember(rhId, groupInfo, mem) + members.forEach { member -> + upsertGroupMember(rhId, groupInfo, member) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, mem) + members.forEach { member -> + upsertGroupMember(rhId, groupInfo, member) + } } }.onFailure { newRole.value = prevValue @@ -257,13 +261,17 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withBGApi { - val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId) - if (removedMember != null) { + val removedMembers = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + if (removedMembers != null) { withChats { - upsertGroupMember(rhId, groupInfo, removedMember) + removedMembers.forEach { removedMember -> + upsertGroupMember(rhId, groupInfo, removedMember) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, removedMember) + removedMembers.forEach { removedMember -> + upsertGroupMember(rhId, groupInfo, removedMember) + } } } close?.invoke() @@ -804,12 +812,16 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { withBGApi { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) + val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, listOf(member.groupMemberId), blocked) withChats { - upsertGroupMember(rhId, gInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, gInfo, updatedMember) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, gInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, gInfo, updatedMember) + } } } } From 50232fd1799f05c9db4f2fce917f797635e595d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 1 Mar 2025 01:55:49 +0700 Subject: [PATCH 095/289] android, desktop: go to forwarded item or search result (#5666) * android, desktop: go to forwarded item or search result * changes * reactions back * button appearance * indentation * change * rename variable * rename function * rename variable * rename variable * fix scroll position --- .../chat/simplex/common/model/ChatModel.kt | 8 + .../simplex/common/views/chat/ChatInfoView.kt | 3 +- .../common/views/chat/ChatItemsLoader.kt | 29 +- .../simplex/common/views/chat/ChatView.kt | 62 +- .../common/views/chat/item/ChatItemView.kt | 913 ++++++++++-------- .../common/views/chat/item/FramedItemView.kt | 2 +- .../views/chatlist/ChatListNavLinkView.kt | 22 +- 7 files changed, 591 insertions(+), 448 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 7ef8123d2f..f784dcb9ed 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 @@ -65,6 +65,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) + val openAroundItemId: MutableState = mutableStateOf(null) val chatsContext = ChatsContext(null) val reportsChatsContext = ChatsContext(MsgContentTag.Report) // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions @@ -3111,6 +3112,13 @@ sealed class CIForwardedFrom { is Group -> chatName } + val chatTypeApiIdMsgId: Triple? + get() = when (this) { + Unknown -> null + is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null + is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null + } + fun text(chatType: ChatType): String = if (chatType == ChatType.Local) { if (chatName.isEmpty()) { 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 23ce191557..a730bd1b71 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 @@ -1398,7 +1398,8 @@ private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressInd chat, navInfo, contentTag = null, - pagination = pagination + pagination = pagination, + openAroundItemId = null ) } catch (e: Exception) { Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}") 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 6a8c277ae1..9050804db4 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 @@ -28,13 +28,15 @@ suspend fun apiLoadMessages( contentTag: MsgContentTag?, pagination: ChatPagination, search: String = "", + openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, 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 - if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) + /** 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) || !isActive) return@coroutineScope - processLoadedChat(chat, navInfo, contentTag, pagination, visibleItemIndexesNonReversed) + processLoadedChat(chat, navInfo, contentTag, pagination, openAroundItemId, visibleItemIndexesNonReversed) } suspend fun processLoadedChat( @@ -42,6 +44,7 @@ suspend fun processLoadedChat( navInfo: NavigationInfo, contentTag: MsgContentTag?, pagination: ChatPagination, + openAroundItemId: Long?, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) { val chatState = chatModel.chatStateForContent(contentTag) @@ -67,7 +70,7 @@ suspend fun processLoadedChat( withChats(contentTag) { chatItemStatuses.clear() chatItems.replaceAll(chat.chatItems) - chatModel.chatId.value = chat.chatInfo.id + chatModel.chatId.value = chat.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { unreadAfterItemId.value = chat.chatItems.last().id @@ -119,10 +122,15 @@ suspend fun processLoadedChat( } } is ChatPagination.Around -> { - newItems.addAll(oldItems) - val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) + val newSplits = if (openAroundItemId == null) { + newItems.addAll(oldItems) + removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) + } else { + emptyList() + } // currently, items will always be added on top, which is index 0 newItems.addAll(0, chat.chatItems) + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = listOf(chat.chatItems.last().id) + newSplits @@ -130,8 +138,15 @@ suspend fun processLoadedChat( totalAfter.value = navInfo.afterTotal unreadTotal.value = chat.chatStats.unreadCount unreadAfter.value = navInfo.afterUnread - // no need to set it, count will be wrong - // unreadAfterNewestLoaded.value = navInfo.afterUnread + + if (openAroundItemId != null) { + unreadAfterNewestLoaded.value = navInfo.afterUnread + chatModel.openAroundItemId.value = openAroundItemId + chatModel.chatId.value = chat.id + } else { + // no need to set it, count will be wrong + // unreadAfterNewestLoaded.value = navInfo.afterUnread + } } } is ChatPagination.Last -> { 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 8b94f36d75..f9a7f25f48 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 @@ -133,9 +133,12 @@ fun ChatView( SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> - if (searchText.value == value) return@onSearchValueChanged - val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged - if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged + 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 && contentTag == null + val c = chatModel.getChat(chatInfo.id) + if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { apiFindMessages(c, value, contentTag) searchText.value = value @@ -344,7 +347,7 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, visibleItemIndexes) + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, null, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> @@ -602,6 +605,10 @@ fun ChatView( }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, onSearchValueChanged = onSearchValueChanged, + closeSearch = { + showSearch.value = false + searchText.value = "" + }, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), @@ -699,6 +706,7 @@ fun ChatLayout( markChatRead: () -> Unit, changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, + closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean, @@ -751,7 +759,7 @@ fun ChatLayout( useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, + setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, ) } if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { @@ -1160,11 +1168,13 @@ fun BoxScope.ChatItemsList( showItemDetails: (ChatInfo, ChatItem) -> Unit, markItemsRead: (List) -> Unit, markChatRead: () -> Unit, + closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean ) { val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } + val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } val contentTag = LocalContentTag.current // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state @@ -1177,15 +1187,29 @@ fun BoxScope.ChatItemsList( val reportsCount = reportsCount(chatInfo.id) val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 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 * 2).roundToPx() } + with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } ) - val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { - val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + 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 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 + } if (reportsState != null) { reportsListState = null reportsState @@ -1221,7 +1245,6 @@ fun BoxScope.ChatItemsList( val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) val chatInfoUpdated = rememberUpdatedState(chatInfo) - val highlightedItems = remember { mutableStateOf(setOf()) } 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 @@ -1238,7 +1261,7 @@ fun BoxScope.ChatItemsList( scrollToItemId.value = null } } } - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) + LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo) SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) @@ -1296,7 +1319,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, 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(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, 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) } } @@ -1567,9 +1590,9 @@ fun BoxScope.ChatItemsList( } @Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { +private fun LoadLastItems(loadingMoreItems: MutableState, resetListState: State, remoteHostId: Long?, chatInfo: ChatInfo) { val contentTag = LocalContentTag.current - LaunchedEffect(remoteHostId, chatInfo.id) { + LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) { try { loadingMoreItems.value = true if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect @@ -1888,6 +1911,17 @@ fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): D } } +@Composable +private fun numberOfBottomAppBars(): Int { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val chatBottomBar = remember { appPrefs.chatBottomBar.state } + return if (oneHandUI.value && chatBottomBar.value) { + 2 + } else { + 1 + } +} + @Composable private fun FloatingDate( modifier: Modifier, @@ -2798,6 +2832,7 @@ fun PreviewChatLayout() { markChatRead = {}, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + closeSearch = {}, onComposed = {}, developerTools = false, showViaProxy = false, @@ -2874,6 +2909,7 @@ fun PreviewGroupChatLayout() { markChatRead = {}, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + closeSearch = {}, onComposed = {}, developerTools = false, showViaProxy = 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 8c053ad1d2..e4bcc965c4 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 @@ -3,14 +3,14 @@ package chat.simplex.common.views.chat.item import SectionItemView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.HoverInteraction -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* @@ -31,8 +31,10 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.group.LocalContentTag +import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock import kotlin.math.* @@ -73,6 +75,7 @@ fun ChatItemView( highlighted: State, range: State, selectedChatItems: MutableState?>, + searchIsNotBlank: State, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -112,8 +115,16 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value + val bubbleInteractionSource = remember { MutableInteractionSource() } + val lineInteractionSource = remember { MutableInteractionSource() } + val bubblePressed = bubbleInteractionSource.collectIsPressedAsState() + val linePressed = lineInteractionSource.collectIsPressedAsState() + val lineHovered = lineInteractionSource.collectIsHoveredAsState() + val lineActivated = remember { derivedStateOf { lineHovered.value || linePressed.value || bubblePressed.value } } Box( - modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, + modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier) + .hoverable(lineInteractionSource) + .clickable(onClick = {}, interactionSource = lineInteractionSource, indication = null), contentAlignment = alignment, ) { val info = cItem.meta.itemStatus.statusInto @@ -228,197 +239,289 @@ fun ChatItemView( } } - Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { - val interactionSource = remember { MutableInteractionSource() } - val enterInteraction = remember { HoverInteraction.Enter() } - KeyChangeEffect(highlighted.value) { - if (highlighted.value) { - interactionSource.emit(enterInteraction) - } else { - interactionSource.emit(HoverInteraction.Exit(enterInteraction)) + @Composable + fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, parentActivated: State, onClick: () -> Unit) { + val buttonInteractionSource = remember { MutableInteractionSource() } + val buttonHovered = buttonInteractionSource.collectIsHoveredAsState() + val buttonPressed = buttonInteractionSource.collectIsPressedAsState() + val buttonActivated = remember { derivedStateOf { buttonHovered.value || buttonPressed.value } } + IconButton( + onClick, + Modifier + .padding(start = if (alignStart) 0.dp else DEFAULT_PADDING_HALF + 3.dp, end = if (alignStart) DEFAULT_PADDING_HALF + 3.dp else 0.dp) + .size(22.dp) + .alpha(if (parentActivated.value || buttonActivated.value) 1f else 0.4f), + interactionSource = buttonInteractionSource + ) { + Icon(painterResource(icon), null, Modifier.size(22.dp), tint = MaterialTheme.colors.secondary) + } + } + + @Composable + fun GoToItemButton(alignStart: Boolean, parentActivated: State) { + val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId + if (searchIsNotBlank.value) { + GoToItemInnerButton(alignStart, MR.images.ic_search, parentActivated) { + withBGApi { + openChat(rhId, cInfo.chatType, cInfo.apiId, null, cItem.id) + closeReportsIfNeeded() + } + } + } else if (chatTypeApiIdMsgId != null) { + GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, parentActivated) { + val (chatType, apiId, msgId) = chatTypeApiIdMsgId + withBGApi { + openChat(rhId, chatType, apiId, null, msgId) + closeReportsIfNeeded() + } } } - Column( - Modifier - .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = interactionSource, indication = LocalIndication.current) - .onRightClick { showMenu.value = true }, - ) { - @Composable - fun framedItemView() { - FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToQuotedItemFromItem) - } + } - fun deleteMessageQuestionText(): String { - return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) { - generalGetString(MR.strings.delete_message_cannot_be_undone_warning) - } else { - generalGetString(MR.strings.delete_message_mark_deleted_warning) - } + Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (cItem.chatDir.sent) { + GoToItemButton(true, lineActivated) } - - @Composable - fun MsgReactionsMenu() { - val rs = MsgReaction.old.mapNotNull { r -> - if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { - r + Column(Modifier.weight(1f, fill = false)) { + val enterInteraction = remember { HoverInteraction.Enter() } + KeyChangeEffect(highlighted.value) { + if (highlighted.value) { + bubbleInteractionSource.emit(enterInteraction) } else { - null + bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction)) } } - if (rs.isNotEmpty()) { - Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { - rs.forEach() { r -> - Box( - Modifier.size(36.dp).clickable { - setReaction(cInfo, cItem, true, r) - showMenu.value = false - }, - contentAlignment = Alignment.Center - ) { - ReactionIcon(r.text, 12.sp) - } + Column( + Modifier + .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) + .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) + .onRightClick { showMenu.value = true }, + ) { + @Composable + fun framedItemView() { + FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToQuotedItemFromItem) + } + + fun deleteMessageQuestionText(): String { + return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) { + generalGetString(MR.strings.delete_message_cannot_be_undone_warning) + } else { + generalGetString(MR.strings.delete_message_mark_deleted_warning) } } - } - } - @Composable - fun DeleteItemMenu() { - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun MsgContentItemDropdownMenu() { - val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) - when { - // cItem.id check is a special case for live message chat item which has negative ID while not sent yet - cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { - DefaultDropdownMenu(showMenu) { - if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) + @Composable + fun MsgReactionsMenu() { + val rs = MsgReaction.old.mapNotNull { r -> + if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { + r + } else { + null } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) - Divider() - SelectItemAction(showMenu, selectChatItem) } - } - cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { - DefaultDropdownMenu(showMenu) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live && !cItem.localNote) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + if (rs.isNotEmpty()) { + Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + rs.forEach() { r -> + Box( + Modifier.size(36.dp).clickable { + setReaction(cInfo, cItem, true, r) + showMenu.value = false + }, + contentAlignment = Alignment.Center + ) { + ReactionIcon(r.text, 12.sp) } - showMenu.value = false - }) - } - val clipboard = LocalClipboardManager.current - val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - - val copyAndShareAllowed = when { - cItem.content.text.isNotEmpty() -> true - cItem.file?.forwardingAllowed() == true -> true - else -> false - } - - if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - var fileSource = getLoadedFileSource(cItem.file) - val shareIfExists = { - when (val f = fileSource) { - null -> clipboard.shareText(cItem.content.text) - else -> shareFile(cItem.text, f) - } - showMenu.value = false - } - if (chatModel.connectedToRemote() && fileSource == null) { - withLongRunningApi(slow = 600_000) { - cItem.file?.loadRemoteFile(true) - fileSource = getLoadedFileSource(cItem.file) - shareIfExists() - } - } else shareIfExists() - }) - } - if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - } - if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { - SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { - ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { - withBGApi { - Log.d(TAG, "ChatItemView downloadFileAction") - val user = chatModel.currentUser.value - if (user != null) { - controller.receiveFile(rhId, user, cItem.file.fileId) - } - } - showMenu.value = false - }) - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) - showMenu.value = false - }) - } - if (cItem.meta.itemDeleted == null && - (cItem.file == null || cItem.file.forwardingAllowed()) && - !cItem.isLiveDummy && !live - ) { - ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { - forwardItem(cInfo, cItem) - showMenu.value = false - }) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) - } - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - } - if (cItem.chatDir !is CIDirection.GroupSnd) { - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) - } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.groupFeatureEnabled(GroupFeature.Reports) && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { - ReportItemAction(cItem, composeState, showMenu) } } + } + } + + @Composable + fun DeleteItemMenu() { + DefaultDropdownMenu(showMenu) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) } } } - cItem.meta.itemDeleted != null -> { + + @Composable + fun MsgContentItemDropdownMenu() { + val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) + when { + // cItem.id check is a special case for live message chat item which has negative ID while not sent yet + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) + } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { + DefaultDropdownMenu(showMenu) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() + } + if (cItem.meta.itemDeleted == null && !live && !cItem.localNote) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + val clipboard = LocalClipboardManager.current + val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } + val copyAndShareAllowed = when { + cItem.content.text.isNotEmpty() -> true + cItem.file?.forwardingAllowed() == true -> true + else -> false + } + + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + var fileSource = getLoadedFileSource(cItem.file) + val shareIfExists = { + when (val f = fileSource) { + null -> clipboard.shareText(cItem.content.text) + else -> shareFile(cItem.text, f) + } + showMenu.value = false + } + if (chatModel.connectedToRemote() && fileSource == null) { + withLongRunningApi(slow = 600_000) { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + shareIfExists() + } + } else shareIfExists() + }) + } + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + } + if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { + SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { + withBGApi { + Log.d(TAG, "ChatItemView downloadFileAction") + val user = chatModel.currentUser.value + if (user != null) { + controller.receiveFile(rhId, user, cItem.file.fileId) + } + } + showMenu.value = false + }) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + showMenu.value = false + }) + } + if (cItem.meta.itemDeleted == null && + (cItem.file == null || cItem.file.forwardingAllowed()) && + !cItem.isLiveDummy && !live + ) { + ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { + forwardItem(cInfo, cItem) + showMenu.value = false + }) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (revealed.value) { + HideItemAction(revealed, showMenu, reveal) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive) && !preview) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } + if (cItem.chatDir !is CIDirection.GroupSnd) { + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.groupFeatureEnabled(GroupFeature.Reports) && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + ReportItemAction(cItem, composeState, showMenu) + } + } + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.meta.itemDeleted != null -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + HideItemAction(revealed, showMenu, reveal) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu, reveal) + } else if (range.value != null) { + ExpandItemAction(revealed, showMenu, reveal) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.isDeletedContent -> { + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + ShrinkItemAction(revealed, showMenu, reveal) + } else { + ExpandItemAction(revealed, showMenu, reveal) + } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + else -> { + DefaultDropdownMenu(showMenu) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (selectedChatItems.value == null) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + } + } + + @Composable + fun MarkedDeletedItemDropdownMenu() { DefaultDropdownMenu(showMenu) { - if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) - } else if (!cItem.isDeletedContent) { + if (!cItem.isDeletedContent) { RevealItemAction(revealed, showMenu, reveal) - } else if (range.value != null) { - ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -428,268 +531,222 @@ fun ChatItemView( } } } - cItem.isDeletedContent -> { - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { - DefaultDropdownMenu(showMenu) { - if (revealed.value) { - ShrinkItemAction(revealed, showMenu, reveal) - } else { - ExpandItemAction(revealed, showMenu, reveal) - } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - else -> { - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (selectedChatItems.value == null) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - } - } - @Composable - fun MarkedDeletedItemDropdownMenu() { - DefaultDropdownMenu(showMenu) { - if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu, reveal) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun ContentItem() { - val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - MarkedDeletedItemDropdownMenu() - } else { - if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) + @Composable + fun ContentItem() { + val mc = cItem.content.msgContent + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemDropdownMenu() } else { - framedItemView() + if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) + } else { + framedItemView() + } + } else { + framedItemView() + } + MsgContentItemDropdownMenu() } - } else { - framedItemView() } - MsgContentItemDropdownMenu() - } - } - @Composable fun LegacyDeletedItem() { - DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) + @Composable fun LegacyDeletedItem() { + DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + + @Composable fun CallItem(status: CICallStatus, duration: Int) { + CICallItemView(cInfo, cItem, status, duration, showTimestamp = showTimestamp, acceptCall, cInfo.timedMessagesTTL) + DeleteItemMenu() + } + + fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems) + val members = when { + ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) + ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) + ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) + ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) + else -> "" + } + return if (count <= 1) { + null + } else if (ns.isEmpty()) { + generalGetString(MR.strings.rcv_group_events_count).format(count) + } else if (count > ns.size) { + members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) + } else { + members + } + } + + fun eventItemViewText(reversedChatItems: List): AnnotatedString { + val memberDisplayName = cItem.memberDisplayName + val t = mergedGroupEventText(cItem, reversedChatItems) + return if (!revealed.value && t != null) { + chatEventText(t, cItem.timestampText) + } else if (memberDisplayName != null) { + buildAnnotatedString { + withStyle(chatEventStyle) { append(memberDisplayName) } + append(" ") + }.plus(chatEventText(cItem)) + } else { + chatEventText(cItem) + } + } + + @Composable fun EventItemView() { + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + CIEventView(eventItemViewText(reversedChatItems)) + } + + @Composable + fun DeletedItem() { + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + + @Composable + fun E2EEInfoNoPQText() { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } + + @Composable + fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { + if (e2EEInfo.pqEnabled) { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } else { + E2EEInfoNoPQText() + } + } + + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> LegacyDeletedItem() + is CIContent.RcvDeleted -> LegacyDeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> if (developerTools) { + IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + DeleteItemMenu() + } else { + Box(Modifier.size(0.dp)) {} + } + is CIContent.RcvDecryptionError -> { + CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) + DeleteItemMenu() + } + is CIContent.RcvGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.SndGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + DeleteItemMenu() + } + is CIContent.SndChatPreference -> { + CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndModerated -> DeletedItem() + is CIContent.RcvModerated -> DeletedItem() + is CIContent.RcvBlocked -> DeletedItem() + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.InvalidJSON -> { + CIInvalidJSONView(c.json) + DeleteItemMenu() + } } } } - - @Composable fun CallItem(status: CICallStatus, duration: Int) { - CICallItemView(cInfo, cItem, status, duration, showTimestamp = showTimestamp, acceptCall, cInfo.timedMessagesTTL) - DeleteItemMenu() - } - - fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): String? { - val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems) - val members = when { - ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) - ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) - ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) - ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) - else -> "" - } - return if (count <= 1) { - null - } else if (ns.isEmpty()) { - generalGetString(MR.strings.rcv_group_events_count).format(count) - } else if (count > ns.size) { - members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) - } else { - members - } - } - - fun eventItemViewText(reversedChatItems: List): AnnotatedString { - val memberDisplayName = cItem.memberDisplayName - val t = mergedGroupEventText(cItem, reversedChatItems) - return if (!revealed.value && t != null) { - chatEventText(t, cItem.timestampText) - } else if (memberDisplayName != null) { - buildAnnotatedString { - withStyle(chatEventStyle) { append(memberDisplayName) } - append(" ") - }.plus(chatEventText(cItem)) - } else { - chatEventText(cItem) - } - } - - @Composable fun EventItemView() { - val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() - CIEventView(eventItemViewText(reversedChatItems)) - } - - @Composable - fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun E2EEInfoNoPQText() { - Text( - buildAnnotatedString { - withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } - }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) - ) - } - - @Composable - fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { - if (e2EEInfo.pqEnabled) { - Text( - buildAnnotatedString { - withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } - }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) - ) - } else { - E2EEInfoNoPQText() - } - } - - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> LegacyDeletedItem() - is CIContent.RcvDeleted -> LegacyDeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> if (developerTools) { - IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) - DeleteItemMenu() - } else { - Box(Modifier.size(0.dp)) {} - } - is CIContent.RcvDecryptionError -> { - CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) - DeleteItemMenu() - } - is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.RcvDirectEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupEventContent -> { - when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - else -> EventItemView() - } - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.SndConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - DeleteItemMenu() - } - is CIContent.SndChatPreference -> { - CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndModerated -> DeletedItem() - is CIContent.RcvModerated -> DeletedItem() - is CIContent.RcvBlocked -> DeletedItem() - is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.InvalidJSON -> { - CIInvalidJSONView(c.json) - DeleteItemMenu() - } + if (!cItem.chatDir.sent) { + GoToItemButton(false, lineActivated) } } - if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { ChatItemReactions() } @@ -1109,7 +1166,7 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ -> val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) } - val width = if (sent && tailVisible) size.width - msgTailWidth else size.width + val width = size.width val height = size.height val rxMax = min(msgBubbleMaxRadius, width / 2) val ryMax = min(msgBubbleMaxRadius, height / 2) @@ -1224,6 +1281,12 @@ fun shapeStyle(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVisible: Bo } } +private fun closeReportsIfNeeded() { + if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) { + ModalManager.end.closeModals() + } +} + fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { AlertManager.shared.showAlertDialog( title = generalGetString(cancelAction.alert.titleId), @@ -1338,6 +1401,7 @@ fun PreviewChatItemView( highlighted = remember { mutableStateOf(false) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, + searchIsNotBlank = remember { mutableStateOf(false) }, selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, @@ -1385,6 +1449,7 @@ fun PreviewChatItemViewDeletedContent() { highlighted = remember { mutableStateOf(false) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, + searchIsNotBlank = remember { mutableStateOf(false) }, selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, 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 1fee43f80e..689bf195f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -202,7 +202,7 @@ fun FramedItemView( Column( Modifier .width(IntrinsicSize.Max) - .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) + .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { @Composable 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 4cdd4b5c05..89dc84a1fc 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 @@ -210,8 +210,26 @@ suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag) -private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) = - apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +suspend fun openChat( + rhId: Long?, + chatType: ChatType, + apiId: Long, + contentTag: MsgContentTag? = null, + openAroundItemId: Long? = null +) = + apiLoadMessages( + rhId, + chatType, + apiId, + contentTag, + if (openAroundItemId != null) { + ChatPagination.Around(openAroundItemId, ChatPagination.INITIAL_COUNT) + } else { + ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + }, + "", + openAroundItemId + ) suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { withChats(contentTag) { From 7471fd2af5838dc0467aebc570b5ea75e5df3209 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 2 Mar 2025 22:44:26 +0000 Subject: [PATCH 096/289] docs: update privacy policy (#5646) * docs: update privacy policy * remove OSI requirement * update --- PRIVACY.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 7c4bfbf660..18e5539726 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -123,6 +123,16 @@ This section applies only to the experimental group directory operated by Simple [SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). +#### Public groups and content channels + +You may participate in a public group and receive content from a public channel (Group). In case you send messages or comments to the Group, you grant a license: +- to all recipients: + - to share your messages with the new Group members and outside of the group, e.g. via quoting (replying), forwarding and copy-pasting your message. When your message is deleted or marked as deleted, the copies of your message will not be deleted. + - to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message. +- to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages. + +Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators. + #### User Support The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information. @@ -131,9 +141,9 @@ The app includes support contact operated by SimpleX Chat Ltd. If you contact su Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics. -Preset server operators will not provide general access to their servers or the data on their servers to each other. +Preset server operators must not provide general access to their servers or the data on their servers to each other. -Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information on the servers. +Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing illegal content identified in publicly accessible resources (contact and group addresses, and downloadable files). This control port access only allows deleting known links and files, and accessing aggregate server-wide statistics, but does NOT allow enumerating any information on the servers or accessing statistics related to specific users. ### Information Preset Server Operators May Share @@ -148,7 +158,7 @@ The cases when the preset server operators may share the data temporarily stored - To detect, prevent, or otherwise address fraud, security, or technical issues. - To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law. -At the time of updating this document, the preset server operators have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law. +By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law. Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md). @@ -190,7 +200,18 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b **Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves. -**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. +**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software or by using a 3rd party client applications that satisfies the requirements of the Conditions of use (see the next section); 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. + +**3rd party client applications**. You may use a 3rd party application (App) to access preset operators' Infrastructure or systems, provided that this App: +- is compatible with the protocol specifications not older than 1 year, +- provides user-to-user messaging only or enables automated chat bots sending messages requested by users (in case of bots, it must be made clear to the users that these are automated bots), +- implements the same limits, rules and restrictions as Software, +- requires that the users accept the same Conditions of use of preset operators' Infrastructure as in Software prior to providing access to this Infrastructure, +- displays the notice that it is the App for using SimpleX network, +- provides its source code under open-source license accessible to the users via the App interface. In case the App uses the source code of Software, the App's source code must be provided under AGPLv3 license, and in case it is developed without using Software code its source code must be provided under any widely recognized free open-source license, +- does NOT use the branding of SimpleX Chat Ltd without the permission, +- does NOT pretend to be Software, +- complies with these Conditions of use. **Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss. @@ -222,4 +243,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b **Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators. -Updated November 14, 2024 +Updated March 3, 2025 From 3d076a89e729440b6f60a71ac7e19ae797f8070a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 3 Mar 2025 16:09:25 +0000 Subject: [PATCH 097/289] core: update privacy policy for the apps (#5696) --- src/Simplex/Chat/Operators.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 6af0c4a17e..15135d4e9e 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -57,10 +57,10 @@ import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) usageConditionsCommit :: Text -usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" +usageConditionsCommit = "7471fd2af5838dc0467aebc570b5ea75e5df3209" previousConditionsCommit :: Text -previousConditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c" +previousConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" usageConditionsText :: Text usageConditionsText = From 27bf19c2b1b367274df29fa760f859a843453d34 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 3 Mar 2025 20:26:05 +0400 Subject: [PATCH 098/289] ui: updated conditions (#5700) * ios: updated conditions ui * view * kotlin * show Updated conditions via review button * same for android --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/ContentView.swift | 3 +- .../Onboarding/ChooseServerOperators.swift | 3 +- .../Views/Onboarding/WhatsNewView.swift | 3 +- .../NetworkAndServers/NetworkAndServers.swift | 42 +++++++++++++--- .../common/views/chatlist/ChatListView.kt | 8 ++- .../views/onboarding/ChooseServerOperators.kt | 1 + .../common/views/onboarding/WhatsNewView.kt | 31 +++++++++--- .../networkAndServers/NetworkAndServers.kt | 50 +++++++++++++++++-- .../commonMain/resources/MR/base/strings.xml | 1 + 9 files changed, 119 insertions(+), 23 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 65631954e5..5418f4bcdd 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -303,7 +303,8 @@ struct ContentView: View { case .updatedConditions: UsageConditionsView( currUserServers: Binding.constant([]), - userServers: Binding.constant([]) + userServers: Binding.constant([]), + updated: true ) .modifier(ThemedBackground(grouped: true)) .task { await setConditionsNotified_() } diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 1a0a736acd..24379cf9e3 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -163,7 +163,8 @@ struct ChooseServerOperators: View { case .showConditions: UsageConditionsView( currUserServers: Binding.constant([]), - userServers: Binding.constant([]) + userServers: Binding.constant([]), + updated: false ) .modifier(ThemedBackground(grouped: true)) } diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index f2b4dd7928..4547c6d20a 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -647,7 +647,8 @@ struct WhatsNewView: View { case .showConditions: UsageConditionsView( currUserServers: Binding.constant([]), - userServers: Binding.constant([]) + userServers: Binding.constant([]), + updated: true ) .modifier(ThemedBackground(grouped: true)) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 16aa98bc5f..7d8424a67d 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -20,11 +20,11 @@ private enum NetworkAlert: Identifiable { } private enum NetworkAndServersSheet: Identifiable { - case showConditions + case showConditions(updated: Bool) var id: String { switch self { - case .showConditions: return "showConditions" + case let .showConditions(updated): return "showConditions \(updated)" } } } @@ -169,10 +169,11 @@ struct NetworkAndServers: View { } .sheet(item: $sheetItem) { item in switch item { - case .showConditions: + case let .showConditions(updated): UsageConditionsView( currUserServers: $ss.servers.currUserServers, - userServers: $ss.servers.userServers + userServers: $ss.servers.userServers, + updated: updated ) .modifier(ThemedBackground(grouped: true)) } @@ -218,7 +219,8 @@ struct NetworkAndServers: View { private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View { Button { - sheetItem = .showConditions + let updated = if case .review = conditionsAction { true } else { false } + sheetItem = .showConditions(updated: updated) } label: { switch conditionsAction { case .review: @@ -235,13 +237,18 @@ struct UsageConditionsView: View { @EnvironmentObject var theme: AppTheme @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] + var updated: Bool var body: some View { VStack(alignment: .leading, spacing: 20) { HStack { - Text("Conditions of use").font(.largeTitle).bold() - Spacer() - conditionsLinkButton() + if updated { + Text("Updated conditions").font(.largeTitle).bold() + } else { + Text("Conditions of use").font(.largeTitle).bold() + Spacer() + conditionsLinkButton() + } } .padding(.top) .padding(.top) @@ -265,6 +272,12 @@ struct UsageConditionsView: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 32) + if updated { + conditionsDiffButton(.footnote) + } + } else if updated { + conditionsDiffButton() + .padding(.top) } } .padding(.bottom) @@ -312,6 +325,19 @@ struct UsageConditionsView: View { } } } + + @ViewBuilder private func conditionsDiffButton(_ font: Font? = nil) -> some View { + let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit + if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") { + Link(destination: commitUrl) { + HStack { + Text("Open changes") + Image(systemName: "arrow.up.right.circle") + } + .font(font) + } + } + } } func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 2375e26dd4..e551ed9635 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -145,7 +145,13 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId) + Row( + modifier = Modifier + .clip(shape = CircleShape) + .clickable { + modalManager.showModalCloseable { close -> + UsageConditionsView( + userServers = mutableStateOf(emptyList()), + currUserServers = mutableStateOf(emptyList()), + updated = true, + close = close, + rhId = rhId + ) + } } - } - ) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + stringResource(MR.strings.view_updated_conditions), + color = MaterialTheme.colors.primary + ) + } } if (!viaSettings) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 835e01ec27..c211ba4c66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -22,8 +22,12 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.* @@ -180,7 +184,15 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { @Composable fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) { SectionItemView( - click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } }, + click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + UsageConditionsView( + currUserServers, + userServers, + updated = conditionsAction is UsageConditionsAction.Review, + close, + rhId + ) + } }, ) { Text( stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted), @@ -700,6 +712,7 @@ private fun UnsavedChangesIndicator() { fun UsageConditionsView( currUserServers: MutableState>, userServers: MutableState>, + updated: Boolean, close: () -> Unit, rhId: Long? ) { @@ -733,8 +746,35 @@ fun UsageConditionsView( } } + @Composable + fun ConditionsDiffButton() { + val uriHandler = LocalUriHandler.current + val commit = chatModel.conditions.value.currentConditions.conditionsCommit + Column ( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .clip(shape = CircleShape) + .clickable { + val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit" + uriHandler.openUriCatching(commitUrl) + } + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text(stringResource(MR.strings.operator_open_changes), color = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Icon(painterResource(MR.images.ic_outbound), contentDescription = null, tint = MaterialTheme.colors.primary) + } + } + } + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) + val title = if (updated) MR.strings.operator_updated_conditions else MR.strings.operator_conditions_of_use + AppBarTitle(stringResource(title), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) when (val conditionsAction = chatModel.conditions.value.conditionsAction) { is UsageConditionsAction.Review -> { if (conditionsAction.operators.isNotEmpty()) { @@ -743,7 +783,7 @@ fun UsageConditionsView( Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { ConditionsTextView(rhId) } - AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2) + AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null || updated) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2) if (conditionsAction.deadline != null) { SectionTextFooter( text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))), @@ -751,6 +791,10 @@ fun UsageConditionsView( ) Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) } + if (updated) { + ConditionsDiffButton() + Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) + } } is UsageConditionsAction.Accepted -> { 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 83f085fe3a..d6f182bc02 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1859,6 +1859,7 @@ View conditions Accept conditions Conditions of use + Updated conditions %s, accept conditions of use.]]> Use for messages To receive From b2de37a9fb309284584e6691323270320c31af19 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 3 Mar 2025 18:57:29 +0000 Subject: [PATCH 099/289] core: member acceptance (#5678) * core: member acceptance * migration * move hook * core: support sending direct messages to members (#5680) * fix compilation, todos * fix test * predicates * comment * extend hook * wip * wip * wip * wip * fix test * mute output * schema * better query * plans * fix test * directory * captcha * captcha works * remove column, add UI types and group status icon * fix test * query plans * exclude messages of pending members from history * commands for filter settings * core: separately delete pending approval members; other apis validation (#5699) * accepted status * send captcha messages as replies * fix blocked words * simpler filter info * info about /filter and /role after group registration * update query plans --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 5 + .../chat/simplex/common/model/ChatModel.kt | 5 + .../commonMain/resources/MR/base/strings.xml | 2 + .../resources/MR/images/ic_help_filled.svg | 1 + apps/simplex-directory-service/Main.hs | 7 +- .../src/Directory/BlockedWords.hs | 55 ++- .../src/Directory/Events.hs | 75 +++- .../src/Directory/Options.hs | 38 +- .../src/Directory/Service.hs | 384 +++++++++++++----- .../src/Directory/Store.hs | 127 ++++-- simplex-chat.cabal | 8 +- src/Simplex/Chat.hs | 3 - src/Simplex/Chat/Bot.hs | 17 +- src/Simplex/Chat/Controller.hs | 46 ++- src/Simplex/Chat/Library/Commands.hs | 207 ++++++---- src/Simplex/Chat/Library/Internal.hs | 161 +++++++- src/Simplex/Chat/Library/Subscriber.hs | 247 ++++------- src/Simplex/Chat/Messages.hs | 2 + src/Simplex/Chat/ProfileGenerator.hs | 13 - src/Simplex/Chat/Protocol.hs | 7 + src/Simplex/Chat/Store.hs | 1 + src/Simplex/Chat/Store/Groups.hs | 19 +- src/Simplex/Chat/Store/Messages.hs | 27 +- src/Simplex/Chat/Store/Profiles.hs | 40 +- .../SQLite/Migrations/chat_query_plans.txt | 4 +- src/Simplex/Chat/Types.hs | 12 + src/Simplex/Chat/Types/Shared.hs | 24 ++ src/Simplex/Chat/View.hs | 18 +- tests/Bots/DirectoryTests.hs | 15 +- tests/ChatTests/Groups.hs | 137 ++++++- tests/SchemaDump.hs | 4 +- 32 files changed, 1188 insertions(+), 525 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 6969ae325c..7f92862f66 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -154,7 +154,7 @@ struct ChatPreviewView: View { } } - @ViewBuilder private func inactiveIcon() -> some View { + private func inactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a601e60d5f..468bc2ea8f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2147,6 +2147,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { case .memGroupDeleted: return false case .memUnknown: return false case .memInvited: return false + case .memPendingApproval: return true case .memIntroduced: return false case .memIntroInvited: return false case .memAccepted: return false @@ -2165,6 +2166,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { case .memGroupDeleted: return false case .memUnknown: return false case .memInvited: return false + case .memPendingApproval: return false case .memIntroduced: return true case .memIntroInvited: return true case .memAccepted: return true @@ -2296,6 +2298,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case memGroupDeleted = "deleted" case memUnknown = "unknown" case memInvited = "invited" + case memPendingApproval = "pending_approval" case memIntroduced = "introduced" case memIntroInvited = "intro-inv" case memAccepted = "accepted" @@ -2312,6 +2315,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case .memGroupDeleted: return "group deleted" case .memUnknown: return "unknown status" case .memInvited: return "invited" + case .memPendingApproval: return "pending approval" case .memIntroduced: return "connecting (introduced)" case .memIntroInvited: return "connecting (introduction invitation)" case .memAccepted: return "connecting (accepted)" @@ -2330,6 +2334,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case .memGroupDeleted: return "group deleted" case .memUnknown: return "unknown" case .memInvited: return "invited" + case .memPendingApproval: return "pending" case .memIntroduced: return "connecting" case .memIntroInvited: return "connecting" case .memAccepted: return "connecting" 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 f784dcb9ed..7afcd69487 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 @@ -1917,6 +1917,7 @@ data class GroupMember ( GroupMemberStatus.MemGroupDeleted -> false GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false + GroupMemberStatus.MemPendingApproval -> true GroupMemberStatus.MemIntroduced -> false GroupMemberStatus.MemIntroInvited -> false GroupMemberStatus.MemAccepted -> false @@ -1933,6 +1934,7 @@ data class GroupMember ( GroupMemberStatus.MemGroupDeleted -> false GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false + GroupMemberStatus.MemPendingApproval -> false GroupMemberStatus.MemIntroduced -> true GroupMemberStatus.MemIntroInvited -> true GroupMemberStatus.MemAccepted -> true @@ -2037,6 +2039,7 @@ enum class GroupMemberStatus { @SerialName("deleted") MemGroupDeleted, @SerialName("unknown") MemUnknown, @SerialName("invited") MemInvited, + @SerialName("pending_approval") MemPendingApproval, @SerialName("introduced") MemIntroduced, @SerialName("intro-inv") MemIntroInvited, @SerialName("accepted") MemAccepted, @@ -2052,6 +2055,7 @@ enum class GroupMemberStatus { MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) 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) 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) @@ -2068,6 +2072,7 @@ enum class GroupMemberStatus { MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted) 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) MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting) MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting) MemAccepted -> generalGetString(MR.strings.group_member_status_connecting) 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 d6f182bc02..52e25a6d8d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1632,6 +1632,8 @@ group deleted unknown status invited + pending approval + pending connecting (introduced) connecting (introduction invitation) connecting (accepted) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg new file mode 100644 index 0000000000..ba3d3a393a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index b01f088483..2091ab444b 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -5,7 +5,9 @@ module Main where import Directory.Options import Directory.Service import Directory.Store +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) import Simplex.Chat.Core +import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do @@ -14,5 +16,6 @@ main = do if runCLI then directoryServiceCLI st opts else do - cfg <- directoryChatConfig opts - simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts + env <- newServiceState opts + let cfg = terminalChatConfig {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} + simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts env diff --git a/apps/simplex-directory-service/src/Directory/BlockedWords.hs b/apps/simplex-directory-service/src/Directory/BlockedWords.hs index 7477226e53..a29e2c99e0 100644 --- a/apps/simplex-directory-service/src/Directory/BlockedWords.hs +++ b/apps/simplex-directory-service/src/Directory/BlockedWords.hs @@ -1,3 +1,6 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + module Directory.BlockedWords where import Data.Char (isMark, isPunctuation, isSpace) @@ -5,28 +8,38 @@ import Data.List (isPrefixOf) import Data.Maybe (fromMaybe) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import Data.Set (Set) +import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Normalize as TN -containsBlockedWords :: Map Char [Char] -> [String] -> Text -> Bool -containsBlockedWords spelling blockedWords s = - let normalizedWords = concatMap words $ normalizeText spelling s - -- Fully normalize the entire string (no spaces or punctuation) - fullNorm = normalizeText spelling $ T.filter (not . isSpace) s - -- Check if any individual word is a swear word - wordCheck = any (`elem` blockedWords) normalizedWords - -- Check if the full string, when normalized, matches a swear word exactly - fullCheck = any (\bw -> T.length s <= length bw * 2 && any (bw ==) fullNorm) blockedWords - -- Check if the string is a single word (no spaces) - isSingleWord = not $ T.any isSpace s - in wordCheck || (fullCheck && not isSingleWord) +data BlockedWordsConfig = BlockedWordsConfig + { blockedWords :: Set Text, + blockedFragments :: Set Text, + extensionRules :: [(String, [String])], + spelling :: Map Char [Char] + } -normalizeText :: Map Char [Char] -> Text -> [String] -normalizeText spelling = - filter (not . null) - . map (filter (\c -> not (isPunctuation c) && not (isMark c))) - . allSubstitutions spelling +hasBlockedFragments :: BlockedWordsConfig -> Text -> Bool +hasBlockedFragments BlockedWordsConfig {spelling, blockedFragments} s = + any (\w -> any (`T.isInfixOf` w) blockedFragments) ws + where + ws = S.fromList $ filter (not . T.null) $ normalizeText spelling s + +hasBlockedWords :: BlockedWordsConfig -> Text -> Bool +hasBlockedWords BlockedWordsConfig {spelling, blockedWords} s = + not $ ws1 `S.disjoint` blockedWords && (length ws <= 1 || ws2 `S.disjoint` blockedWords) + where + ws = T.words s + ws1 = normalizeWords ws + ws2 = normalizeWords $ T.splitOn " " s + normalizeWords = S.fromList . filter (not . T.null) . concatMap (normalizeText spelling) + +normalizeText :: Map Char [Char] -> Text -> [Text] +normalizeText spelling' = + map (T.pack . filter (\c -> not $ isSpace c || isPunctuation c || isMark c)) + . allSubstitutions spelling' . removeTriples . T.unpack . T.toLower @@ -44,12 +57,12 @@ removeTriples xs = go xs '\0' False -- Generate all possible strings by substituting each character allSubstitutions :: Map Char [Char] -> String -> [String] -allSubstitutions spelling = sequence . map substs +allSubstitutions spelling' = sequence . map substs where - substs c = fromMaybe [c] $ M.lookup c spelling + substs c = fromMaybe [c] $ M.lookup c spelling' -wordVariants :: [(String, [String])] -> String -> [String] -wordVariants [] s = [s] +wordVariants :: [(String, [String])] -> String -> [Text] +wordVariants [] s = [T.pack s] wordVariants (sub : subs) s = concatMap (wordVariants subs) (replace sub) where replace (pat, tos) = go s diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index adaccf612f..89099ab9df 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -19,7 +19,7 @@ module Directory.Events ) where -import Control.Applicative ((<|>)) +import Control.Applicative (optional, (<|>)) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A import Data.Char (isSpace) @@ -46,6 +46,8 @@ data DirectoryEvent | DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} | DEGroupUpdated {contactId :: ContactId, fromGroup :: GroupInfo, toGroup :: GroupInfo} + | DEPendingMember GroupInfo GroupMember + | DEPendingMemberMsg GroupInfo GroupMember ChatItemId Text | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed | DEServiceRoleChanged GroupInfo GroupMemberRole | DEContactRemovedFromGroup ContactId GroupInfo @@ -65,6 +67,12 @@ crDirectoryEvent = \case CRReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} CRUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember CRGroupUpdated {fromGroup, toGroup, member_} -> (\contactId -> DEGroupUpdated {contactId, fromGroup, toGroup}) <$> (memberContactId =<< member_) + CRJoinedGroupMember {groupInfo, member = m} + | pending m -> Just $ DEPendingMember groupInfo m + | otherwise -> Nothing + CRNewChatItems {chatItems = AChatItem _ _ (GroupChat g) 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} | groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member @@ -89,6 +97,8 @@ crDirectoryEvent = \case _ -> Just $ DELogChatResponse $ "chat error: " <> tshow chatError CRChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) _ -> Nothing + where + pending m = memberStatus m == GSMemPendingApproval data DirectoryRole = DRUser | DRAdmin | DRSuperUser @@ -108,7 +118,8 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCConfirmDuplicateGroup_ :: DirectoryCmdTag 'DRUser DCListUserGroups_ :: DirectoryCmdTag 'DRUser DCDeleteGroup_ :: DirectoryCmdTag 'DRUser - DCSetRole_ :: DirectoryCmdTag 'DRUser + DCMemberRole_ :: DirectoryCmdTag 'DRUser + DCGroupFilter_ :: DirectoryCmdTag 'DRUser DCApproveGroup_ :: DirectoryCmdTag 'DRAdmin DCRejectGroup_ :: DirectoryCmdTag 'DRAdmin DCSuspendGroup_ :: DirectoryCmdTag 'DRAdmin @@ -118,6 +129,8 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCShowGroupLink_ :: DirectoryCmdTag 'DRAdmin DCSendToGroupOwner_ :: DirectoryCmdTag 'DRAdmin DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin + -- DCAddBlockedWord_ :: DirectoryCmdTag 'DRAdmin + -- DCRemoveBlockedWord_ :: DirectoryCmdTag 'DRAdmin DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser deriving instance Show (DirectoryCmdTag r) @@ -134,7 +147,8 @@ data DirectoryCmd (r :: DirectoryRole) where DCConfirmDuplicateGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser DCListUserGroups :: DirectoryCmd 'DRUser DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser - DCSetRole :: GroupId -> GroupName -> GroupMemberRole -> DirectoryCmd 'DRUser + DCMemberRole :: UserGroupRegId -> Maybe GroupName -> Maybe GroupMemberRole -> DirectoryCmd 'DRUser + DCGroupFilter :: UserGroupRegId -> Maybe GroupName -> Maybe DirectoryMemberAcceptance -> DirectoryCmd 'DRUser DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRAdmin DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin @@ -144,6 +158,8 @@ data DirectoryCmd (r :: DirectoryRole) where DCShowGroupLink :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSendToGroupOwner :: GroupId -> GroupName -> Text -> DirectoryCmd 'DRAdmin DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin + -- DCAddBlockedWord :: Text -> DirectoryCmd 'DRAdmin + -- DCRemoveBlockedWord :: Text -> DirectoryCmd 'DRAdmin DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -175,7 +191,8 @@ directoryCmdP = "list" -> u DCListUserGroups_ "ls" -> u DCListUserGroups_ "delete" -> u DCDeleteGroup_ - "role" -> u DCSetRole_ + "role" -> u DCMemberRole_ + "filter" -> u DCGroupFilter_ "approve" -> au DCApproveGroup_ "reject" -> au DCRejectGroup_ "suspend" -> au DCSuspendGroup_ @@ -185,6 +202,8 @@ directoryCmdP = "link" -> au DCShowGroupLink_ "owner" -> au DCSendToGroupOwner_ "invite" -> au DCInviteOwnerToGroup_ + -- "block_word" -> au DCAddBlockedWord_ + -- "unblock_word" -> au DCRemoveBlockedWord_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -202,10 +221,36 @@ directoryCmdP = DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup DCListUserGroups_ -> pure DCListUserGroups DCDeleteGroup_ -> gc DCDeleteGroup - DCSetRole_ -> do - (groupId, displayName) <- gc (,) - memberRole <- A.space *> ("member" $> GRMember <|> "observer" $> GRObserver) - pure $ DCSetRole groupId displayName memberRole + DCMemberRole_ -> do + (groupId, displayName_) <- gc_ (,) + memberRole_ <- optional $ spacesP *> ("member" $> GRMember <|> "observer" $> GRObserver) + pure $ DCMemberRole groupId displayName_ memberRole_ + DCGroupFilter_ -> do + (groupId, displayName_) <- gc_ (,) + acceptance_ <- + (A.takeWhile (== ' ') >> A.endOfInput) $> Nothing + <|> Just <$> (acceptancePresetsP <|> acceptanceFiltersP) + pure $ DCGroupFilter groupId displayName_ acceptance_ + where + acceptancePresetsP = + spacesP + *> A.choice + [ "no" $> noJoinFilter, + "basic" $> basicJoinFilter, + ("moderate" <|> "mod") $> moderateJoinFilter, + "strong" $> strongJoinFilter + ] + acceptanceFiltersP = do + rejectNames <- filterP "name" + passCaptcha <- filterP "captcha" + makeObserver <- filterP "observer" + pure DirectoryMemberAcceptance {rejectNames, passCaptcha, makeObserver} + filterP :: Text -> Parser (Maybe ProfileCondition) + filterP s = Just <$> (spacesP *> A.string s *> conditionP) <|> pure Nothing + conditionP = + "=all" $> PCAll + <|> ("=noimage" <|> "=no_image" <|> "=no-image") $> PCNoImage + <|> pure PCAll DCApproveGroup_ -> do (groupId, displayName) <- gc (,) groupApprovalId <- A.space *> A.decimal @@ -221,9 +266,14 @@ directoryCmdP = msg <- A.space *> A.takeText pure $ DCSendToGroupOwner groupId displayName msg DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup - DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) + -- DCAddBlockedWord_ -> DCAddBlockedWord <$> wordP + -- DCRemoveBlockedWord_ -> DCRemoveBlockedWord <$> wordP + DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (spacesP *> A.takeText) where - gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameTextP + gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) + gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) + -- wordP = spacesP *> A.takeTill (== ' ') + spacesP = A.takeWhile1 (== ' ') viewName :: Text -> Text viewName n = if T.any (== ' ') n then "'" <> n <> "'" else n @@ -240,7 +290,8 @@ directoryCmdTag = \case DCListUserGroups -> "list" DCDeleteGroup {} -> "delete" DCApproveGroup {} -> "approve" - DCSetRole {} -> "role" + DCMemberRole {} -> "role" + DCGroupFilter {} -> "filter" DCRejectGroup {} -> "reject" DCSuspendGroup {} -> "suspend" DCResumeGroup {} -> "resume" @@ -249,6 +300,8 @@ directoryCmdTag = \case DCShowGroupLink {} -> "link" DCSendToGroupOwner {} -> "owner" DCInviteOwnerToGroup {} -> "invite" + -- DCAddBlockedWord _ -> "block_word" + -- DCRemoveBlockedWord _ -> "unblock_word" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 277d332cf6..5ee52249ac 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -13,10 +13,9 @@ module Directory.Options where import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) import Options.Applicative import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (AcceptAsObserver (..), updateStr, versionNumber, versionString) +import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, coreChatOptsP) data DirectoryOpts = DirectoryOpts @@ -25,10 +24,11 @@ data DirectoryOpts = DirectoryOpts superUsers :: [KnownContact], ownersGroup :: Maybe KnownGroup, blockedWordsFile :: Maybe FilePath, + blockedFragmentsFile :: Maybe FilePath, blockedExtensionRules :: Maybe FilePath, nameSpellingFile :: Maybe FilePath, profileNameLimit :: Int, - acceptAsObserver :: Maybe AcceptAsObserver, + captchaGenerator :: Maybe FilePath, directoryLog :: Maybe FilePath, serviceName :: T.Text, runCLI :: Bool, @@ -67,7 +67,14 @@ directoryOpts appDir defaultDbName = do strOption ( long "blocked-words-file" <> metavar "BLOCKED_WORDS_FILE" - <> help "File with the basic forms of words not allowed in profiles and groups" + <> help "File with the basic forms of words not allowed in profiles" + ) + blockedFragmentsFile <- + optional $ + strOption + ( long "blocked-fragments-file" + <> metavar "BLOCKED_WORDS_FILE" + <> help "File with the basic forms of word fragments not allowed in profiles" ) blockedExtensionRules <- optional $ @@ -91,13 +98,12 @@ directoryOpts appDir defaultDbName = do <> help "Max length of profile name that will be allowed to connect and to join groups" <> value maxBound ) - acceptAsObserver <- + captchaGenerator <- optional $ - option - parseAcceptAsObserver - ( long "accept-as-observer" - <> metavar "ACCEPT_AS_OBSERVER" - <> help "Whether to accept all or some of the joining members without posting rights ('all', 'no-image', 'incognito')" + strOption + ( long "captcha-generator" + <> metavar "CAPTCHA_GENERATOR" + <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" ) directoryLog <- Just @@ -125,10 +131,11 @@ directoryOpts appDir defaultDbName = do superUsers, ownersGroup, blockedWordsFile, + blockedFragmentsFile, blockedExtensionRules, nameSpellingFile, profileNameLimit, - acceptAsObserver, + captchaGenerator, directoryLog, serviceName = T.pack serviceName, runCLI, @@ -165,12 +172,3 @@ mkChatOpts DirectoryOpts {coreOptions} = markRead = False, maintenance = False } - -parseAcceptAsObserver :: ReadM AcceptAsObserver -parseAcceptAsObserver = eitherReader $ decodeAAO . encodeUtf8 . T.pack - where - decodeAAO = \case - "all" -> Right AOAll - "name-only" -> Right AONameOnly - "incognito" -> Right AOIncognito - _ -> Left "bad AcceptAsObserver" diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 543ba2c84e..7c54f344ad 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -5,31 +5,38 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module Directory.Service ( welcomeGetOpts, directoryService, directoryServiceCLI, - directoryChatConfig + newServiceState, + acceptMemberHook ) where import Control.Concurrent (forkIO) import Control.Concurrent.Async import Control.Concurrent.STM +import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad -import Data.Composition ((.:)) -import Data.Containers.ListUtils (nubOrd) +import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Int (Int64) import Data.List (find, intercalate) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (diffUTCTime, getCurrentTime) +import qualified Data.Text.IO as T +import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Directory.BlockedWords import Directory.Events @@ -43,17 +50,24 @@ import Simplex.Chat.Core import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Store.Direct (getContact) +import Simplex.Chat.Store.Groups (getGroupInfo, getGroupLink, getGroupSummary, setGroupCustomData) +import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) import System.Directory (getAppUserDataDirectory) +import System.Process (readProcess) +import System.Random (randomRIO) data GroupProfileUpdate = GPNoServiceLink | GPServiceLinkAdded | GPServiceLinkRemoved | GPHasServiceLink | GPServiceLinkError @@ -70,13 +84,32 @@ data GroupRolesStatus deriving (Eq) data ServiceState = ServiceState - { searchRequests :: TMap ContactId SearchRequest + { searchRequests :: TMap ContactId SearchRequest, + blockedWordsCfg :: BlockedWordsConfig, + pendingCaptchas :: TMap GroupMemberId PendingCaptcha } -newServiceState :: IO ServiceState -newServiceState = do +data PendingCaptcha = PendingCaptcha + { captchaText :: Text, + sentAt :: UTCTime, + attempts :: Int + } + +captchaLength :: Int +captchaLength = 7 + +maxCaptchaAttempts :: Int +maxCaptchaAttempts = 5 + +captchaTTL :: NominalDiffTime +captchaTTL = 600 -- 10 minutes + +newServiceState :: DirectoryOpts -> IO ServiceState +newServiceState opts = do searchRequests <- TM.emptyIO - pure ServiceState {searchRequests} + blockedWordsCfg <- readBlockedWordsConfig opts + pendingCaptchas <- TM.emptyIO + pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas} welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do @@ -100,12 +133,12 @@ welcomeGetOpts = do directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () directoryServiceCLI st opts = do - env <- newServiceState + env <- newServiceState opts eventQ <- newTQueueIO let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) - cfg <- directoryChatConfig opts + chatHooks = defaultChatHooks {eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} race_ - (simplexChatCLI' cfg {chatHooks = defaultChatHooks {eventHook}} (mkChatOpts opts) Nothing) + (simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing) (processEvents eventQ env) where processEvents eventQ env = forever $ do @@ -113,31 +146,63 @@ directoryServiceCLI st opts = do u_ <- readTVarIO (currentUser cc) forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp -directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () -directoryService st opts@DirectoryOpts {testing} user cc = do +directoryService :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> IO () +directoryService st opts@DirectoryOpts {testing} env user cc = do initializeBotAddress' (not testing) cc - env <- newServiceState race_ (forever $ void getLine) . forever $ do (_, _, resp) <- atomically . readTBQueue $ outputQ cc directoryServiceEvent st opts env user cc resp -directoryChatConfig :: DirectoryOpts -> IO ChatConfig -directoryChatConfig DirectoryOpts {blockedWordsFile, nameSpellingFile, blockedExtensionRules, profileNameLimit, acceptAsObserver} = do - blockedWords <- mapM (fmap lines . readFile) blockedWordsFile +acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) +acceptMemberHook + DirectoryOpts {profileNameLimit} + ServiceState {blockedWordsCfg} + g + GroupLinkInfo {memberRole} + Profile {displayName, image = img} = runExceptT $ do + let a = groupMemberAcceptance g + when (useMemberFilter img $ rejectNames a) checkName + pure $ + if + | useMemberFilter img (passCaptcha a) -> (GAPending, GRMember) + | useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver) + | otherwise -> (GAAccepted, memberRole) + where + checkName :: ExceptT GroupRejectionReason IO () + checkName + | T.length displayName > profileNameLimit = throwError GRRLongName + | otherwise = do + when (hasBlockedFragments blockedWordsCfg displayName) $ throwError GRRBlockedName + when (hasBlockedWords blockedWordsCfg displayName) $ throwError GRRBlockedName + +groupMemberAcceptance :: GroupInfo -> DirectoryMemberAcceptance +groupMemberAcceptance GroupInfo {customData} = memberAcceptance $ fromCustomData customData + +useMemberFilter :: Maybe ImageData -> Maybe ProfileCondition -> Bool +useMemberFilter img_ = \case + Just PCAll -> True + Just PCNoImage -> maybe True (\(ImageData i) -> i == "") img_ + Nothing -> False + +readBlockedWordsConfig :: DirectoryOpts -> IO BlockedWordsConfig +readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, nameSpellingFile, blockedExtensionRules} = do + extensionRules <- maybe (pure []) (fmap read . readFile) blockedExtensionRules spelling <- maybe (pure M.empty) (fmap (M.fromList . read) . readFile) nameSpellingFile - extensionRules <- maybe (pure []) (fmap read . readFile) blockedExtensionRules - let !bws = nubOrd . concatMap (wordVariants extensionRules) <$> blockedWords - !allowedProfileName = not .: containsBlockedWords spelling <$> bws - putStrLn $ "Blocked words: " <> show (maybe 0 length bws) <> ", spelling rules: " <> show (M.size spelling) - pure terminalChatConfig {allowedProfileName, profileNameLimit, acceptAsObserver} + blockedFragments <- S.fromList <$> maybe (pure []) (fmap T.lines . T.readFile) blockedFragmentsFile + bws <- maybe (pure []) (fmap lines . readFile) blockedWordsFile + let blockedWords = S.fromList $ concatMap (wordVariants extensionRules) bws + putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) + pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () -directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} ServiceState {searchRequests} user@User {userId} cc event = +directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup + DEPendingMember g m -> dePendingMember g m + DEPendingMemberMsg g m ciId t -> dePendingMemberMsg g m ciId t DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role DEServiceRoleChanged g role -> deServiceRoleChanged g role DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g @@ -163,7 +228,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId withGroupReg GroupInfo {groupId, localDisplayName} err action = do - atomically (getGroupReg st groupId) >>= \case + getGroupReg st groupId >>= \case Just gr -> action gr Nothing -> logError $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId groupInfoText GroupProfile {displayName = n, fullName = fn, description = d} = @@ -373,10 +438,91 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own Just (Just msg) -> notifyOwner gr msg Just Nothing -> sendToApprove toGroup gr gaId + dePendingMember :: GroupInfo -> GroupMember -> IO () + dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do + s <- getCaptchaStr captchaLength "" + mc <- getCaptcha s + sentAt <- getCurrentTime + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1} + atomically $ TM.insert gmId captcha $ pendingCaptchas env + sendCaptcha mc + where + getCaptchaStr 0 s = pure s + getCaptchaStr n s = do + i <- randomRIO (0, length chars - 1) + let c = chars !! i + getCaptchaStr (n - 1) (c : s) + chars = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrsty" + getCaptcha s = case captchaGenerator opts of + Nothing -> pure textMsg + Just script -> content <$> readProcess script [s] "" + where + textMsg = MCText $ T.pack s + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just gmId) [(quotedId, MCText noticeText), (Nothing, mc)] + gmId = groupMemberId' m + + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () + approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do + gli_ <- join <$> withDB' cc (\db -> getGroupLinkInfo db userId groupId) + 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 + atomically $ TM.delete gmId $ pendingCaptchas env + logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected accept member response: " <> tshow r + + dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () + dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText + | memberRequiresCaptcha a m = do + ts <- getCurrentTime + atomically (TM.lookup (groupMemberId' m) $ pendingCaptchas env) >>= \case + Just PendingCaptcha {captchaText, sentAt, attempts} + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 + | captchaText == msgText -> do + sendComposedMessages_ cc (SRGroup groupId $ 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 + Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + rejectPendingMember rjctNotice = do + let gmId = groupMemberId' m + sendComposedMessages cc (SRGroup groupId $ Just gmId) [MCText rjctNotice] + sendChatCmd cc (APIRemoveMembers groupId [gmId]) >>= \case + CRUserDeletedMembers _ _ (_ : _) -> do + atomically $ TM.delete gmId $ pendingCaptchas env + logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired = "Captcha expired, please try again." + wrongCaptcha attempts + | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." + | otherwise = "Incorrect text, please try again." + noCaptcha = "Unexpected message, please try again." + tooManyAttempts = "Too many failed attempts, you can't join group." + + memberRequiresCaptcha :: DirectoryMemberAcceptance -> GroupMember -> Bool + memberRequiresCaptcha a GroupMember {memberProfile = LocalProfile {image}} = + useMemberFilter image $ passCaptcha a + sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do - ct_ <- getContact cc dbContactId - gr_ <- getGroupAndSummary cc dbGroupId + ct_ <- getContact' cc user dbContactId + gr_ <- getGroupAndSummary cc user dbGroupId let membersStr = maybe "" (\(_, s) -> "_" <> tshow (currentMembers s) <> " members_\n") gr_ text = maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ @@ -518,41 +664,86 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own _ -> processInvitation ct g _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." DCListUserGroups -> - atomically (getUserGroupRegs st $ contactId' ct) >>= \grs -> do + getUserGroupRegs st (contactId' ct) >>= \grs -> do sendReply $ tshow (length grs) <> " registered group(s)" + -- debug how it can be that user has 0 registered groups + when (length grs == 0) $ do + total <- length <$> readTVarIO (groupRegs st) + withSuperUsers $ \ctId -> sendMessage' cc ctId $ + "0 registered groups for " <> localDisplayName' ct <> " (" <> tshow (contactId' ct) <> ") out of " <> tshow total <> " registrations" void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} -> sendGroupInfo ct gr userGroupRegId Nothing DCDeleteGroup ugrId gName -> withUserGroupReg ugrId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do delGroupReg st gr sendReply $ "Your group " <> displayName <> " is deleted from the directory" - DCSetRole gId gName mRole -> - (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ - \GroupInfo {groupId, groupProfile = GroupProfile {displayName}} _gr -> do - gLink_ <- setGroupLinkRole cc groupId mRole - sendReply $ case gLink_ of - Nothing -> "Error: the initial member role for the group " <> displayName <> " was NOT upgated" - Just gLink -> - ("The initial member role for the group " <> displayName <> " is set to *" <> strEncodeTxt mRole <> "*\n\n") - <> ("*Please note*: it applies only to members joining via this link: " <> strEncodeTxt (simplexChatContact gLink)) + DCMemberRole gId gName_ mRole_ -> + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do + let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g + case mRole_ of + Nothing -> + getGroupLinkRole cc user g >>= \case + Just (_, gLink, mRole) -> do + let anotherRole = case mRole of GRObserver -> GRMember; _ -> GRObserver + sendReply $ + initialRole n mRole + <> ("Send */role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "* to change it.\n\n") + <> onlyViaLink gLink + Nothing -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Just mRole -> do + setGroupLinkRole cc g mRole >>= \case + Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink + Nothing -> sendReply $ "Error: the initial member role for the group " <> n <> " was NOT upgated." + where + initialRole n mRole = "The initial member role for the group " <> n <> " is set to *" <> strEncodeTxt mRole <> "*\n" + onlyViaLink gLink = "*Please note*: it applies only to members joining via this link: " <> strEncodeTxt (simplexChatContact gLink) + DCGroupFilter gId gName_ acceptance_ -> + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do + let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g + a = groupMemberAcceptance g + case acceptance_ of + Just a' | a /= a' -> do + let d = toCustomData $ DirectoryGroupData a' + withDB' cc (\db -> setGroupCustomData db user g $ Just d) >>= \case + Just () -> sendSettigns n a' " set to" + Nothing -> sendReply $ "Error changing spam filter settings for group " <> n + _ -> sendSettigns n a "" + where + sendSettigns n a setTo = + sendReply $ + T.unlines + [ "Spam filter settings for group " <> n <> setTo <> ":", + "- reject long/inappropriate names: " <> showCondition (rejectNames a), + "- pass captcha to join: " <> showCondition (passCaptcha a), + -- "- make observer: " <> showCondition (makeObserver a) <> (if isJust (makeObserver a) then "" else " (use default set with /role command)"), + "", + -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", + -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." + "Or use */filter " <> tshow gId <> " [name] [captcha]* to configure filter." + ] + showCondition = \case + Nothing -> "_disabled_" + Just PCAll -> "_enabled_" + Just PCNoImage -> "_enabled for profiles without image_" DCUnknownCommand -> sendReply "Unknown command" DCCommandError tag -> sendReply $ "Command error: " <> tshow tag where knownCt = knownContact ct isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers - withUserGroupReg ugrId gName action = - atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case + withUserGroupReg ugrId = withUserGroupReg_ ugrId . Just + withUserGroupReg_ ugrId gName_ action = + getUserGroupReg st (contactId' ct) ugrId >>= \case Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" Just gr@GroupReg {dbGroupId} -> do - getGroup cc dbGroupId >>= \case + getGroup cc user dbGroupId >>= \case Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | displayName == gName -> action g gr + | maybe True (displayName ==) gName_ -> action g gr | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName sendReply = mkSendReply ct ciId withFoundListedGroups s_ action = getGroups_ s_ >>= \case - Just groups -> atomically (filterListedGroups st groups) >>= action + Just groups -> filterListedGroups st groups >>= action Nothing -> sendReply "Error: getGroups. Please notify the developers." sendSearchResults s = \case [] -> sendReply "No groups found" @@ -560,18 +751,18 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own let gs' = takeTop searchResults gs moreGroups = length gs - length gs' more = if moreGroups > 0 then ", sending top " <> tshow (length gs') else "" - sendReply $ "Found " <> tshow (length gs) <> " group(s)" <> more <> "." + reply = "Found " <> tshow (length gs) <> " group(s)" <> more <> "." updateSearchRequest (STSearch s) $ groupIds gs' - sendFoundGroups gs' moreGroups + sendFoundGroups reply gs' moreGroups sendAllGroups takeFirst sortName searchType = \case [] -> sendReply "No groups listed" gs -> do let gs' = takeFirst searchResults gs moreGroups = length gs - length gs' more = if moreGroups > 0 then ", sending " <> sortName <> " " <> tshow (length gs') else "" - sendReply $ tshow (length gs) <> " group(s) listed" <> more <> "." + reply = tshow (length gs) <> " group(s) listed" <> more <> "." updateSearchRequest searchType $ groupIds gs' - sendFoundGroups gs' moreGroups + sendFoundGroups reply gs' moreGroups sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case [] -> do sendReply "Sorry, no more groups" @@ -580,33 +771,31 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own let gs' = takeFirst searchResults $ filterNotSent sentGroups gs sentGroups' = sentGroups <> groupIds gs' moreGroups = length gs - S.size sentGroups' - sendReply $ "Sending " <> tshow (length gs') <> " more group(s)." + reply = "Sending " <> tshow (length gs') <> " more group(s)." updateSearchRequest searchType sentGroups' - sendFoundGroups gs' moreGroups + sendFoundGroups reply gs' moreGroups updateSearchRequest :: SearchType -> Set GroupId -> IO () updateSearchRequest searchType sentGroups = do searchTime <- getCurrentTime let search = SearchRequest {searchType, searchTime, sentGroups} atomically $ TM.insert (contactId' ct) search searchRequests - sendFoundGroups gs moreGroups = - void . forkIO $ do - forM_ gs $ - \(GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" - showId = if isAdmin then tshow groupId <> ". " else "" - text = showId <> groupInfoText p <> "\n" <> membersStr - msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - when (moreGroups > 0) $ - sendComposedMessage cc ct Nothing $ - MCText $ - "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)." + sendFoundGroups reply gs moreGroups = + void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs + where + msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] + replyMsg = (Just ciId, MCText reply) + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) = + let membersStr = "_" <> tshow currentMembers <> " members_" + showId = if isAdmin then tshow groupId <> ". " else "" + text = showId <> groupInfoText p <> "\n" <> membersStr + in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) + moreMsg = (Nothing, MCText $ "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s).") deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of DCApproveGroup {groupId, displayName = n, groupApprovalId} -> - withGroupAndReg sendReply groupId n $ \g gr -> + withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId} -> readTVarIO (groupRegStatus gr) >>= \case GRSPendingApproval gaId | gaId == groupApprovalId -> do @@ -618,7 +807,10 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own Just GRSOk -> do setGroupStatus st gr GRSActive let approved = "The group " <> userGroupReference' gr n <> " is approved" - notifyOwner gr $ approved <> " and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." + notifyOwner gr $ + (approved <> " and listed in directory!\n") + <> "Please note: if you change the group profile it will be hidden from directory until it is re-approved.\n\n" + <> ("Use */filter " <> tshow ugrId <> "* to configure anti-spam filter and */role " <> tshow ugrId <> "* to set default member role.") invited <- forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do inviteToOwnersGroup og gr $ \case @@ -699,6 +891,8 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own sendReply $ "you" <> invited Left err -> sendReply err Nothing -> sendReply "owners' group is not specified" + -- DCAddBlockedWord _word -> pure () + -- DCRemoveBlockedWord _word -> pure () DCCommandError tag -> sendReply $ "Command error: " <> tshow tag | otherwise = sendReply "You are not allowed to use this command" where @@ -713,7 +907,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own else pure groups sendReply $ tshow (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> tshow count else "") void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do - ct_ <- getContact cc dbContactId + ct_ <- getContact' cc user dbContactId let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ sendGroupInfo ct gr dbGroupId $ Just ownerStr inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a @@ -735,7 +929,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own putStrLn $ T.unpack err cont $ Left err groupOwnerInfo groupRef dbContactId = do - owner_ <- getContact cc dbContactId + owner_ <- getContact' cc user dbContactId let ownerInfo = "the owner of the group " <> groupRef ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " pure $ maybe "" ownerName owner_ <> ownerInfo @@ -760,12 +954,15 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText withGroupAndReg :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () - withGroupAndReg sendReply gId gName action = - getGroup cc gId >>= \case + withGroupAndReg sendReply gId = withGroupAndReg_ sendReply gId . Just + + withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () + withGroupAndReg_ sendReply gId gName_ action = + getGroup cc user gId >>= \case Nothing -> sendReply $ "Group ID " <> tshow gId <> " not found (getGroup)" Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | displayName == gName -> - atomically (getGroupReg st gId) >>= \case + | maybe False (displayName ==) gName_ -> + getGroupReg st gId >>= \case Nothing -> sendReply $ "Registration for group ID " <> tshow gId <> " not found (getGroupReg)" Just gr -> action g gr | otherwise -> @@ -775,7 +972,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do grStatus <- readTVarIO $ groupRegStatus gr let statusStr = "Status: " <> groupRegStatusText grStatus - getGroupAndSummary cc dbGroupId >>= \case + getGroupAndSummary cc user dbGroupId >>= \case Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do let membersStr = "_" <> tshow currentMembers <> " members_" text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr] @@ -785,31 +982,36 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, own let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] sendComposedMessage cc ct Nothing $ MCText text -getContact :: ChatController -> ContactId -> IO (Maybe Contact) -getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) Nothing (CPLast 0) Nothing) - where - resp :: ChatResponse -> Maybe Contact - resp = \case - CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) _ -> Just ct - _ -> Nothing +getContact' :: ChatController -> User -> ContactId -> IO (Maybe Contact) +getContact' cc user ctId = withDB cc $ \db -> getContact db (vr cc) user ctId -getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo) -getGroup cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId) - where - resp :: ChatResponse -> Maybe GroupInfo - resp = \case - CRGroupInfo {groupInfo} -> Just groupInfo - _ -> Nothing +getGroup :: ChatController -> User -> GroupId -> IO (Maybe GroupInfo) +getGroup cc user gId = withDB cc $ \db -> getGroupInfo db (vr cc) user gId -getGroupAndSummary :: ChatController -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) -getGroupAndSummary cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId) - where - resp = \case - CRGroupInfo {groupInfo, groupSummary} -> Just (groupInfo, groupSummary) - _ -> Nothing +withDB' :: ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) +withDB' cc a = withDB cc $ ExceptT . fmap Right . a -setGroupLinkRole :: ChatController -> GroupId -> GroupMemberRole -> IO (Maybe ConnReqContact) -setGroupLinkRole cc gId mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole gId mRole) +withDB :: ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) +withDB ChatController {chatStore} action = do + r_ :: Either ChatError a <- withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors + case r_ of + Right r -> pure $ Just r + Left e -> Nothing <$ logError ("Database error: " <> tshow e) + +getGroupAndSummary :: ChatController -> User -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) +getGroupAndSummary cc user gId = + withDB cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) + +vr :: ChatController -> VersionRangeChat +vr ChatController {config = ChatConfig {chatVRange}} = chatVRange +{-# INLINE vr #-} + +getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, ConnReqContact, GroupMemberRole)) +getGroupLinkRole cc user gInfo = + withDB cc $ \db -> getGroupLink db user gInfo + +setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe ConnReqContact) +setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where resp = \case CRGroupLink _ _ gLink _ -> Just gLink diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index cecb253e8d..fed52f494f 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -3,6 +3,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} module Directory.Store ( DirectoryStore (..), @@ -10,6 +11,9 @@ module Directory.Store GroupRegStatus (..), UserGroupRegId, GroupApprovalId, + DirectoryGroupData (..), + DirectoryMemberAcceptance (..), + ProfileCondition (..), restoreDirectoryStore, addGroupReg, delGroupReg, @@ -21,25 +25,35 @@ module Directory.Store filterListedGroups, groupRegStatusText, pendingApproval, + fromCustomData, + toCustomData, + noJoinFilter, + basicJoinFilter, + moderateJoinFilter, + strongJoinFilter ) where import Control.Concurrent.STM import Control.Monad +import Data.Aeson ((.=), (.:)) +import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Composition ((.:)) import Data.Int (Int64) import Data.List (find, foldl', sortOn) import Data.Map (Map) import qualified Data.Map.Strict as M -import Data.Maybe (isJust) +import Data.Maybe (fromMaybe, isJust) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import Simplex.Chat.Types import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) import Simplex.Messaging.Util (ifM) import System.Directory (doesFileExist, renameFile) import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile) @@ -67,6 +81,51 @@ data GroupRegData = GroupRegData groupRegStatus_ :: GroupRegStatus } +data DirectoryGroupData = DirectoryGroupData + { memberAcceptance :: DirectoryMemberAcceptance + } + +-- these filters are applied in the order of fields, depending on ProfileCondition: +-- Nothing - do not apply +-- Just +-- PCAll - apply to all profiles +-- PCNoImage - apply to profiles without images +data DirectoryMemberAcceptance = DirectoryMemberAcceptance + { rejectNames :: Maybe ProfileCondition, -- reject long names and names with profanity + passCaptcha :: Maybe ProfileCondition, -- run captcha challenge with joining members + makeObserver :: Maybe ProfileCondition -- the role assigned in the end, after captcha challenge + } + deriving (Eq, Show) + +data ProfileCondition = PCAll | PCNoImage deriving (Eq, Show) + +noJoinFilter :: DirectoryMemberAcceptance +noJoinFilter = DirectoryMemberAcceptance Nothing Nothing Nothing + +basicJoinFilter :: DirectoryMemberAcceptance +basicJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCNoImage, + passCaptcha = Nothing, + makeObserver = Nothing + } + +moderateJoinFilter :: DirectoryMemberAcceptance +moderateJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCAll, + passCaptcha = Just PCNoImage, + makeObserver = Nothing + } + +strongJoinFilter :: DirectoryMemberAcceptance +strongJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCAll, + passCaptcha = Just PCAll, + makeObserver = Nothing + } + type UserGroupRegId = Int64 type GroupApprovalId = Int64 @@ -106,16 +165,31 @@ grDirectoryStatus = \case GRSSuspendedBadRoles -> DSReserved _ -> DSRegistered +$(JQ.deriveJSON (enumJSON $ dropPrefix "PC") ''ProfileCondition) + +$(JQ.deriveJSON defaultJSON ''DirectoryMemberAcceptance) + +$(JQ.deriveJSON defaultJSON ''DirectoryGroupData) + +fromCustomData :: Maybe CustomData -> DirectoryGroupData +fromCustomData cd_ = + let memberAcceptance = fromMaybe noJoinFilter $ cd_ >>= \(CustomData o) -> JT.parseMaybe (.: "memberAcceptance") o + in DirectoryGroupData {memberAcceptance} + +toCustomData :: DirectoryGroupData -> CustomData +toCustomData DirectoryGroupData {memberAcceptance} = + CustomData $ JM.fromList ["memberAcceptance" .= memberAcceptance] + addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId addGroupReg st ct GroupInfo {groupId} grStatus = do - grData <- atomically addGroupReg_ + grData <- addGroupReg_ logGCreate st grData pure $ userGroupRegId_ grData where addGroupReg_ = do let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus} gr <- dataToGroupReg grData - stateTVar (groupRegs st) $ \grs -> + atomically $ stateTVar (groupRegs st) $ \grs -> let ugrId = 1 + foldl' maxUgrId 0 grs grData' = grData {userGroupRegId_ = ugrId} gr' = gr {userGroupRegId = ugrId} @@ -149,18 +223,18 @@ setGroupRegOwner st gr owner = do logGUpdateOwner st (dbGroupId gr) memberId atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId) -getGroupReg :: DirectoryStore -> GroupId -> STM (Maybe GroupReg) -getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVar (groupRegs st) +getGroupReg :: DirectoryStore -> GroupId -> IO (Maybe GroupReg) +getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVarIO (groupRegs st) -getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> STM (Maybe GroupReg) -getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVar (groupRegs st) +getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> IO (Maybe GroupReg) +getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVarIO (groupRegs st) -getUserGroupRegs :: DirectoryStore -> ContactId -> STM [GroupReg] -getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVar (groupRegs st) +getUserGroupRegs :: DirectoryStore -> ContactId -> IO [GroupReg] +getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVarIO (groupRegs st) -filterListedGroups :: DirectoryStore -> [(GroupInfo, GroupSummary)] -> STM [(GroupInfo, GroupSummary)] +filterListedGroups :: DirectoryStore -> [(GroupInfo, GroupSummary)] -> IO [(GroupInfo, GroupSummary)] filterListedGroups st gs = do - lgs <- readTVar $ listedGroups st + lgs <- readTVarIO $ listedGroups st pure $ filter (\(GroupInfo {groupId}, _) -> groupId `S.member` lgs) gs listGroup :: DirectoryStore -> GroupId -> STM () @@ -200,10 +274,10 @@ logGDelete :: DirectoryStore -> GroupId -> IO () logGDelete st = logDLR st . GRDelete logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO () -logGUpdateStatus st = logDLR st .: GRUpdateStatus +logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO () -logGUpdateOwner st = logDLR st .: GRUpdateOwner +logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId instance StrEncoding DLRTag where strEncode = \case @@ -271,10 +345,10 @@ instance StrEncoding GroupRegStatus where "removed" -> pure GRSRemoved _ -> fail "invalid GroupRegStatus" -dataToGroupReg :: GroupRegData -> STM GroupReg +dataToGroupReg :: GroupRegData -> IO GroupReg dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = do - dbOwnerMemberId <- newTVar dbOwnerMemberId_ - groupRegStatus <- newTVar groupRegStatus_ + dbOwnerMemberId <- newTVarIO dbOwnerMemberId_ + groupRegStatus <- newTVarIO groupRegStatus_ pure GroupReg { dbGroupId = dbGroupId_, @@ -286,10 +360,9 @@ dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerM restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore restoreDirectoryStore = \case - Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= new . Just) - Nothing -> new Nothing + Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= newDirectoryStore . Just) + Nothing -> newDirectoryStore Nothing where - new = atomically . newDirectoryStore newFile f = do h <- openFile f WriteMode hSetBuffering h LineBuffering @@ -298,15 +371,15 @@ restoreDirectoryStore = \case grs <- readDirectoryData f renameFile f (f <> ".bak") h <- writeDirectoryData f grs -- compact - atomically $ mkDirectoryStore h grs + mkDirectoryStore h grs emptyStoreData :: ([GroupReg], Set GroupId, Set GroupId) emptyStoreData = ([], S.empty, S.empty) -newDirectoryStore :: Maybe Handle -> STM DirectoryStore +newDirectoryStore :: Maybe Handle -> IO DirectoryStore newDirectoryStore = (`mkDirectoryStore_` emptyStoreData) -mkDirectoryStore :: Handle -> [GroupRegData] -> STM DirectoryStore +mkDirectoryStore :: Handle -> [GroupRegData] -> IO DirectoryStore mkDirectoryStore h groups = foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h) where @@ -318,11 +391,11 @@ mkDirectoryStore h groups = DSReserved -> (grs', listed, S.insert gId reserved) DSRegistered -> (grs', listed, reserved) -mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> STM DirectoryStore +mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> IO DirectoryStore mkDirectoryStore_ h (grs, listed, reserved) = do - groupRegs <- newTVar grs - listedGroups <- newTVar listed - reservedGroups <- newTVar reserved + groupRegs <- newTVarIO grs + listedGroups <- newTVarIO listed + reservedGroups <- newTVarIO reserved pure DirectoryStore {groupRegs, listedGroups, reservedGroups, directoryLogFile = h} readDirectoryData :: FilePath -> IO [GroupRegData] diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 3f12ebd1af..f987162b78 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -416,13 +416,17 @@ executable simplex-directory-service Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - async ==2.2.* + aeson ==2.2.* + , async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 , composition ==1.0.* , containers ==0.6.* , directory ==1.3.* + , mtl >=2.3.1 && <3.0 , optparse-applicative >=0.15 && <0.17 + , process >=1.6 && <1.6.18 + , random >=1.1 && <1.3 , simple-logger ==0.1.* , simplex-chat , simplexmq >=6.3 @@ -470,6 +474,7 @@ test-suite simplex-chat-test ViewTests Broadcast.Bot Broadcast.Options + Directory.BlockedWords Directory.Events Directory.Options Directory.Search @@ -512,6 +517,7 @@ test-suite simplex-chat-test , mtl >=2.3.1 && <3.0 , network ==3.1.* , optparse-applicative >=0.15 && <0.17 + , random >=1.1 && <1.3 , silently ==1.2.* , simple-logger ==0.1.* , simplex-chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 89bd16b273..bf07e4ae51 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -112,9 +112,6 @@ defaultChatConfig = ntf = _defaultNtfServers, netCfg = defaultNetworkConfig }, - allowedProfileName = Nothing, - profileNameLimit = maxBound, - acceptAsObserver = Nothing, tbqSize = 1024, fileChunkSize = 15780, -- do not change xftpDescrPartSize = 14000, diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index e1f5ce1ef9..54e7baa194 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE TupleSections #-} module Simplex.Chat.Bot where @@ -11,6 +12,8 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad import qualified Data.ByteString.Char8 as B +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M import Data.Text (Text) import qualified Data.Text as T @@ -68,10 +71,16 @@ sendComposedMessage :: ChatController -> Contact -> Maybe ChatItemId -> MsgConte sendComposedMessage cc = sendComposedMessage' cc . contactId' sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO () -sendComposedMessage' cc ctId quotedItemId msgContent = do - let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent, mentions = M.empty} - sendChatCmd cc (APISendMessages (ChatRef CTDirect ctId) False Nothing [cm]) >>= \case - CRNewChatItems {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId +sendComposedMessage' cc ctId qiId mc = sendComposedMessages_ cc (SRDirect ctId) [(qiId, mc)] + +sendComposedMessages :: ChatController -> SendRef -> NonEmpty MsgContent -> IO () +sendComposedMessages cc sendRef = sendComposedMessages_ cc sendRef . L.map (Nothing,) + +sendComposedMessages_ :: ChatController -> SendRef -> NonEmpty (Maybe ChatItemId, MsgContent) -> IO () +sendComposedMessages_ cc sendRef qmcs = do + let cms = L.map (\(qiId, mc) -> ComposedMessage {fileSource = Nothing, quotedItemId = qiId, msgContent = mc, mentions = M.empty}) qmcs + sendChatCmd cc (APISendMessages sendRef False Nothing cms) >>= \case + CRNewChatItems {} -> printLog cc CLLInfo $ "sent " <> show (length cms) <> " messages to " <> show sendRef r -> putStrLn $ "unexpected send message response: " <> show r deleteMessage :: ChatController -> Contact -> ChatItemId -> IO () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 71b57c72b2..e639771f41 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -19,7 +19,8 @@ module Simplex.Chat.Controller where import Control.Concurrent (ThreadId) import Control.Concurrent.Async (Async) -import Control.Exception +import Control.Exception (Exception, SomeException) +import qualified Control.Exception as E import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader @@ -60,7 +61,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types import Simplex.Chat.Stats (PresentedServersSummary) -import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, GroupLinkInfo, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -93,7 +94,6 @@ import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitatio import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) -import qualified UnliftIO.Exception as E import UnliftIO.STM #if !defined(dbPostgres) import Database.SQLite.Simple (SQLError) @@ -137,9 +137,6 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, - allowedProfileName :: Maybe (ContactName -> Bool), - profileNameLimit :: Int, - acceptAsObserver :: Maybe AcceptAsObserver, tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -161,11 +158,6 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } -data AcceptAsObserver - = AOAll -- all members - | AONameOnly -- members without image - | AOIncognito -- members with incognito-style names and without image - data RandomAgentServers = RandomAgentServers { smpServers :: NonEmpty (ServerCfg 'PSMP), xftpServers :: NonEmpty (ServerCfg 'PXFTP) @@ -177,18 +169,16 @@ data ChatHooks = ChatHooks { -- preCmdHook can be used to process or modify the commands before they are processed. -- This hook should be used to process CustomChatCommand. -- if this hook returns ChatResponse, the command processing will be skipped. - preCmdHook :: ChatController -> ChatCommand -> IO (Either ChatResponse ChatCommand), + preCmdHook :: Maybe (ChatController -> ChatCommand -> IO (Either ChatResponse ChatCommand)), -- eventHook can be used to additionally process or modify events, -- it is called before the event is sent to the user (or to the UI). - eventHook :: ChatController -> ChatResponse -> IO ChatResponse + eventHook :: Maybe (ChatController -> ChatResponse -> IO ChatResponse), + -- acceptMember hook can be used to accept or reject member connecting via group link without API calls + acceptMember :: Maybe (GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole))) } defaultChatHooks :: ChatHooks -defaultChatHooks = - ChatHooks - { preCmdHook = \_ -> pure . Right, - eventHook = \_ -> pure - } +defaultChatHooks = ChatHooks Nothing Nothing Nothing data PresetServers = PresetServers { operators :: NonEmpty PresetOperator, @@ -313,7 +303,7 @@ data ChatCommand | APIGetChat ChatRef (Maybe MsgContentTag) ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId - | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} + | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatTag ChatTagData | APISetChatTags ChatRef (Maybe (NonEmpty ChatTagId)) | APIDeleteChatTag ChatTagId @@ -366,6 +356,7 @@ data ChatCommand | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} + | APIAcceptMember GroupId GroupMemberId GroupMemberRole | APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole | APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool | APIRemoveMembers GroupId (NonEmpty GroupMemberId) @@ -906,6 +897,17 @@ 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) + deriving (Eq, Show) + +sendToChatRef :: SendRef -> ChatRef +sendToChatRef = \case + SRDirect cId -> ChatRef CTDirect cId + SRGroup gId _ -> ChatRef CTGroup gId + data ChatPagination = CPLast Int | CPAfter ChatItemId Int @@ -1509,7 +1511,9 @@ toView = lift . toView' toView' :: ChatResponse -> CM' () toView' ev = do cc@ChatController {outputQ = localQ, remoteCtrlSession = session, config = ChatConfig {chatHooks}} <- ask - event <- liftIO $ eventHook chatHooks cc ev + event <- case eventHook chatHooks of + Just hook -> liftIO $ hook cc ev + Nothing -> pure ev atomically $ readTVar session >>= \case Just (_, RCSessionConnected {remoteOutputQ}) @@ -1544,7 +1548,7 @@ withStoreBatch actions = do liftIO $ withTransaction chatStore $ mapM (`E.catches` handleDBErrors) . actions -- TODO [postgres] postgres specific error handling -handleDBErrors :: [E.Handler IO (Either ChatError a)] +handleDBErrors :: [E.Handler (Either ChatError a)] handleDBErrors = #if !defined(dbPostgres) ( E.Handler $ \(e :: SQLError) -> diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f44dca9026..821bc3d6ce 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -277,7 +277,9 @@ execChatCommand rh s = do | otherwise -> pure $ CRChatCmdError u $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand _ -> do cc@ChatController {config = ChatConfig {chatHooks}} <- ask - liftIO (preCmdHook chatHooks cc cmd) >>= either pure (execChatCommand_ u) + case preCmdHook chatHooks of + Just hook -> liftIO (hook cc cmd) >>= either pure (execChatCommand_ u) + Nothing -> execChatCommand_ u cmd execChatCommand' :: ChatCommand -> CM' ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) @@ -536,20 +538,17 @@ processChatCommand' vr = \case Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) _ -> pure Nothing - APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of - CTDirect -> do + 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) - CTGroup -> + SRGroup chatId directMemId_ -> 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 live itemTTL cmrs - CTLocal -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + sendGroupContentMessages user gInfo directMemId_ live itemTTL cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user @@ -583,7 +582,8 @@ processChatCommand' vr = \case 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" - sendGroupContentMessages_ user gInfo ms' False Nothing [composedMessageReq cm] + 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 @@ -633,6 +633,7 @@ processChatCommand' vr = \case 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)) ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime @@ -687,6 +688,7 @@ processChatCommand' vr = \case 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 CTLocal -> do @@ -764,6 +766,7 @@ 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) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do @@ -847,7 +850,7 @@ processChatCommand' vr = \case Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId - sendGroupContentMessages user gInfo False itemTTL cmrs' + sendGroupContentMessages user gInfo Nothing False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -1084,6 +1087,7 @@ processChatCommand' vr = \case cancelFilesInProgress user filesInfo deleteFilesLocally 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 deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel @@ -1127,7 +1131,7 @@ processChatCommand' vr = \case (user@User {userId}, cReq) <- withFastStore $ \db -> getContactRequest' db connReqId (ct, conn@Connection {connId}, sqSecured) <- acceptContactRequest user cReq incognito ucl <- withFastStore $ \db -> getUserContactLinkById db userId userContactLinkId - let contactUsed = (\(_, groupId_, _) -> isNothing groupId_) ucl + let contactUsed = (\(_, gLinkInfo_) -> isNothing gLinkInfo_) ucl ct' <- withStore' $ \db -> do deleteContactRequestRec db user cReq updateContactAccepted db user ct contactUsed @@ -1838,8 +1842,8 @@ processChatCommand' vr = \case CTDirect -> withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do - let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing [composedMessage Nothing mc] + let sendRef = SRDirect ctId + processChatCommand $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] Left _ -> withFastStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case Right [(gInfo, member)] -> do @@ -1854,8 +1858,8 @@ processChatCommand' vr = \case (gId, mentions) <- withFastStore $ \db -> do gId <- getGroupIdByName db user name (gId,) <$> liftIO (getMessageMentions db user gId msg) - let chatRef = ChatRef CTGroup gId - processChatCommand $ APISendMessages chatRef False Nothing [ComposedMessage Nothing Nothing mc mentions] + let sendRef = SRGroup gId Nothing + processChatCommand $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] CTLocal | name == "" -> do folderId <- withFastStore (`getUserNoteFolderId` user) @@ -1877,12 +1881,13 @@ processChatCommand' vr = \case processChatCommand $ APISendMemberContactInvitation contactId (Just mc) cr -> pure cr Just ctId -> do - let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing [composedMessage Nothing mc] + let sendRef = SRDirect ctId + processChatCommand $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] SendLiveMessage chatName msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg - let mc = MCText msg - processChatCommand $ APISendMessages chatRef True Nothing [ComposedMessage Nothing Nothing mc mentions] + withSendRef chatRef $ \sendRef -> do + let mc = MCText msg + processChatCommand $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withFastStore' $ \db -> getUserContacts db vr user withChatLock "sendMessageBroadcast" . procCmd $ do @@ -1922,12 +1927,12 @@ 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) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False createdAt + void $ createNewSndChatItem db user (CDDirectSnd ct) Nothing 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 let mc = MCText msg - processChatCommand $ APISendMessages (ChatRef CTDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] + processChatCommand $ APISendMessages (SRDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg @@ -2023,14 +2028,27 @@ 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 + (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 APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId . procCmd $ do g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwChatError $ CECommandError "can't change role for self" - let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin) = selectMembers members + let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ throwChatError $ CECommandError "can't change role of multiple members when admins selected, or new role is admin" + when anyPending $ throwChatError $ CECommandError "can't change role of members pending approval" assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems (errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems @@ -2040,19 +2058,20 @@ processChatCommand' vr = \case pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds - selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) - selectMembers = foldr' addMember ([], [], [], GRObserver, False) + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) + selectMembers = foldr' addMember ([], [], [], GRObserver, False, False) where - addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin) + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending) | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin + anyPending' = anyPending || memberStatus == GSMemPendingApproval in if - | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin') - | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin') - | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin') - | otherwise = (invited, current, unchanged, maxRole, anyAdmin) + | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending') + | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending') + | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending') + | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending) changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) changeRoleInvitedMems user gInfo memsToChange = do -- not batched, as we need to send different invitations to different connections anyway @@ -2074,7 +2093,7 @@ processChatCommand' vr = \case let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' (msgs_, _gsr) <- sendGroupMessages user gInfo members events let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) 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_ @@ -2084,7 +2103,7 @@ processChatCommand' vr = \case sndItemData GroupMember {groupMemberId, memberProfile} msg = let content = CISndGroupEvent $ SGEMemberRole groupMemberId (fromLocalProfile memberProfile) newRole ts = ciContentTexts content - in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing updMember db m = do updateGroupMemberRole db user m newRole pure (m :: GroupMember) {memberRole = newRole} @@ -2092,22 +2111,24 @@ processChatCommand' vr = \case withGroupLock "blockForAll" groupId . procCmd $ do Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwChatError $ CECommandError "can't block/unblock self" - let (blockMems, remainingMems, maxRole, anyAdmin) = selectMembers members + let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't block/unblock multiple members when admins selected" + when anyPending $ throwChatError $ CECommandError "can't block/unblock members pending approval" assertUserGroupRole gInfo $ max GRModerator maxRole blockMembers user gInfo blockMems remainingMems where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds - selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool) - selectMembers = foldr' addMember ([], [], GRObserver, False) + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) + selectMembers = foldr' addMember ([], [], GRObserver, False, False) where - addMember m@GroupMember {groupMemberId, memberRole} (block, remaining, maxRole, anyAdmin) + addMember m@GroupMember {groupMemberId, memberRole, memberStatus} (block, remaining, maxRole, anyAdmin, anyPending) | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin - in (m : block, remaining, maxRole', anyAdmin') - | otherwise = (block, m : remaining, maxRole, anyAdmin) + anyPending' = anyPending || memberStatus == GSMemPendingApproval + in (m : block, remaining, maxRole', anyAdmin', anyPending') + | otherwise = (block, m : remaining, maxRole, anyAdmin, anyPending) blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse blockMembers user gInfo blockMems remainingMems = case L.nonEmpty blockMems of Nothing -> throwChatError $ CECommandError "no members to block/unblock" @@ -2116,7 +2137,7 @@ processChatCommand' vr = \case events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' (msgs_, _gsr) <- sendGroupMessages user gInfo remainingMems events let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) 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_ unless (null acis) $ toView $ CRNewChatItems user acis @@ -2130,33 +2151,37 @@ processChatCommand' vr = \case sndItemData GroupMember {groupMemberId, memberProfile} msg = let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag ts = ciContentTexts content - in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing APIRemoveMembers groupId memberIds -> withUser $ \user -> withGroupLock "removeMembers" groupId . procCmd $ do - g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId - let (invitedMems, currentMems, maxRole, anyAdmin) = selectMembers members - when (length invitedMems + length currentMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + let (invitedMems, pendingMems, currentMems, maxRole, anyAdmin) = selectMembers members + when (length invitedMems + length pendingMems + length currentMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't remove multiple members when admins selected" assertUserGroupRole gInfo $ max GRAdmin maxRole (errs1, deleted1) <- deleteInvitedMems user invitedMems - (errs2, deleted2, acis) <- deleteCurrentMems user g currentMems + (errs2, deleted2, acis2) <- deleteMemsSend user gInfo members currentMems + rs <- forM pendingMems $ \m -> deleteMemsSend user gInfo [m] [m] + let (errs3, deleted3, acis3) = concatTuples rs + acis = acis2 <> acis3 + errs = errs1 <> errs2 <> errs3 unless (null acis) $ toView $ CRNewChatItems user acis - let errs = errs1 <> errs2 unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2) -- same order is not guaranteed + pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2 <> deleted3) -- same order is not guaranteed where - selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool) - selectMembers = foldr' addMember ([], [], GRObserver, False) + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldr' addMember ([], [], [], GRObserver, False) where - addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, maxRole, anyAdmin) + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, pending, current, maxRole, anyAdmin) | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin in - if memberStatus == GSMemInvited - then (m : invited, current, maxRole', anyAdmin') - else (invited, m : current, maxRole', anyAdmin') - | otherwise = (invited, current, maxRole, anyAdmin) + case memberStatus of + GSMemInvited -> (m : invited, pending, current, maxRole', anyAdmin') + GSMemPendingApproval -> (invited, m : pending, current, maxRole', anyAdmin') + _ -> (invited, pending, m : current, maxRole', anyAdmin') + | otherwise = (invited, pending, current, maxRole, anyAdmin) deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) deleteInvitedMems user memsToDelete = do deleteMembersConnections user memsToDelete @@ -2165,14 +2190,14 @@ processChatCommand' vr = \case delMember db m = do deleteGroupMember db user m pure m {memberStatus = GSMemRemoved} - deleteCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) - deleteCurrentMems user (Group gInfo members) memsToDelete = case L.nonEmpty memsToDelete of + deleteMemsSend :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + deleteMemsSend user gInfo sendToMems memsToDelete = case L.nonEmpty memsToDelete of Nothing -> pure ([], [], []) Just memsToDelete' -> do let events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId) memsToDelete' - (msgs_, _gsr) <- sendGroupMessages user gInfo members events + (msgs_, _gsr) <- sendGroupMessages user gInfo sendToMems events let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) Nothing 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) @@ -2183,15 +2208,19 @@ processChatCommand' vr = \case sndItemData GroupMember {groupMemberId, memberProfile} msg = let content = CISndGroupEvent $ SGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) ts = ciContentTexts content - in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing delMember db m = do deleteOrUpdateMemberRecordIO db user m pure m {memberStatus = GSMemRemoved} + 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] @@ -2320,7 +2349,7 @@ processChatCommand' vr = \case qiId <- getGroupChatItemIdByText db user gId cName quotedMsg (gId, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand $ APISendMessages (ChatRef CTGroup groupId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] + 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) @@ -2361,15 +2390,16 @@ processChatCommand' vr = \case chatRef <- getChatRef user chatName case chatRef of ChatRef CTLocal folderId -> processChatCommand $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] - _ -> processChatCommand $ APISendMessages chatRef False Nothing [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 - filePath <- lift $ toFSFilePath fPath - unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} - fileSize <- getFileSize filePath - unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} - -- TODO include file description for preview - processChatCommand $ APISendMessages chatRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] + withSendRef chatRef $ \sendRef -> do + filePath <- lift $ toFSFilePath fPath + unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} + fileSize <- getFileSize filePath + unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} + -- TODO include file description for preview + processChatCommand $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" @@ -2403,6 +2433,7 @@ processChatCommand' vr = \case 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 @@ -2795,6 +2826,7 @@ 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'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' sendGroupMessage user g' newMs $ XGrpPrefs ps' @@ -2823,6 +2855,8 @@ processChatCommand' vr = \case 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 where @@ -3115,7 +3149,7 @@ 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) itemsData timed_ live + r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) Nothing itemsData timed_ live processSendErrs user r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> @@ -3151,14 +3185,26 @@ 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 -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user gInfo live itemTTL cmrs = do + sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupMemberId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo@GroupInfo {membership} directMemId_ live itemTTL cmrs = do assertMultiSendable live cmrs - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - sendGroupContentMessages_ user gInfo ms live itemTTL cmrs - sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do - assertUserGroupRole gInfo GRAuthor + (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 + forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages where @@ -3175,12 +3221,12 @@ processChatCommand' vr = \case Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do - (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) + (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers numFileInvs timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ (msgs_, gsr) <- sendGroupMessages user gInfo ms chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) notInHistory_ itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ @@ -3351,6 +3397,11 @@ processChatCommand' vr = \case getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) 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 + _ -> throwChatError $ CECommandError "not supported" protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) protocolServers p (operators, smpServers, xftpServers) = case p of @@ -3833,7 +3884,7 @@ chatCommandP = "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), - "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create tag " *> (APICreateChatTag <$> jsonP), "/_tags " *> (APISetChatTags <$> chatRefP <*> optional _strP), "/_delete tag " *> (APIDeleteChatTag <$> A.decimal), @@ -3886,6 +3937,7 @@ chatCommandP = "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), "/_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), "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* A.space <* "blocked=" <*> onOffP), "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP), @@ -4206,6 +4258,9 @@ chatCommandP = ct -> ChatName ct <$> displayNameP chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP chatRefP = ChatRef <$> chatTypeP <*> A.decimal + sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional (" @" *> A.decimal)) 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 eba1bf169f..1b18abda37 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -38,7 +38,7 @@ import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) import Data.List (find, mapAccumL, partition) -import Data.List.NonEmpty (NonEmpty (..)) +import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -78,7 +78,7 @@ import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (getFastNetworkConfig, ipAddressProtected, withLockMap) -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), ServerCfg (..)) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) @@ -820,17 +820,19 @@ acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = Agen setCommandConnId db user cmdId connId pure ct -acceptGroupJoinRequestAsync :: User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> GroupInfo -> UserContactRequest -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember acceptGroupJoinRequestAsync user gInfo@GroupInfo {groupProfile, membership, businessChat} ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} + gAccepted gLinkMemRole incognitoProfile = do gVar <- asks random + let initialStatus = acceptanceToStatus gAccepted (groupMemberId, memberId) <- withStore $ \db -> do liftIO $ deleteContactRequestRec db user ucr - createJoiningMember db gVar user gInfo ucr gLinkMemRole GSMemAccepted + createJoiningMember db gVar user gInfo ucr gLinkMemRole initialStatus currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let Profile {displayName} = profileToSendOnAccept user incognitoProfile True GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -841,6 +843,7 @@ acceptGroupJoinRequestAsync fromMemberName = displayName, invitedMember = MemberIdRole memberId gLinkMemRole, groupProfile, + accepted = Just gAccepted, business = businessChat, groupSize = Just currentMemCount } @@ -900,6 +903,7 @@ acceptBusinessJoinRequestAsync fromMemberName = displayName, invitedMember = MemberIdRole memberId GRMember, groupProfile = businessGroupProfile userProfile groupPreferences, + accepted = Just GAAccepted, -- This refers to the "title member" that defines the group name and profile. -- This coincides with fromMember to be current user when accepting the connecting user, -- but it will be different when inviting somebody else. @@ -926,6 +930,132 @@ 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 + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m + sendIntroductions members + when (groupFeatureAllowed SGFHistory gInfo) sendHistory + where + sendIntroductions members = do + intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m + shuffledIntros <- liftIO $ shuffleIntros intros + if m `supportsVersion` batchSendVersion + then do + let events = map (memberIntro . reMember) shuffledIntros + forM_ (L.nonEmpty events) $ \events' -> + sendGroupMemberMessages user conn events' groupId + else forM_ shuffledIntros $ \intro -> + processIntro intro `catchChatError` (toView . CRChatError (Just user)) + memberIntro :: GroupMember -> ChatMsgEvent 'Json + memberIntro reMember = + let mInfo = memberInfo reMember + mRestrictions = memberRestrictions reMember + in XGrpMemIntro mInfo mRestrictions + shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] + shuffleIntros intros = do + let (admins, others) = partition isAdmin intros + (admPics, admNoPics) = partition hasPicture admins + (othPics, othNoPics) = partition hasPicture others + mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] + where + isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin + hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image + 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 + descrEvent_ :: Maybe (ChatMsgEvent 'Json) + descrEvent_ + | m `supportsVersion` groupHistoryIncludeWelcomeVersion = do + let GroupInfo {groupProfile = GroupProfile {description}} = gInfo + fmap (\descr -> XMsgNew $ MCSimple $ extMsgContent (MCText descr) Nothing) description + | otherwise = Nothing + itemForwardEvents :: CChatItem 'CTGroup -> CM [ChatMsgEvent 'Json] + itemForwardEvents cci = case cci of + (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) + | not (blockedByAdmin sender) -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender ci mc fInvDescr_ + (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do + fInvDescr_ <- join <$> forM file getSndFileInvDescr + processContentItem membership ci mc fInvDescr_ + _ -> pure [] + where + getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText)) + getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired + then pure Nothing + else do + rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + pure $ invCompleteDescr ciFile rfd + getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText)) + getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired + then pure Nothing + else do + -- can also lookup in extra_xftp_file_descriptions, though it can be empty; + -- would be best if snd file had a single rcv description for all members saved in files table + rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId + pure $ invCompleteDescr ciFile rfd + fileExpired :: CM Bool + fileExpired = do + ttl <- asks $ rcvFilesTTL . agentConfig . config + cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime + pure $ chatItemTs cci < cutoffTs + invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText) + invCompleteDescr CIFile {fileName, fileSize} RcvFileDescr {fileDescrText, fileDescrComplete} + | fileDescrComplete = + let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} + fInv = xftpFileInvitation fileName fileSize fInvDescr + 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_ = + if isNothing fInvDescr_ && not (msgContentHasText mc) + then pure [] + else do + let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta + quotedItemId_ = quoteItemId =<< quotedItem + 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 + let senderVRange = memberChatVRange' sender + xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} + fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of + (Just fileDescrText, Just msgId) -> do + partSize <- asks $ xftpDescrPartSize . config + let parts = splitFileDescr partSize fileDescrText + pure . L.toList $ L.map (XMsgFileDescr msgId) parts + _ -> pure [] + let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents + GroupMember {memberId} = sender + msgForwardEvents = map (\cm -> XGrpMsgForward memberId cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) + pure msgForwardEvents + +splitFileDescr :: Int -> RcvFileDescrText -> NonEmpty FileDescr +splitFileDescr partSize rfdText = splitParts 1 rfdText + where + splitParts partNo remText = + let (part, rest) = T.splitAt partSize remText + complete = T.null rest + fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} + in if complete + then fileDescr :| [] + else fileDescr <| splitParts (partNo + 1) rest + deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do vr <- chatVersionRange @@ -1459,6 +1589,7 @@ sendGroupMessage' user gInfo members chatMsgEvent = 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? when shouldSendProfileUpdate $ sendProfileUpdate `catchChatError` (toView . CRChatError (Just user)) sendGroupMessages_ user gInfo members events @@ -1489,7 +1620,10 @@ sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> No sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do let idsEvts = L.map (GroupId groupId,) events sndMsgs_ <- lift $ createSndMessages idsEvts - recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) + -- 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) let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events} (toSendSeparate, toSendBatched, toPending, forwarded, _, dups) = foldr' addMember ([], [], [], [], S.empty, 0 :: Int) recipientMembers @@ -1691,7 +1825,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 [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case + saveSndChatItems user cd Nothing [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case [Right ci] -> pure ci _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item" @@ -1710,11 +1844,12 @@ 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 itemsData itemTimed live = do +saveSndChatItems user cd notInHistory_ itemsData itemTimed live = do createdAt <- liftIO getCurrentTime when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ withStore' (\db -> updateChatTs db user cd createdAt) @@ -1722,7 +1857,7 @@ saveSndChatItems user cd 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 msg content quotedItem itemForwarded itemTimed live createdAt + ciId <- createNewSndChatItem db user cd notInHistory_ 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 @@ -1734,13 +1869,13 @@ 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 msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty + saveRcvChatItem' user cd Nothing 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' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do +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 createdAt <- liftIO getCurrentTime withStore' $ \db -> do when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt @@ -1753,7 +1888,7 @@ 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) - (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt + (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd notInHistory_ 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 @@ -1999,7 +2134,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 content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt + ciId <- createNewChatItem_ db user cd Nothing 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 087d49e49a..739ab25ea9 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -27,8 +27,8 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (lefts, partitionEithers, rights) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (foldl', partition) -import Data.List.NonEmpty (NonEmpty (..), (<|)) +import Data.List (foldl') +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -36,8 +36,7 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) -import Data.Time (addUTCTime) -import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime) +import Data.Time.Clock (UTCTime, diffUTCTime) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) @@ -47,7 +46,7 @@ import Simplex.Chat.Library.Internal import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events -import Simplex.Chat.ProfileGenerator (generateRandomProfile, isRandomName) +import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -60,14 +59,12 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Description (ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (FilePartyI) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent as Agent -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -296,17 +293,6 @@ agentFileError = \case SMP.TRANSPORT TEVersion -> srvErr SrvErrVersion e -> srvErr . SrvErrOther $ tshow e -splitFileDescr :: Int -> RcvFileDescrText -> NonEmpty FileDescr -splitFileDescr partSize rfdText = splitParts 1 rfdText - where - splitParts partNo remText = - let (part, rest) = T.splitAt partSize remText - complete = T.null rest - fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} - in if complete - then fileDescr :| [] - else fileDescr <| splitParts (partNo + 1) rest - processAgentMsgRcvFile :: ACorrId -> RcvFileId -> AEvent 'AERcvFile -> CM () processAgentMsgRcvFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPRcvFileDBIds` AgentRcvFileId aFileId) @@ -592,14 +578,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> resetContactConnInitiated db user conn' forM_ viaUserContactLink $ \userContactLinkId -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl + let (UserContactLink {autoAccept}, gli_) = ucl when (connChatVersion < batchSend2Version) $ sendAutoReply ct' autoAccept - forM_ groupId_ $ \groupId -> do + -- TODO REMOVE LEGACY vvv + forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode + -- TODO REMOVE LEGACY ^^^ Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct @@ -658,7 +646,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRContactSndReady user ct forM_ viaUserContactLink $ \userContactLinkId -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {autoAccept}, _, _) = ucl + let (UserContactLink {autoAccept}, _) = ucl when (connChatVersion >= batchSend2Version) $ sendAutoReply ct autoAccept QCONT -> void $ continueSending connEntity conn @@ -703,6 +691,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = liftIO $ setConnConnReqInv db user connId cReq getHostConnId db user groupId sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + -- TODO REMOVE LEGACY vvv -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do ct <- withStore $ \db -> getContactViaMember db vr user m @@ -728,6 +717,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 + -- TODO REMOVE LEGACY ^^^ _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _pqSupport _ connInfo -> do @@ -765,7 +755,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" -- sent when connecting via group link XInfo _ -> - -- TODO [group rejection] Keep rejected member record and connection for ability to start dialogue. + -- TODO Keep rejected member to allow them to appeal against rejection. when (memberStatus m == GSMemRejected) $ do deleteMemberConnection' user m True withStore' $ \db -> deleteGroupMember db user m @@ -773,16 +763,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () CON _pqEnc -> unless (memberStatus m == GSMemRejected) $ 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 + 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 withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do - toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} + toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = status'}} m {memberStatus = status'} let cd = CDGroupRcv gInfo m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CIRcvGroupFeature gInfo @@ -793,125 +787,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion GCInviteeMember -> do memberConnectedChatItem gInfo m - toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} + toView $ CRJoinedGroupMember user gInfo m {memberStatus = status'} let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem - members <- withStore' $ \db -> getGroupMembers db vr user gInfo - void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m - sendIntroductions members - when (groupFeatureAllowed SGFHistory gInfo) sendHistory when (connChatVersion < batchSend2Version) sendGroupAutoReply + unless (status' == GSMemPendingApproval) $ introduceToGroup vr user gInfo m where sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo profileToSend = profileToSendOnAccept user profileMode True void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId - sendIntroductions members = do - intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m - shuffledIntros <- liftIO $ shuffleIntros intros - if m `supportsVersion` batchSendVersion - then do - let events = map (memberIntro . reMember) shuffledIntros - forM_ (L.nonEmpty events) $ \events' -> - sendGroupMemberMessages user conn events' groupId - else forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` (toView . CRChatError (Just user)) - memberIntro :: GroupMember -> ChatMsgEvent 'Json - memberIntro reMember = - let mInfo = memberInfo reMember - mRestrictions = memberRestrictions reMember - in XGrpMemIntro mInfo mRestrictions - shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] - shuffleIntros intros = do - let (admins, others) = partition isAdmin intros - (admPics, admNoPics) = partition hasPicture admins - (othPics, othNoPics) = partition hasPicture others - mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] - where - isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin - hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image - 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 - descrEvent_ :: Maybe (ChatMsgEvent 'Json) - descrEvent_ - | m `supportsVersion` groupHistoryIncludeWelcomeVersion = do - let GroupInfo {groupProfile = GroupProfile {description}} = gInfo - fmap (\descr -> XMsgNew $ MCSimple $ extMsgContent (MCText descr) Nothing) description - | otherwise = Nothing - itemForwardEvents :: CChatItem 'CTGroup -> CM [ChatMsgEvent 'Json] - itemForwardEvents cci = case cci of - (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) - | not (blockedByAdmin sender) -> do - fInvDescr_ <- join <$> forM file getRcvFileInvDescr - processContentItem sender ci mc fInvDescr_ - (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do - fInvDescr_ <- join <$> forM file getSndFileInvDescr - processContentItem membership ci mc fInvDescr_ - _ -> pure [] - where - getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText)) - getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do - expired <- fileExpired - if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired - then pure Nothing - else do - rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId - pure $ invCompleteDescr ciFile rfd - getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText)) - getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do - expired <- fileExpired - if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired - then pure Nothing - else do - -- can also lookup in extra_xftp_file_descriptions, though it can be empty; - -- would be best if snd file had a single rcv description for all members saved in files table - rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId - pure $ invCompleteDescr ciFile rfd - fileExpired :: CM Bool - fileExpired = do - ttl <- asks $ rcvFilesTTL . agentConfig . config - cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime - pure $ chatItemTs cci < cutoffTs - invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText) - invCompleteDescr CIFile {fileName, fileSize} RcvFileDescr {fileDescrText, fileDescrComplete} - | fileDescrComplete = - let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} - fInv = xftpFileInvitation fileName fileSize fInvDescr - 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_ = - if isNothing fInvDescr_ && not (msgContentHasText mc) - then pure [] - else do - let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta - quotedItemId_ = quoteItemId =<< quotedItem - 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 - let senderVRange = memberChatVRange' sender - xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} - fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of - (Just fileDescrText, Just msgId) -> do - partSize <- asks $ xftpDescrPartSize . config - let parts = splitFileDescr partSize fileDescrText - pure . L.toList $ L.map (XMsgFileDescr msgId) parts - _ -> pure [] - let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents - GroupMember {memberId} = sender - msgForwardEvents = map (\cm -> XGrpMsgForward memberId cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) - pure msgForwardEvents _ -> do let memCategory = memberCategory m withStore' (\db -> getViaGroupContact db vr user m) >>= \case @@ -974,6 +859,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 -> 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 @@ -1294,13 +1180,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () where profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> CM () - profileContactRequest invId chatVRange p@Profile {displayName, image} xContactId_ reqPQSup = do + profileContactRequest invId chatVRange p@Profile {displayName} xContactId_ reqPQSup = do withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {connReqContact, autoAccept}, groupId_, gLinkMemRole) = ucl + let (UserContactLink {connReqContact, autoAccept}, gLinkInfo_) = ucl isSimplexTeam = sameConnReqContact connReqContact adminContactReq v = maxVersion chatVRange case autoAccept of @@ -1313,49 +1199,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else do gInfo <- acceptBusinessJoinRequestAsync user cReq toView $ CRAcceptingBusinessRequest user gInfo - | otherwise -> case groupId_ of + | otherwise -> case gLinkInfo_ of Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequestAsync user cReq incognitoProfile reqPQSup toView $ CRAcceptingContactRequest user ct - Just groupId -> do + Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId - cfg <- asks config - case rejectionReason cfg of - Nothing + acceptMember_ <- asks $ acceptMember . chatHooks . config + maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case + Right (acceptance, useRole) | v < groupFastLinkJoinVersion -> messageError "processUserContactRequest: chat version range incompatible for accepting group join request" | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - useRole = userMemberRole gLinkMemRole $ acceptAsObserver cfg - mem <- acceptGroupJoinRequestAsync user gInfo cReq useRole profileMode + mem <- acceptGroupJoinRequestAsync user gInfo cReq acceptance useRole profileMode createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - Just rjctReason + Left rjctReason | v < groupJoinRejectVersion -> messageWarning $ "processUserContactRequest (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" | otherwise -> do mem <- acceptGroupJoinSendRejectAsync user gInfo cReq rjctReason toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason _ -> toView $ CRReceivedContactRequest user cReq - where - rejectionReason ChatConfig {profileNameLimit, allowedProfileName} - | T.length displayName > profileNameLimit = Just GRRLongName - | maybe False (\f -> not $ f displayName) allowedProfileName = Just GRRBlockedName - | otherwise = Nothing - userMemberRole linkRole = \case - Just AOAll -> GRObserver - Just AONameOnly | noImage -> GRObserver - Just AOIncognito | noImage && isRandomName displayName -> GRObserver - _ -> linkRole - where - noImage = maybe True (\(ImageData i) -> i == "") image + -- TODO [knocking] review memberCanSend :: GroupMember -> CM () -> CM () - memberCanSend GroupMember {memberRole} a - | memberRole <= GRObserver = messageError "member is not allowed to send messages" - | otherwise = a + memberCanSend GroupMember {memberRole, memberStatus} a + | memberRole > GRObserver || memberStatus == GSMemPendingApproval = a + | otherwise = messageError "member is not allowed to send messages" processConnMERR :: ConnectionEntity -> Connection -> AgentErrorType -> CM () processConnMERR connEntity conn err = do @@ -1576,7 +1450,7 @@ 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 <- saveRcvChatItem' user (CDDirectRcv ct) Nothing 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}] @@ -1643,7 +1517,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) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty + ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing 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 @@ -1760,10 +1634,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = live' = fromMaybe False live_ ExtMsgContent content mentions fInv_ itemTTL live_ = mcExtMsgContent mc ts@(_, ft_) = msgContentTexts content + saveRcvCI = saveRcvChatItem' user (CDGroupRcv gInfo m) (memberNotInHistory m) msg sharedMsgId_ brokerTs createBlockedByAdmin | groupFeatureAllowed SGFFullDelete gInfo = do -- ignores member role when blocked by admin - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty + ci <- saveRcvCI (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs groupMsgToView gInfo ci' | otherwise = do @@ -1775,7 +1650,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty + ci <- saveRcvCI (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt groupMsgToView gInfo ci' | otherwise = do @@ -1783,7 +1658,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci <- createNonLive file_ toView =<< markGroupCIsDeleted user gInfo [CChatItem SMDRcv ci] False (Just moderator) moderatedAt createNonLive file_ = - saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions + saveRcvCI (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions createContentItem = do file_ <- processFileInv newChatItem (CIRcvMsgContent content, ts) (snd <$> file_) timed' live' @@ -1792,7 +1667,7 @@ 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 <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live mentions' + 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} @@ -1808,7 +1683,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_ = rcvGroupCITimed gInfo ttl_ mentions' = if showMessages (memberSettings m) then mentions else [] - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) (memberNotInHistory 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 @@ -1841,6 +1716,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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_ @@ -1896,7 +1776,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) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] where brokerTs = metaBrokerTs msgMeta @@ -1910,7 +1790,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 (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) Nothing msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci groupMsgToView gInfo ci' @@ -2173,16 +2053,27 @@ 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} Connection {viaGroupLink} p' = do + xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory, memberStatus} Connection {viaGroupLink} p' = do xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do m' <- processMemberProfileUpdate gInfo m p' False Nothing withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True - let connectedIncognito = memberIncognito membership - probeMatchingMemberContact m' connectedIncognito + unless (memberStatus == GSMemPendingApproval) $ do + let connectedIncognito = memberIncognito membership + 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 + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_ | redactedMemberProfile (fromLocalProfile p) /= redactedMemberProfile p' = do @@ -2330,7 +2221,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) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty + ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] -- to party initiating call diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5562f016c9..ae88bc796b 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -162,6 +162,8 @@ 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 diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index 8380bb58f1..5b6c9222cc 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -2,8 +2,6 @@ module Simplex.Chat.ProfileGenerator where -import qualified Data.Attoparsec.Text as A -import Data.Either (isRight) import Data.Text (Text) import Simplex.Chat.Types (Profile (..)) import System.Random (randomRIO) @@ -25,15 +23,6 @@ generateRandomProfile = do then pickNoun adjective (n - 1) else pure noun --- This function does not check for exact match with this disctionary, --- it only checks for the WordWord style. -isRandomName :: Text -> Bool -isRandomName = isRight . A.parseOnly randomNameP - where - randomNameP = A.satisfy upper >> A.takeWhile1 lower >> A.satisfy upper >> A.takeWhile1 lower >> A.endOfInput - upper c = c >= 'A' && c <= 'Z' - lower c = c >= 'a' && c <= 'z' - adjectives :: [Text] adjectives = [ "Abatic", @@ -1503,7 +1492,6 @@ adjectives = "Recommendable", "Rectangular", "Recuperative", - "Red", "Refined", "Reflecting", "Reflective", @@ -2940,7 +2928,6 @@ nouns = "Sister", "Size", "Skill", - "Skin", "Skipper", "Sleek", "Slick", diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 94e08a0897..a145914ce7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -333,6 +333,7 @@ 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 XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -823,6 +824,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpLinkInv_ :: CMEventTag 'Json XGrpLinkReject_ :: CMEventTag 'Json XGrpLinkMem_ :: CMEventTag 'Json + XGrpLinkAcpt_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -875,6 +877,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpLinkInv_ -> "x.grp.link.inv" XGrpLinkReject_ -> "x.grp.link.reject" XGrpLinkMem_ -> "x.grp.link.mem" + XGrpLinkAcpt_ -> "x.grp.link.acpt" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -928,6 +931,7 @@ instance StrEncoding ACMEventTag where "x.grp.link.inv" -> XGrpLinkInv_ "x.grp.link.reject" -> XGrpLinkReject_ "x.grp.link.mem" -> XGrpLinkMem_ + "x.grp.link.acpt" -> XGrpLinkAcpt_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -977,6 +981,7 @@ toCMEventTag msg = case msg of XGrpLinkInv _ -> XGrpLinkInv_ XGrpLinkReject _ -> XGrpLinkReject_ XGrpLinkMem _ -> XGrpLinkMem_ + XGrpLinkAcpt _ -> XGrpLinkAcpt_ XGrpMemNew _ -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1079,6 +1084,7 @@ 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" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1142,6 +1148,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 -> o ["role" .= role] XGrpMemNew memInfo -> o ["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.hs b/src/Simplex/Chat/Store.hs index 03b4d7a640..a41641f88d 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -6,6 +6,7 @@ module Simplex.Chat.Store ChatLockEntity (..), UserMsgReceiptSettings (..), UserContactLink (..), + GroupLinkInfo (..), AutoAccept (..), createChatStore, migrations, -- used in tests diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a1ce3ab269..0094e20cb8 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -78,6 +78,7 @@ module Simplex.Chat.Store.Groups createMemberConnectionAsync, updateGroupMemberStatus, updateGroupMemberStatusById, + updateGroupMemberAccepted, createNewGroupMember, checkGroupMemberHasItems, deleteGroupMember, @@ -520,9 +521,10 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, business} = do +createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName - createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business GSMemAccepted + initialStatus = maybe GSMemAccepted acceptanceToStatus accepted + createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupRejectedViaLink db vr user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do @@ -1201,6 +1203,19 @@ 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 + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + 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} + -- | 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 diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 1902fd002e..00158e0054 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -142,7 +142,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 @@ -372,9 +372,9 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti (chatTs, userId, noteFolderId) _ -> pure () -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 +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 where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -388,9 +388,9 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -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 +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 quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -407,13 +407,13 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw 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 ciContent quoteRow Nothing Nothing False False itemTs Nothing + createNewChatItem_ db user chatDirection Nothing 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 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 +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 DB.execute db [sql| @@ -448,7 +448,7 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q includeInHistory :: Bool includeInHistory = let (_, groupId_, _, _) = idsRow - in isJust groupId_ && isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) + in isJust groupId_ && isNothing notInHistory_ && isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) forwardedFromRow = case itemForwarded of Nothing -> @@ -2319,9 +2319,9 @@ updateGroupCIMentions db g ci@ChatItem {mentions} mentions' unless (null mentions) $ deleteMentions if null mentions' then pure ci - -- This is a fallback for the error that should not happen in practice. + else -- This is a fallback for the error that should not happen in practice. -- In theory, it may happen in item mentions in database are different from item record. - else createMentions `E.catch` \e -> if constraintError e then deleteMentions >> createMentions else E.throwIO e + createMentions `E.catch` \e -> if constraintError e then deleteMentions >> createMentions else E.throwIO e where deleteMentions = DB.execute db "DELETE FROM chat_item_mentions WHERE chat_item_id = ?" (Only $ chatItemId' ci) createMentions = createGroupCIMentions db g ci mentions' @@ -3138,6 +3138,7 @@ 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/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index a8d1c094d4..22d2a7b1f5 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -18,6 +18,7 @@ module Simplex.Chat.Store.Profiles ( AutoAccept (..), UserMsgReceiptSettings (..), UserContactLink (..), + GroupLinkInfo (..), createUserRecord, createUserRecordAt, getUsersInfo, @@ -47,6 +48,7 @@ module Simplex.Chat.Store.Profiles deleteUserAddress, getUserAddress, getUserContactLinkById, + getGroupLinkInfo, getUserContactLinkByConnReq, getContactWithoutConnViaAddress, updateUserAddressAutoAccept, @@ -453,6 +455,12 @@ data UserContactLink = UserContactLink } deriving (Show) +data GroupLinkInfo = GroupLinkInfo + { groupId :: GroupId, + memberRole :: GroupMemberRole + } + deriving (Show) + data AutoAccept = AutoAccept { businessAddress :: Bool, -- possibly, it can be wrapped together with acceptIncognito, or AutoAccept made sum type acceptIncognito :: IncognitoEnabled, @@ -481,18 +489,28 @@ getUserAddress db User {userId} = |] (Only userId) -getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupId, GroupMemberRole) +getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupLinkInfo) getUserContactLinkById db userId userContactLinkId = - ExceptT . firstRow (\(ucl :. (groupId_, mRole_)) -> (toUserContactLink ucl, groupId_, fromMaybe GRMember mRole_)) SEUserContactLinkNotFound $ - DB.query - db - [sql| - 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 = ? - |] - (userId, userContactLinkId) + ExceptT . firstRow (\(ucl :. gli) -> (toUserContactLink ucl, toGroupLinkInfo gli)) SEUserContactLinkNotFound $ + DB.query db (groupLinkInfoQuery <> " AND user_contact_link_id = ?") (userId, userContactLinkId) + +groupLinkInfoQuery :: Query +groupLinkInfoQuery = + [sql| + 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 = ? + |] + +toGroupLinkInfo :: (Maybe GroupId, Maybe GroupMemberRole) -> Maybe GroupLinkInfo +toGroupLinkInfo (groupId_, mRole_) = + (\groupId -> GroupLinkInfo {groupId, memberRole = fromMaybe GRMember mRole_}) + <$> groupId_ + +getGroupLinkInfo :: DB.Connection -> UserId -> GroupId -> IO (Maybe GroupLinkInfo) +getGroupLinkInfo db userId groupId = + fmap join $ maybeFirstRow toGroupLinkInfo $ + DB.query db (groupLinkInfoQuery <> " AND group_id = ?") (userId, groupId) getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = 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 72f8e4b8fd..01f4cf3f68 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4870,7 +4870,7 @@ 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 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 ? Plan: MULTI-INDEX OR INDEX 1 @@ -4879,7 +4879,7 @@ INDEX 2 SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts=? AND rowid ? ) 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 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 ? Plan: MULTI-INDEX OR INDEX 1 diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 94b67d8349..5ba7565611 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -668,6 +668,7 @@ data GroupLinkInvitation = GroupLinkInvitation fromMemberName :: ContactName, invitedMember :: MemberIdRole, groupProfile :: GroupProfile, + accepted :: Maybe GroupAcceptance, business :: Maybe BusinessChatInfo, groupSize :: Maybe Int } @@ -997,6 +998,7 @@ data GroupMemberStatus | GSMemGroupDeleted -- user member of the deleted group | 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") | 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) @@ -1017,6 +1019,11 @@ instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode +acceptanceToStatus :: GroupAcceptance -> GroupMemberStatus +acceptanceToStatus = \case + GAAccepted -> GSMemAccepted + GAPending -> GSMemPendingApproval + memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of GSMemRejected -> False @@ -1025,6 +1032,7 @@ memberActive m = case memberStatus m of GSMemGroupDeleted -> False GSMemUnknown -> False GSMemInvited -> False + GSMemPendingApproval -> True GSMemIntroduced -> False GSMemIntroInvited -> False GSMemAccepted -> False @@ -1045,6 +1053,7 @@ memberCurrent' = \case GSMemGroupDeleted -> False GSMemUnknown -> False GSMemInvited -> False + GSMemPendingApproval -> False GSMemIntroduced -> True GSMemIntroInvited -> True GSMemAccepted -> True @@ -1061,6 +1070,7 @@ memberRemoved m = case memberStatus m of GSMemGroupDeleted -> True GSMemUnknown -> False GSMemInvited -> False + GSMemPendingApproval -> False GSMemIntroduced -> False GSMemIntroInvited -> False GSMemAccepted -> False @@ -1077,6 +1087,7 @@ instance TextEncoding GroupMemberStatus where "deleted" -> Just GSMemGroupDeleted "unknown" -> Just GSMemUnknown "invited" -> Just GSMemInvited + "pending_approval" -> Just GSMemPendingApproval "introduced" -> Just GSMemIntroduced "intro-inv" -> Just GSMemIntroInvited "accepted" -> Just GSMemAccepted @@ -1092,6 +1103,7 @@ instance TextEncoding GroupMemberStatus where GSMemGroupDeleted -> "deleted" GSMemUnknown -> "unknown" GSMemInvited -> "invited" + GSMemPendingApproval -> "pending_approval" GSMemIntroduced -> "introduced" GSMemIntroInvited -> "intro-inv" GSMemAccepted -> "accepted" diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index d5c8f48776..e22610cfe5 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -48,3 +48,27 @@ instance FromJSON GroupMemberRole where instance ToJSON GroupMemberRole where toJSON = strToJSON toEncoding = strToJEncoding + +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" + GAPending -> "pending" + strDecode = \case + "accepted" -> Right GAAccepted + "pending" -> Right GAPending + r -> Left $ "bad GroupAcceptance " <> B.unpack r + strP = strDecode <$?> A.takeByteString + +instance FromJSON GroupAcceptance where + parseJSON = strParseJSON "GroupAcceptance" + +instance ToJSON GroupAcceptance where + toJSON = strToJSON + toEncoding = strToJEncoding diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7a20cb2fb0..1c4f4258d8 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1076,14 +1076,22 @@ viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortO viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) viewUserJoinedGroup :: GroupInfo -> [StyledString] -viewUserJoinedGroup g = +viewUserJoinedGroup g@GroupInfo {membership} = case incognitoMembershipProfile g of - Just mp -> [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile mp)] - Nothing -> [ttyGroup' g <> ": you joined the group"] + Just mp -> [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile mp) <> pendingApproval_] + Nothing -> [ttyGroup' g <> ": you joined the group" <> pendingApproval_] + where + pendingApproval_ = case memberStatus membership of + GSMemPendingApproval -> ", pending approval" + _ -> "" viewJoinedGroupMember :: GroupInfo -> GroupMember -> [StyledString] -viewJoinedGroupMember g m = - [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "] +viewJoinedGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, memberStatus} = case memberStatus of + GSMemPendingApproval -> + [ (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 "] viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] viewReceivedGroupInvitation g c role = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 2a9ad30dd2..31b36159fd 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -86,12 +86,13 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = adminUsers = [], superUsers, ownersGroup, - directoryLog = Just $ ps "directory_service.log", + blockedFragmentsFile = Nothing, blockedWordsFile = Nothing, blockedExtensionRules = Nothing, nameSpellingFile = Nothing, profileNameLimit = maxBound, - acceptAsObserver = Nothing, + captchaGenerator = Nothing, + directoryLog = Just $ ps "directory_service.log", serviceName = "SimpleX-Directory", runCLI = False, searchResults = 3, @@ -182,6 +183,8 @@ testDirectoryService ps = superUser <## " Group approved!" bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + bob <## "" + bob <## "Use /filter 1 to configure anti-spam filter and /role 1 to set default member role." search bob "privacy" welcomeWithLink' search bob "security" welcomeWithLink' cath `connectVia` dsLink @@ -1045,6 +1048,8 @@ reapproveGroup count superUser bob = do superUser <## " Group approved!" bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + bob <## "" + bob <## "Use /filter 1 to configure anti-spam filter and /role 1 to set default member role." addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do @@ -1114,7 +1119,9 @@ runDirectory cfg opts@DirectoryOpts {directoryLog} action = do threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) where - bot st = simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts + bot st = do + env <- newServiceState opts + simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts env registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 @@ -1187,6 +1194,8 @@ approveRegistrationId su u n gId ugId = do su <## " Group approved!" u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory!") u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + u <## "" + u <## ("Use /filter " <> show ugId <> " to configure anti-spam filter and /role " <> show ugId <> " to set default member role.") connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 14539ac219..7ec4033046 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -20,14 +20,14 @@ import qualified Data.ByteString.Char8 as B import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types -import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -98,7 +98,11 @@ chatGroupTests = do it "group link member role" testGroupLinkMemberRole it "host profile received" testGroupLinkHostProfileReceived it "existing contact merged" testGroupLinkExistingContactMerged - it "reject member joining via group link - blocked name" testGroupLinkRejectBlockedName + 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 link connection plan" $ do it "ok to connect; known group" testPlanGroupLinkKnown it "own group link" testPlanGroupLinkOwn @@ -185,6 +189,8 @@ 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 testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -2867,8 +2873,8 @@ testGroupLinkExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" -testGroupLinkRejectBlockedName :: HasCallStack => TestParams -> IO () -testGroupLinkRejectBlockedName = +testGLinkRejectBlockedName :: HasCallStack => TestParams -> IO () +testGLinkRejectBlockedName = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" @@ -2894,7 +2900,92 @@ testGroupLinkRejectBlockedName = bob <## "group link: known group #team" bob <## "use #team to send messages" where - cfg = testCfg {allowedProfileName = Just (const False)} + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Left GRRBlockedName)}} + +testGLinkManualAcceptMember :: HasCallStack => TestParams -> IO () +testGLinkManualAcceptMember = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath connected and pending approval, use /_accept member #1 3 to accept member", + do + cath <## "#team: joining the group..." + cath <## "#team: you joined the group, pending approval" + ] + + -- pending approval 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" + + cath ##> "/_send #1 @1 text proofs" + cath <# "#team proofs" + alice <# "#team cath> proofs" + + -- accept member + alice ##> "/_accept member #1 3 member" + concurrentlyN_ + [ alice <## "#team: cath joined the group", + cath + <### [ "#team: you joined the group", + WithTime "#team alice> hi group [>>]", + WithTime "#team bob> hey [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + + alice #> "#team welcome cath" + [bob, cath] *<# "#team alice> welcome cath" + + bob #> "#team hi cath" + [alice, cath] *<# "#team bob> hi cath" + + cath #> "#team hi group" + [alice, bob] *<# "#team cath> hi group" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPending, GRObserver))}} + +testGLinkDeletePendingMember :: HasCallStack => TestParams -> IO () +testGLinkDeletePendingMember = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath connected and pending approval, use /_accept member #1 3 to accept member", + do + cath <## "#team: joining the group..." + cath <## "#team: you joined the group, pending approval" + ] + + alice ##> "/rm team cath" + alice <## "#team: you removed cath from the group" + 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))}} testPlanGroupLinkKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkKnown = @@ -6457,3 +6548,37 @@ testUpdatedMentionNames = do mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = ciMentionMember <$> name_} where ciMentionMember name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} + +testGroupDirectMessages :: HasCallStack => TestParams -> IO () +testGroupDirectMessages = + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + createGroup3 "team" alice bob cath + + alice #> "#team 1" + [bob, cath] *<# "#team alice> 1" + + 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 @2 text 3" + alice <# "#team 3" + bob <# "#team alice> 3" + + void $ withCCTransaction bob $ \db -> + DB.execute_ db "UPDATE group_members SET member_status='pending_approval' WHERE group_member_id = 1" + + bob ##> "/_send #1 @1 text 4" + bob <# "#team 4" + alice <# "#team bob> 4" + + -- 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" + + bob #> "#team 6" + cath <# "#team bob> 6" diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 6c429e2a56..807fa4a0cb 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -125,7 +125,9 @@ skipComparisonForDownMigrations = -- indexes move down to the end of the file "20241125_indexes", -- indexes move down to the end of the file - "20250130_indexes" + "20250130_indexes", + -- index moves down to the end of the file + "20250227_member_acceptance" ] getSchema :: FilePath -> FilePath -> IO String From 8b030075d77c5d27d40eb2774d116895b7e887fc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Mar 2025 19:58:00 +0000 Subject: [PATCH 100/289] core: update query plans --- .../SQLite/Migrations/chat_query_plans.txt | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) 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 01f4cf3f68..88836035e5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -2950,15 +2950,6 @@ 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, @@ -4294,6 +4285,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_status = ?, member_role = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_status = ?, updated_at = ? @@ -4611,6 +4610,14 @@ 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 @@ -5781,6 +5788,14 @@ 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 +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) From 1ddf7a62adbbccf05d0df865dca33db542543f2b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 4 Mar 2025 03:53:08 +0700 Subject: [PATCH 101/289] ios: fix running in simulator (#5704) --- apps/ios/SimpleXChat/dummy.m | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/ios/SimpleXChat/dummy.m b/apps/ios/SimpleXChat/dummy.m index 64fbc32dd3..d26e108520 100644 --- a/apps/ios/SimpleXChat/dummy.m +++ b/apps/ios/SimpleXChat/dummy.m @@ -21,4 +21,13 @@ DIR *opendir$INODE64(const char *name) { return opendir(name); } +int readdir$INODE64(DIR *restrict dirp, struct dirent *restrict entry, + struct dirent **restrict result) { + return readdir_r(dirp, entry, result); +} + +DIR *fdopendir$INODE64(const char *name) { + return opendir(name); +} + #endif From 2788a1dbb36e066a76babf1b9a0ae63d1fa2d336 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 4 Mar 2025 03:53:23 +0700 Subject: [PATCH 102/289] ios: fix showing first unread (#5703) --- apps/ios/Shared/Views/Chat/EndlessScrollView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 3d4fb381a6..f04987e5f8 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -476,7 +476,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { return } - if bounds.height == 0 { + if bounds.height == 0 || contentSize.height == 0 { scrollToItemIndexDelayed = index return } From 0a97218440c1d8fc6174c0f8a27ee801e11eefae Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Mar 2025 20:57:25 +0000 Subject: [PATCH 103/289] core: 6.3.0.7 (simplexmq 6.3.0.7) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index aa96704e62..477ba9c601 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: f9d7b1eebc7e825423ee0d3b995a69c4006ac99c + tag: 9fece9ce3df24d1b006d98e44a6b4e654861428b source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 20b0b781d7..1a9de205f3 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f9d7b1eebc7e825423ee0d3b995a69c4006ac99c" = "1yi4igwrgj5n0ljh06fhzzf2yprrwx9nrzp56s305z9g7mgx7gqm"; + "https://github.com/simplex-chat/simplexmq.git"."9fece9ce3df24d1b006d98e44a6b4e654861428b" = "0vf4kkj2jc4bg3bc9vsl0gv2z0l7z48626sqlk5wh7il6k14pn17"; "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 f987162b78..950b689234 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.0.6 +version: 6.3.0.7 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 5a6ef3b847..3e64b2bcea 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, 0, 4] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 7] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 4] +minRemoteHostVersion = AppVersion [6, 3, 0, 7] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From 7c2153762fb71564fa3243081cce65c7d4560edf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Mar 2025 21:21:25 +0000 Subject: [PATCH 104/289] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8ac80ccbdc..2091b4b862 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.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.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 */; }; @@ -531,8 +531,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.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.6-JqjWEhRZNfuBevXEobl047.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a */, ); path = Libraries; sourceTree = ""; From d2e60503f9e7dc719a69492169751bc57aa29b42 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 3 Mar 2025 22:09:56 +0000 Subject: [PATCH 105/289] ui: translations (#5705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cli: remove multiple members (#5656) * cli: remove multiple members * accept all members joining via link as observers (do NOT release) * blocked words * blocked words * XGrpLinkReject * core: 6.3.0.6 (simplexmq 6.3.0.6) --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * core: communicate group join rejection (#5661) * ui: rejected group previews (#5665) * Translated using Weblate (German) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2061 of 2061 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% (2328 of 2328 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% (2061 of 2061 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% (2328 of 2328 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% (2061 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Korean) Currently translated at 64.4% (1500 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ko/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2328 of 2328 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% (2328 of 2328 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% (2061 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Vietnamese) Currently translated at 99.7% (2323 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2328 of 2328 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% (2328 of 2328 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% (2061 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Arabic) Currently translated at 15.3% (316 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2328 of 2328 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% (2061 of 2061 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% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Czech) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2328 of 2328 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% (2061 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Croatian) Currently translated at 61.2% (1425 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2328 of 2328 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% (2061 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2061 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Croatian) Currently translated at 63.8% (1487 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Arabic) Currently translated at 17.6% (363 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (Russian) Currently translated at 97.5% (2271 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2328 of 2328 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2061 of 2061 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * import/export localizations --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: mlanp Co-authored-by: summoner001 Co-authored-by: Random Co-authored-by: robbie.med <161779148+robbie-med@users.noreply.github.com> Co-authored-by: 大王叫我来巡山 Co-authored-by: jonnysemon Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: No name Co-authored-by: Bezruchenko Simon Co-authored-by: Gholamy-Muh Co-authored-by: zenobit Co-authored-by: Nenad Co-authored-by: Full name Co-authored-by: fran secs Co-authored-by: M1K4 --- .../ar.xcloc/Localized Contents/ar.xliff | 1021 +++++++++++++++-- .../bg.xcloc/Localized Contents/bg.xliff | 16 + .../cs.xcloc/Localized Contents/cs.xliff | 16 + .../de.xcloc/Localized Contents/de.xliff | 34 +- .../en.xcloc/Localized Contents/en.xliff | 20 + .../es.xcloc/Localized Contents/es.xliff | 30 + .../fi.xcloc/Localized Contents/fi.xliff | 16 + .../fr.xcloc/Localized Contents/fr.xliff | 16 + .../hu.xcloc/Localized Contents/hu.xliff | 338 +++--- .../it.xcloc/Localized Contents/it.xliff | 30 + .../ja.xcloc/Localized Contents/ja.xliff | 16 + .../nl.xcloc/Localized Contents/nl.xliff | 30 + .../pl.xcloc/Localized Contents/pl.xliff | 16 + .../ru.xcloc/Localized Contents/ru.xliff | 16 + .../th.xcloc/Localized Contents/th.xliff | 16 + .../tr.xcloc/Localized Contents/tr.xliff | 16 + .../uk.xcloc/Localized Contents/uk.xliff | 16 + .../Localized Contents/zh-Hans.xliff | 16 + .../SimpleX SE/hu.lproj/Localizable.strings | 2 +- apps/ios/de.lproj/Localizable.strings | 46 +- apps/ios/es.lproj/Localizable.strings | 42 + apps/ios/hu.lproj/Localizable.strings | 342 +++--- .../hu.lproj/SimpleX--iOS--InfoPlist.strings | 2 +- apps/ios/it.lproj/Localizable.strings | 42 + apps/ios/nl.lproj/Localizable.strings | 42 + .../commonMain/resources/MR/ar/strings.xml | 18 +- .../commonMain/resources/MR/ca/strings.xml | 2 + .../commonMain/resources/MR/cs/strings.xml | 265 ++++- .../commonMain/resources/MR/de/strings.xml | 22 +- .../commonMain/resources/MR/es/strings.xml | 10 +- .../commonMain/resources/MR/hr/strings.xml | 112 +- .../commonMain/resources/MR/hu/strings.xml | 338 +++--- .../commonMain/resources/MR/it/strings.xml | 4 +- .../commonMain/resources/MR/ko/strings.xml | 22 + .../commonMain/resources/MR/nl/strings.xml | 4 +- .../commonMain/resources/MR/ru/strings.xml | 27 +- .../commonMain/resources/MR/uk/strings.xml | 14 + .../commonMain/resources/MR/vi/strings.xml | 14 + .../resources/MR/zh-rCN/strings.xml | 2 + 39 files changed, 2400 insertions(+), 651 deletions(-) diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index ef91bb30fd..ca61f88520 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -49,7 +49,7 @@ %@ - %@ + %@ No comment provided by engineer. @@ -379,240 +379,289 @@ أضف إلى جهاز آخر No comment provided by engineer. - + Admins can create the links to join groups. + يمكن للمُدراء إنشاء روابط للانضمام إلى المجموعات. No comment provided by engineer. - + Advanced network settings + إعدادات الشبكة المتقدمة No comment provided by engineer. - + All chats and messages will be deleted - this cannot be undone! + سيتم حذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا! No comment provided by engineer. - + All group members will remain connected. + سيبقى جميع أعضاء المجموعة على اتصال. No comment provided by engineer. - + All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. + سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك. No comment provided by engineer. All your contacts will remain connected No comment provided by engineer. - + Allow + سماح No comment provided by engineer. - + Allow disappearing messages only if your contact allows it to you. + السماح بالرسائل المختفية فقط إذا سمحت لك جهة الاتصال بذلك. No comment provided by engineer. Allow irreversible message deletion only if your contact allows it to you. No comment provided by engineer. - + Allow sending direct messages to members. + السماح بإرسال رسائل مباشرة إلى الأعضاء. No comment provided by engineer. - + Allow sending disappearing messages. + السماح بإرسال الرسائل التي تختفي. No comment provided by engineer. Allow to irreversibly delete sent messages. No comment provided by engineer. - + Allow to send voice messages. + السماح بإرسال رسائل صوتية. No comment provided by engineer. - + Allow voice messages only if your contact allows them. + اسمح بالرسائل الصوتية فقط إذا سمحت جهة اتصالك بذلك. No comment provided by engineer. - + Allow voice messages? + السماح بالرسائل الصوتية؟ No comment provided by engineer. Allow your contacts to irreversibly delete sent messages. No comment provided by engineer. - + Allow your contacts to send disappearing messages. + السماح لجهات اتصالك بإرسال رسائل تختفي. No comment provided by engineer. - + Allow your contacts to send voice messages. + اسمح لجهات اتصالك بإرسال رسائل صوتية. No comment provided by engineer. - + Already connected? + متصل بالفعل؟ No comment provided by engineer. - + Answer call + أجب الاتصال No comment provided by engineer. - + App build: %@ + إصدار التطبيق: %@ No comment provided by engineer. - + App icon + رمز التطبيق No comment provided by engineer. - + App version + نسخة التطبيق No comment provided by engineer. - + App version: v%@ + نسخة التطبيق: v%@ No comment provided by engineer. - + Appearance + المظهر No comment provided by engineer. - + Attach + إرفاق No comment provided by engineer. - + Audio & video calls + مكالمات الصوت والفيديو No comment provided by engineer. - + Authentication failed + فشلت المصادقة No comment provided by engineer. - + Authentication unavailable + المصادقة غير متاحة No comment provided by engineer. - + Auto-accept contact requests + قبول طلبات الاتصال تلقائيًا No comment provided by engineer. - + Auto-accept images + قبول تلقائي للصور No comment provided by engineer. Automatically No comment provided by engineer. - + Back + رجوع No comment provided by engineer. Both you and your contact can irreversibly delete sent messages. No comment provided by engineer. - + Both you and your contact can send disappearing messages. + يمكنك أنت وجهة اتصالك إرسال رسائل تختفي. No comment provided by engineer. - + Both you and your contact can send voice messages. + يمكنك أنت وجهة اتصالك إرسال رسائل صوتية. No comment provided by engineer. By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. - + Call already ended! + انتهت المكالمة بالفعل! No comment provided by engineer. - + Calls + المكالمات No comment provided by engineer. - + Can't invite contact! + لا يمكن دعوة جهة اتصال! No comment provided by engineer. - + Can't invite contacts! + لا يمكن دعوة جهات الاتصال! No comment provided by engineer. - + Cancel + إلغاء No comment provided by engineer. Cannot access keychain to save database password No comment provided by engineer. - + Cannot receive file + لا يمكن استلام الملف No comment provided by engineer. - + Change + تغير No comment provided by engineer. - + Change database passphrase? + تغيير عبارة مرور قاعدة البيانات؟ No comment provided by engineer. Change member role? No comment provided by engineer. - + Change receiving address + تغيير عنوان الاستلام No comment provided by engineer. - + Change receiving address? + تغيير عنوان الاستلام؟ No comment provided by engineer. - + Change role + تغيير الدور No comment provided by engineer. Chat archive No comment provided by engineer. - + Chat console + وحدة تحكم الدردشة No comment provided by engineer. - + Chat database + قاعدة بيانات الدردشة No comment provided by engineer. - + Chat database deleted + حُذفت قاعدة بيانات الدردشة No comment provided by engineer. - + Chat database imported + استُوردت قاعدة بيانات الدردشة No comment provided by engineer. - + Chat is running + الدردشة قيد التشغيل No comment provided by engineer. - + Chat is stopped + توقفت الدردشة No comment provided by engineer. - + Chat preferences + تفضيلات الدردشة No comment provided by engineer. - + Chats + الدردشات No comment provided by engineer. @@ -1973,8 +2022,9 @@ We will be adding server redundancy to prevent lost messages. Open user profiles authentication reason - + Anybody can host servers. + يمكن لأي شخص استضافة الخوادم. No comment provided by engineer. @@ -2377,96 +2427,117 @@ We will be adding server redundancy to prevent lost messages. Sent messages will be deleted after set time. No comment provided by engineer. - + Server requires authorization to create queues, check password + يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور server test error - + Server test failed! + فشلت تجربة الخادم! No comment provided by engineer. - + Servers + الخوادم No comment provided by engineer. - + Set 1 day + تعيين يوم واحد No comment provided by engineer. - + Set contact name… + تعيين اسم جهة الاتصال… No comment provided by engineer. - + Set group preferences + عيّن تفضيلات المجموعة No comment provided by engineer. - + Set passphrase to export + عيّن عبارة المرور للتصدير No comment provided by engineer. - + Set timeouts for proxy/VPN + حدد مهلات للوسيط او شبكات افتراضية خاصة (Proxy/VPN timeouts) No comment provided by engineer. - + Settings + الإعدادات No comment provided by engineer. - + Share + مشاركة chat item action Share invitation link No comment provided by engineer. - + Share link + مشاركة الرابط No comment provided by engineer. Share one-time invitation link No comment provided by engineer. - + Show QR code + عرض رمز QR No comment provided by engineer. - + Show preview + عرض المعاينة No comment provided by engineer. - + SimpleX Chat security was audited by Trail of Bits. + تم تدقيق أمان SimpleX Chat بواسطة Trail of Bits. No comment provided by engineer. - + SimpleX Lock + قفل SimpleX No comment provided by engineer. - + SimpleX Lock turned on + تم تشغيل القفل SimpleX No comment provided by engineer. - + SimpleX contact address + عنوان جهة أتصال SimpleX simplex link type - + SimpleX encrypted message or connection event + حَدَثْ SimpleX لرسالة أو اتصال مشفر notification - + SimpleX group link + رابط مجموعة SimpleX simplex link type - + SimpleX links + روابط SimpleX No comment provided by engineer. - + SimpleX one-time invitation + دعوة SimpleX لمرة واحدة simplex link type @@ -3657,7 +3728,7 @@ SimpleX servers cannot see your profile. # %@ - # %@ + # %@ copied message info title, # <title> @@ -3877,6 +3948,802 @@ SimpleX servers cannot see your profile. Active connections اتصالات نشطة + + Apply + طبّق + + + %@ server + %@ خادم + + + Accept conditions + اقبل الشروط + + + Share address + مشاركة العنوان + + + Already connecting! + جارٍ الاتصال بالفعل! + + + %d file(s) are still being downloaded. + %d الملف(ات) لا تزال قيد التنزيل. + + + %d file(s) failed to download. + %d الملف(ات) فشلت في التنزيل. + + + All app data is deleted. + حُذفت جميع بيانات التطبيق. + + + Allow irreversible message deletion only if your contact allows it to you. (24 hours) + السماح بحذف الرسائل بشكل لا رجوع فيه فقط إذا سمحت لك جهة الاتصال بذلك. (24 ساعة) + + + Share profile + شارك ملف التعريف + + + Always use relay + استخدم الموجه دائمًا + + + Address + عنوان + + + All data is erased when it is entered. + يتم مسح جميع البيانات عند إدخالها. + + + %d file(s) were deleted. + %d تم حذف الملف(ات). + + + %d file(s) were not downloaded. + %d لم يتم تنزيل الملف(ات). + + + %d messages not forwarded + %d الرسائل لم يتم تحويلها + + + %d seconds(s) + %d ثواني + + + **Scan / Paste link**: to connect via a link you received. + **امسح / ألصِق الرابط**: للاتصال عبر الرابط الذي تلقيته. + + + 1 year + سنة واحدة + + + 1-time link + رابط لمرة واحدة + + + 1-time link can be used *with one contact only* - share in person or via any messenger. + يمكن استعمال الرابط لمرة واحدة *مع جهة اتصال واحدة فقط* - شاركه شخصياً أو عبر أي تطبيق مراسلة. + + + Accent + لون تمييزي + + + Accepted conditions + الشروط المتفق عليها + + + All chats will be removed from the list (text), and the list deleted. + سيتم إزالة جميع الدردشات من القائمة (النص)، وحذف القائمة. + + + Allow message reactions. + السماح بردود الفعل على الرسائل. + + + Allow to irreversibly delete sent messages. (24 hours) + السماح بحذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) + + + Allow to send SimpleX links. + السماح بإرسال روابط SimpleX. + + + Already joining the group! + جارٍ انضمام بالفعل إلى المجموعة! + + + An empty chat profile with the provided name is created, and the app opens as usual. + يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. + + + Authentication cancelled + ألغيت المصادقة + + + Audio/video calls are prohibited. + مكالمات الصوت/الفيديو محظورة. + + + Better groups + مجموعات أفضل + + + Background + الخلفية + + + Better calls + مكالمات أفضل + + + Both you and your contact can irreversibly delete sent messages. (24 hours) + يمكنك أنت وجهة اتصالك حذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) + + + Block member for all? + حظر العضو للجميع؟ + + + Blur media + تمويه الوسائط + + + Server type + نوع الخادم + + + Server requires authorization to upload, check password + يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور + + + Server version is incompatible with network settings. + إصدار الخادم غير متوافق مع إعدادات الشبكة. + + + Share with contacts + مشاركة مع جهات الاتصال + + + Show: + عرض: + + + SimpleX Address + عنوان SimpleX + + + SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + توصلت SimpleX Chat وFlux إلى اتفاق لتضمين الخوادم التي تديرها Flux في التطبيق. + + + Allow calls? + السماح بالمكالمات؟ + + + App passcode is replaced with self-destruct passcode. + يتم استبدال رمز مرور التطبيق برمز مرور التدمير الذاتي. + + + SimpleX Lock mode + SimpleX وضع القفل + + + Audio and video calls + مكالمات الصوت والفيديو + + + App passcode + رمز مرور التطبيق + + + Bad message ID + معرّف رسالة سيئ + + + Server address is incompatible with network settings. + عنوان الخادم غير متوافق مع إعدادات الشبكة. + + + Servers statistics will be reset - this cannot be undone! + سيتم تصفير إحصائيات الخوادم - لا يمكن التراجع عن هذا! + + + Allow to send files and media. + السماح بإرسال الملفات والوسائط. + + + App encrypts new local files (except videos). + يُعمِّي الملفات المحلية الجديدة (باستثناء مقاطع الفيديو). + + + Better messages + رسائل أفضل + + + Set passcode + عيّن رمز المرور + + + Additional accent 2 + لون إضافي ثانوي 2 + + + Allow your contacts adding message reactions. + السماح لجهات اتصالك بإضافة ردود الفعل للرسالة. + + + Allow your contacts to call you. + السماح لجهات اتصالك بالاتصال بك. + + + Audio/video calls + مكالمات الصوت/الفيديو + + + Better notifications + إشعارات أفضل + + + Better user experience + تجربة مستخدم أفضل + + + Block + حظر + + + Black + أسود + + + Block member? + حظر العضو؟ + + + Blocked by admin + محظور من قبل المُدير + + + Blur for better privacy. + تمويه من أجل خصوصية أفضل. + + + Show → on messages sent via private routing. + عرض ← على الرسائل المرسلة عبر التوجيه الخاص. + + + Share from other apps. + المشاركة من التطبيقات الأخرى. + + + Share this 1-time invite link + شارك رابط الدعوة هذا لمرة واحدة + + + Set passphrase + عيّن عبارة المرور + + + Share address with contacts? + مشاركة العنوان مع جهات الاتصال؟ + + + Allow downgrade + السماح بالرجوع إلى إصدار سابق + + + Bad desktop address + عنوان سطح المكتب غير صالح + + + %1$@, %2$@ + %1$@, %2$@ + + + All profiles + جميع ملفات التعريف + + + Authentication is required before the call is connected, but you may miss calls. + يتطلب التوثيق قبل الاتصال بالمكالمة، ولكن قد تفوتك المكالمات. + + + Archiving database + جارِ أرشفة قاعدة البيانات + + + Settings were changed. + تم تغيير الإعدادات. + + + Better groups performance + أداء مجموعات أفضل + + + Better privacy and security + خصوصية وأمان أفضل + + + Better security ✅ + أمان أفضل ✅ + + + Block for all + حظر للجميع + + + Block group members + حظر أعضاء المجموعة + + + Block member + حظر العضو + + + Both you and your contact can add message reactions. + يمكنك أنت وجهة اتصالك إضافة ردود فعل الرسائل. + + + Both you and your contact can make calls. + يمكنك أنت وجهة الاتصال إجراء مكالمات. + + + Server + الخادم + + + Server operators + مُشغلي الخادم + + + Server version is incompatible with your app: %@. + إصدار الخادم غير متوافق مع التطبيق لديك: %@. + + + Servers info + معلومات الخوادم + + + Set chat name… + عيّن اسم الدردشة… + + + Shape profile images + شكّل الصور التعريفية + + + Share address publicly + شارك العنوان علناً + + + Show developer options + عرض خيارات المطور + + + SimpleX address + عنوان SimpleX + + + SimpleX address or 1-time link? + عنوان SimpleX أو رابط لمرة واحدة؟ + + + @'%@' + @'%@' + + + @%@ + @%@ + + + Active + نشط + + + Add friends + أضف أصدقاء + + + Add list + أضف القائمة + + + Address change will be aborted. Old receiving address will be used. + سيتم إحباط تغيير العنوان. سيتم استخدام عنوان الاستلام القديم. + + + All messages will be deleted - this cannot be undone! + سيتم حذف كافة الرسائل - لا يمكن التراجع عن هذا! + + + All reports will be archived for you. + سيتم أرشفة كافة البلاغات لك. + + + All your contacts will remain connected. + ستبقى جميع جهات اتصالك متصلة. + + + All your contacts will remain connected. Profile update will be sent to your contacts. + ستبقى جميع جهات اتصالك متصلة. سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. + + + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + جميع جهات الاتصال، المحادثات والملفات الخاصة بك سيتم تشفيرها بأمان ورفعها على شكل أجزاء إلى موجهات XFTP المُعدة. + + + Allow calls only if your contact allows them. + السماح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك. + + + Allow message reactions only if your contact allows them. + اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك. + + + Allow to report messsages to moderators. + السماح بالإبلاغ عن الرسائل إلى المشرفين. + + + Allow your contacts to irreversibly delete sent messages. (24 hours) + اسمح لجهات اتصالك بحذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) + + + Another reason + سبب آخر + + + App group: + مجموعة التطبيق: + + + Apply to + طبّق لِ + + + Archive + أرشف + + + Archive %lld reports? + أرشف تقارير %lld؟ + + + Archive all reports? + أرشفة كافة البلاغات؟ + + + Archive and upload + أرشفة و رفع + + + Archive report + أرشف البلاغ + + + Archive report? + أرشف البلاغ؟ + + + Archive reports + أرشف البلاغات + + + Ask + اسأل + + + Auto-accept settings + إعدادات القبول التلقائي + + + Better message dates. + تواريخ أفضل للرسائل. + + + Server added to operator %@. + تمت إضافة الخادم إلى المشغل %@. + + + Server address is incompatible with network settings: %@. + عنوان الخادم غير متوافق مع إعدادات الشبكة: %@. + + + Server protocol changed. + تغيّر بروتوكول الخادم. + + + SimpleX links are prohibited. + روابط SimpleX محظورة. + + + Additional accent + لون إضافي ثانوي + + + Always use private routing. + استخدم دائمًا التوجيه الخاص. + + + About operators + عن المُشغلين + + + Add team members + أضف أعضاء الفريق + + + Added media & file servers + أُضيفت خوادم الوسائط والملفات + + + Added message servers + أُضيفت خوادم الرسائل + + + Address or 1-time link? + عنوان أو رابط لمرة واحدة؟ + + + Address settings + إعدادات العنوان + + + Allow sharing + السماح بالمشاركة + + + App data migration + ترحيل بيانات التطبيق + + + Archive contacts to chat later. + أرشفة جهات الاتصال للدردشة لاحقًا. + + + Better networking + اتصال أفضل + + + Session code + رمز الجلسة + + + Set default theme + تعيين السمة الافتراضية + + + Set it instead of system authentication. + عيّنها بدلاً من استيثاق النظام. + + + Set the message shown to new members! + تعيين رسالة تظهر للأعضاء الجدد! + + + Share 1-time link + مشاركة رابط ذو استخدام واحد + + + Share 1-time link with a friend + شارك رابطًا لمرة واحدة مع صديق + + + Share SimpleX address on social media. + شارك عنوان SimpleX على وسائل التواصل الاجتماعي. + + + Share to SimpleX + المشاركة لSimpleX + + + Show calls in phone history + عرض المكالمات في سجل الهاتف + + + Show percentage + أظهِر النسبة المئوية + + + SimpleX + SimpleX + + + SimpleX Lock not enabled! + قفل SimpleX غير مفعّل! + + + Bad message hash + تجزئة رسالة سيئة + + + App session + جلسة التطبيق + + + SimpleX links not allowed + روابط SimpleX غير مسموح بها + + + All data is kept private on your device. + جميع البيانات تُحفظ بشكل خاص على جهازك. + + + Archived contacts + جهات الاتصال المؤرشفة + + + Show message status + أظهِر حالة الرسالة + + + Set message expiration in chats. + اضبط انتهاء صلاحية الرسالة في الدردشات. + + + Server address + عنوان الخادم + + + Show last messages + إظهار الرسائل الأخيرة + + + Server operator changed. + تغيّر مُشغل الخادم. + + + SimpleX address and 1-time links are safe to share via any messenger. + عنوان SimpleX والروابط لمرة واحدة آمنة للمشاركة عبر أي برنامج مُراسلة. + + + Add your team members to the conversations. + أضف أعضاء فريقك إلى المحادثات. + + + Advanced settings + إعدادات متقدّمة + + + Add to list + أضف إلى القائمة + + + Additional secondary + ثانوي إضافي + + + Admins can block a member for all. + يمكن للمُدراء حظر عضو للجميع. + + + All + الكل + + + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + جميع الرسائل والملفات تُرسل **مشفرة من النهاية-إلى-النهاية**، مع أمان ما-بعد-الحوسبة-الكمية في الرسائل المباشرة. + + + All new messages from %@ will be hidden! + جميع الرسائل الجديدة من %@ سيتم إخفاؤها! + + + Auto-accept + قبول تلقائي + + + Change self-destruct mode + تغيير وضع التدمير الذاتي + + + Chat database exported + صُدرت قاعدة بيانات الدردشة + + + Businesses + الشركات + + + Change automatic message deletion? + تغيير حذف الرسائل التلقائي؟ + + + Can't call contact + لا يمكن مكالمة جهة الاتصال + + + Chat list + قائمة الدردشات + + + Calls prohibited! + المكالمات ممنوعة! + + + Change lock mode + تغيير وضع القفل + + + Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. + توقفت الدردشة. إذا كنت قد استخدمت قاعدة البيانات هذه بالفعل على جهاز آخر، فيجب عليك نقلها مرة أخرى قبل بدء الدردشة. + + + Cellular + خلوي + + + Chat + الدردشة + + + Chat already exists! + الدردشة موجودة بالفعل! + + + Chat will be deleted for you - this cannot be undone! + سيتم حذف الدردشة لديك - لا يمكن التراجع عن هذا! + + + Chat will be deleted for all members - this cannot be undone! + سيتم حذف الدردشة لجميع الأعضاء - لا يمكن التراجع عن هذا! + + + Change self-destruct passcode + تغيير رمز المرور التدمير الذاتي + + + Camera not available + الكاميرا غير متوفرة + + + Capacity exceeded - recipient did not receive previously sent messages. + تم تجاوز السعة - لم يتلق المُستلم الرسائل المُرسلة مسبقًا. + + + Change passcode + تغيير رمز المرور + + + Chat colors + ألوان الدردشة + + + Chat theme + سمة الدردشة + + + Business address + عنوان العمل التجاري + + + Business chats + دردشات العمل التجاري + + + Cancel migration + ألغِ الترحيل + + + Change chat profiles + غيّر ملفات تعريف الدردشة + + + Chat migrated! + رحّلت الدردشة! + + + Chat profile + ملف تعريف الدردشة + @@ -3925,4 +4792,12 @@ SimpleX servers cannot see your profile. + + + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + يمكنك السماح بالمشاركة في إعدادات الخصوصية والأمان / اعدادات "SimpleX Lock" + + + 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 dce5d2030c..3e6e1ea387 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -7798,6 +7798,10 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Актуализирането на настройките ще свърже отново клиента към всички сървъри. @@ -9317,6 +9321,14 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption квантово устойчиво e2e криптиране @@ -9332,6 +9344,10 @@ Repeat connection request? получено потвърждение… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call отхвърлено повикване 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 55b750b371..54dc3734c3 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -7528,6 +7528,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Aktualizací nastavení se klient znovu připojí ke všem serverům. @@ -8994,6 +8998,14 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -9008,6 +9020,10 @@ Repeat connection request? obdržel potvrzení… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call odmítnutý hovor 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 3efdfa942b..6674d1587b 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -685,12 +685,12 @@ Add server - Füge Server hinzu + Server hinzufügen No comment provided by engineer. Add servers by scanning QR codes. - Fügen Sie Server durch Scannen der QR Codes hinzu. + Server durch Scannen von QR Codes hinzufügen. No comment provided by engineer. @@ -1245,6 +1245,7 @@ Better groups performance + Bessere Leistung von Gruppen No comment provided by engineer. @@ -1269,6 +1270,7 @@ Better privacy and security + Bessere(r) Security und Datenschutz No comment provided by engineer. @@ -2865,6 +2867,7 @@ Das ist Ihr eigener Einmal-Link! Don't miss important messages. + Verpassen Sie keine wichtigen Nachrichten. No comment provided by engineer. @@ -3625,6 +3628,7 @@ Das ist Ihr eigener Einmal-Link! Faster deletion of groups. + Schnelleres löschen von Gruppen. No comment provided by engineer. @@ -3634,6 +3638,7 @@ Das ist Ihr eigener Einmal-Link! Faster sending messages. + Schnelleres versenden von Nachrichten. No comment provided by engineer. @@ -3941,6 +3946,7 @@ Fehler: %2$@ Get notified when mentioned. + Bei Erwähnung benachrichtigt werden. No comment provided by engineer. @@ -4060,6 +4066,7 @@ Fehler: %2$@ Help admins moderating their groups. + Helfen Sie Administratoren bei der Moderation ihrer Gruppen. No comment provided by engineer. @@ -4846,6 +4853,7 @@ Das ist Ihr Link für die Gruppe %@! Mention members 👋 + Erwähnung von Mitgliedern 👋 No comment provided by engineer. @@ -5315,6 +5323,7 @@ Das ist Ihr Link für die Gruppe %@! No message + Keine Nachricht No comment provided by engineer. @@ -5677,6 +5686,7 @@ Dies erfordert die Aktivierung eines VPNs. Organize chats into lists + Chats in Listen verwalten No comment provided by engineer. @@ -5947,6 +5957,7 @@ Fehler: %@ Private media file names. + Medien mit anonymisierten Dateinamen. No comment provided by engineer. @@ -6875,6 +6886,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Send private reports + Private Meldungen senden No comment provided by engineer. @@ -7119,6 +7131,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Set message expiration in chats. + Verfallsdatum von Nachrichten in Chats festlegen. No comment provided by engineer. @@ -7929,6 +7942,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This message was deleted or not received yet. + Diese Nachricht wurde gelöscht oder bisher noch nicht empfangen. No comment provided by engineer. @@ -8245,6 +8259,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Einstellungen aktualisieren? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Die Aktualisierung der Einstellungen wird den Client wieder mit allen Servern verbinden. @@ -9822,6 +9840,14 @@ Verbindungsanfrage wiederholen? Peer-to-Peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption Quantum-resistente E2E-Verschlüsselung @@ -9837,6 +9863,10 @@ Verbindungsanfrage wiederholen? Bestätigung erhalten… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call Abgelehnter Anruf 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 6d0086bd34..39d229c5fa 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -8259,6 +8259,11 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Updating settings will re-connect the client to all servers. @@ -9836,6 +9841,16 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + pending + No comment provided by engineer. + + + pending approval + pending approval + No comment provided by engineer. + quantum resistant e2e encryption quantum resistant e2e encryption @@ -9851,6 +9866,11 @@ Repeat connection request? received confirmation… No comment provided by engineer. + + rejected + rejected + No comment provided by engineer. + rejected call rejected call 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 c2b996fd2e..e9b4de3017 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1245,6 +1245,7 @@ Better groups performance + Mejor rendimiento de los grupos No comment provided by engineer. @@ -1269,6 +1270,7 @@ Better privacy and security + Mejor privacidad y seguridad No comment provided by engineer. @@ -2865,6 +2867,7 @@ This is your own one-time link! Don't miss important messages. + No pierdas los mensajes importantes. No comment provided by engineer. @@ -3625,6 +3628,7 @@ This is your own one-time link! Faster deletion of groups. + Eliminación más rápida de grupos. No comment provided by engineer. @@ -3634,6 +3638,7 @@ This is your own one-time link! Faster sending messages. + Envío más rápido de mensajes. No comment provided by engineer. @@ -3941,6 +3946,7 @@ Error: %2$@ Get notified when mentioned. + Las menciones ahora notifican. No comment provided by engineer. @@ -4060,6 +4066,7 @@ Error: %2$@ Help admins moderating their groups. + Ayuda a los admins moderar sus grupos. No comment provided by engineer. @@ -4846,6 +4853,7 @@ This is your link for group %@! Mention members 👋 + Mencionar miembros 👋 No comment provided by engineer. @@ -5315,6 +5323,7 @@ This is your link for group %@! No message + Ningún mensaje No comment provided by engineer. @@ -5677,6 +5686,7 @@ Requiere activación de la VPN. Organize chats into lists + Organiza los chats en listas No comment provided by engineer. @@ -5947,6 +5957,7 @@ Error: %@ Private media file names. + Nombres privados de archivos de media. No comment provided by engineer. @@ -6875,6 +6886,7 @@ Actívalo en ajustes de *Servidores y Redes*. Send private reports + Envía informes privados No comment provided by engineer. @@ -7119,6 +7131,7 @@ Actívalo en ajustes de *Servidores y Redes*. Set message expiration in chats. + Establece vencimiento de los mensajes en chats. No comment provided by engineer. @@ -7929,6 +7942,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. This message was deleted or not received yet. + El mensaje ha sido borrado o aún no se ha recibido. No comment provided by engineer. @@ -8245,6 +8259,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión ¿Actualizar configuración? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Para actualizar la configuración el cliente se reconectará a todos los servidores. @@ -9822,6 +9840,14 @@ Repeat connection request? p2p No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption cifrado e2e resistente a tecnología cuántica @@ -9837,6 +9863,10 @@ Repeat connection request? confirmación recibida… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call llamada rechazada 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 d0a7bd7e77..9d7c9c4bbb 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -7505,6 +7505,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Asetusten päivittäminen yhdistää asiakkaan uudelleen kaikkiin palvelimiin. @@ -8971,6 +8975,14 @@ Repeat connection request? vertais No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8985,6 +8997,10 @@ Repeat connection request? vahvistus saatu… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call hylätty puhelu 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 2d838b54a4..31eef4b3b1 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -8134,6 +8134,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Mettre à jour les paramètres ? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. La mise à jour des ces paramètres reconnectera le client à tous les serveurs. @@ -9705,6 +9709,14 @@ Répéter la demande de connexion ? pair-à-pair No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption chiffrement e2e résistant post-quantique @@ -9720,6 +9732,10 @@ Répéter la demande de connexion ? confimation reçu… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call appel rejeté 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 cbc05bda5e..cd2c8022c1 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -234,7 +234,7 @@ %lld contact(s) selected - %lld ismerős kijelölve + %lld partner kijelölve No comment provided by engineer. @@ -354,7 +354,7 @@ **Create 1-time link**: to create and share a new invitation link. - **Ismerős hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. + **Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. No comment provided by engineer. @@ -364,7 +364,7 @@ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. - **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van. + **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van. No comment provided by engineer. @@ -374,7 +374,7 @@ **Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection. - **Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését. + **Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését. No comment provided by engineer. @@ -444,8 +444,8 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! - - értesíti az ismerősöket a törlésről (nem kötelező) -- profil nevek szóközökkel + - partnerek értesítése a törlésről (nem kötelező) +- profilnevek szóközökkel - és még sok más! No comment provided by engineer. @@ -513,7 +513,7 @@ 1-time link can be used *with one contact only* - share in person or via any messenger. - Az egyszer használható meghívási hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható. + Az egyszer használható meghívási hivatkozás csak *egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltón keresztül megosztható. No comment provided by engineer. @@ -560,7 +560,7 @@ A new contact - Egy új ismerős + Egy új partner notification title @@ -576,7 +576,7 @@ A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. - **Az összes ismerőséhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva. + **Az összes partneréhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva. **Megjegyzés:** ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet. No comment provided by engineer. @@ -607,7 +607,7 @@ Accent - Kiemelés + Kiemelőszín No comment provided by engineer. @@ -665,7 +665,7 @@ Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára. + Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára. No comment provided by engineer. @@ -730,17 +730,17 @@ Additional accent - További kiemelés + További kiemelőszín No comment provided by engineer. Additional accent 2 - További kiemelés 2 + További kiemelőszín 2 No comment provided by engineer. Additional secondary - További másodlagos + További másodlagos szín No comment provided by engineer. @@ -850,17 +850,17 @@ All your contacts will remain connected. - Az összes ismerősével kapcsolatban marad. + Az összes partnerével kapcsolatban marad. No comment provided by engineer. All your contacts will remain connected. Profile update will be sent to your contacts. - Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára. + A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön partnerei számára. No comment provided by engineer. All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra. + Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra. No comment provided by engineer. @@ -870,7 +870,7 @@ Allow calls only if your contact allows them. - A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. No comment provided by engineer. @@ -880,7 +880,7 @@ Allow disappearing messages only if your contact allows it to you. - Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi az Ön számára. + Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi az Ön számára. No comment provided by engineer. @@ -890,12 +890,12 @@ Allow irreversible message deletion only if your contact allows it to you. (24 hours) - Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra) + Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) No comment provided by engineer. Allow message reactions only if your contact allows them. - A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. No comment provided by engineer. @@ -925,7 +925,7 @@ Allow to report messsages to moderators. - Lehetővé teszi az üzenetek jelentését a moderátorok felé. + Az üzenetek jelentése a moderátorok felé engedélyezve van. No comment provided by engineer. @@ -945,7 +945,7 @@ Allow voice messages only if your contact allows them. - A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. No comment provided by engineer. @@ -955,27 +955,27 @@ Allow your contacts adding message reactions. - A reakciók hozzáadása az üzenetekhez engedélyezve van az ismerősei számára. + A reakciók hozzáadása az üzenetekhez engedélyezve van a partnerei számára. No comment provided by engineer. Allow your contacts to call you. - A hívások kezdeményezése engedélyezve van az ismerősei számára. + A hívások kezdeményezése engedélyezve van a partnerei számára. No comment provided by engineer. Allow your contacts to irreversibly delete sent messages. (24 hours) - Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra) + Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra) No comment provided by engineer. Allow your contacts to send disappearing messages. - Az eltűnő üzenetek küldésének engedélyezése az ismerősei számára. + Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. No comment provided by engineer. Allow your contacts to send voice messages. - A hangüzenetek küldése engedélyezve van az ismerősei számára. + A hangüzenetek küldése engedélyezve van a partnerei számára. No comment provided by engineer. @@ -1000,7 +1000,7 @@ Always use relay - Mindig használjon továbbító-kiszolgálót + Mindig használjon továbbítókiszolgálót No comment provided by engineer. @@ -1110,7 +1110,7 @@ Archive contacts to chat later. - Az ismerősök archiválása a későbbi csevegéshez. + A partnerek archiválása a későbbi csevegéshez. No comment provided by engineer. @@ -1130,7 +1130,7 @@ Archived contacts - Archivált ismerősök + Archivált partnerek No comment provided by engineer. @@ -1245,6 +1245,7 @@ Better groups performance + Továbbfejlesztett, gyorsabb csoportok No comment provided by engineer. @@ -1269,6 +1270,7 @@ Better privacy and security + Továbbfejlesztett adatvédelem és biztonság No comment provided by engineer. @@ -1403,7 +1405,7 @@ Can't call contact - Nem lehet felhívni az ismerőst + Nem lehet felhívni a partnert No comment provided by engineer. @@ -1413,12 +1415,12 @@ Can't invite contact! - Nem lehet meghívni az ismerőst! + Nem lehet meghívni a partnert! No comment provided by engineer. Can't invite contacts! - Nem lehet meghívni az ismerősöket! + Nem lehet meghívni a partnereket! No comment provided by engineer. @@ -1499,7 +1501,7 @@ Change receiving address - A fogadási cím módosítása + Fogadási cím módosítása No comment provided by engineer. @@ -1585,7 +1587,7 @@ Chat list - Csevegőlista + Csevegési lista No comment provided by engineer. @@ -1595,12 +1597,12 @@ Chat preferences - Csevegés-beállítások + Csevegési beállítások No comment provided by engineer. Chat preferences were changed. - A csevegés-beállítások módosultak. + A csevegési beállítások módosultak. alert message @@ -1735,7 +1737,7 @@ Compare security codes with your contacts. - Biztonsági kódok összehasonlítása az ismerősökével. + Biztonsági kódok összehasonlítása a partnerekével. No comment provided by engineer. @@ -1805,7 +1807,7 @@ Confirm contact deletion? - Biztosan törli az ismerőst? + Biztosan törli a partnert? No comment provided by engineer. @@ -1870,7 +1872,7 @@ Connect to your friends faster. - Kapcsolódjon gyorsabban az ismerőseihez. + Kapcsolódjon gyorsabban a partnereihez. No comment provided by engineer. @@ -1949,7 +1951,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Connecting to contact, please wait or check later! - Kapcsolódás az ismerőshöz, várjon vagy ellenőrizze később! + Kapcsolódás a partnerhez, várjon vagy ellenőrizze később! No comment provided by engineer. @@ -2036,17 +2038,17 @@ Ez az Ön egyszer használható meghívási hivatkozása! Contact allows - Ismerős engedélyezi + Partner engedélyezi No comment provided by engineer. Contact already exists - Az ismerős már létezik + A partner már létezik No comment provided by engineer. Contact deleted! - Ismerős törölve! + Partner törölve! No comment provided by engineer. @@ -2056,12 +2058,12 @@ Ez az Ön egyszer használható meghívási hivatkozása! Contact is connected - Ismerőse kapcsolódott + Partnere kapcsolódott notification Contact is deleted. - Törölt ismerős. + Törölt partner. No comment provided by engineer. @@ -2071,22 +2073,22 @@ Ez az Ön egyszer használható meghívási hivatkozása! Contact preferences - Ismerős-beállítások + Partnerbeállítások No comment provided by engineer. Contact will be deleted - this cannot be undone! - Az ismerős törölve lesz – ez a művelet nem vonható vissza! + A partner törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. Contacts - Ismerősök + Partnerek No comment provided by engineer. Contacts can mark messages for deletion; you will be able to view them. - Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. + A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. No comment provided by engineer. @@ -2425,7 +2427,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete and notify contact - Törlés, és az ismerős értesítése + Törlés, és a partner értesítése No comment provided by engineer. @@ -2460,12 +2462,12 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete contact - Ismerős törlése + Partner törlése No comment provided by engineer. Delete contact? - Ismerős törlése? + Partner törlése? No comment provided by engineer. @@ -2500,7 +2502,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete for me - Csak nálam + Csak számomra No comment provided by engineer. @@ -2570,7 +2572,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete pending connection? - Függőben lévő ismerőskérelem törlése? + Függőben lévő partnerkérelem törlése? No comment provided by engineer. @@ -2665,7 +2667,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Destination server address of %@ is incompatible with forwarding server %@ settings. - A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbító kiszolgáló beállításaival. + A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbítókiszolgáló beállításaival. No comment provided by engineer. @@ -2675,7 +2677,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Destination server version of %@ is incompatible with forwarding server %@. - A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbító kiszolgálóval. + A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbítókiszolgálóval. No comment provided by engineer. @@ -2865,6 +2867,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Don't miss important messages. + Ne maradjon le a fontos üzenetekről. No comment provided by engineer. @@ -2950,7 +2953,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Edit group profile - A csoport profiljának szerkesztése + Csoportprofil szerkesztése No comment provided by engineer. @@ -3230,7 +3233,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Error connecting to forwarding server %@. Please try later. - Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + Hiba a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. No comment provided by engineer. @@ -3255,7 +3258,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Error creating member contact - Hiba az ismerőssel történő kapcsolat létrehozásában + Hiba a partnerrel történő kapcsolat létrehozásában No comment provided by engineer. @@ -3625,6 +3628,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Faster deletion of groups. + Gyorsabb csoporttörlés. No comment provided by engineer. @@ -3634,6 +3638,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Faster sending messages. + Gyorsabb üzenetküldés. No comment provided by engineer. @@ -3782,7 +3787,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Fix not supported by contact - Ismerős általi javítás nem támogatott + Partner általi javítás nem támogatott No comment provided by engineer. @@ -3807,7 +3812,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. - Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. + Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. No comment provided by engineer. @@ -3872,30 +3877,30 @@ Ez az Ön egyszer használható meghívási hivatkozása! Forwarding server %@ failed to connect to destination server %@. Please try later. - A(z) %@ továbbító-kiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később. + A(z) %@ továbbítókiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később. No comment provided by engineer. Forwarding server address is incompatible with network settings: %@. - A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@. + A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@. No comment provided by engineer. Forwarding server version is incompatible with network settings: %@. - A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@. + A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@. No comment provided by engineer. Forwarding server: %1$@ Destination server error: %2$@ - Továbbító-kiszolgáló: %1$@ + Továbbítókiszolgáló: %1$@ Célkiszolgáló-hiba: %2$@ snd error text Forwarding server: %1$@ Error: %2$@ - Továbbító-kiszolgáló: %1$@ + Továbbítókiszolgáló: %1$@ Hiba: %2$@ snd error text @@ -3941,6 +3946,7 @@ Hiba: %2$@ Get notified when mentioned. + Kapjon értesítést, ha megemlítik. No comment provided by engineer. @@ -4020,7 +4026,7 @@ Hiba: %2$@ Group preferences - Csoport-beállítások + Csoportbeállítások No comment provided by engineer. @@ -4060,6 +4066,7 @@ Hiba: %2$@ Help admins moderating their groups. + Segítsen az adminisztrátoroknak a csoportjaik moderálásában. No comment provided by engineer. @@ -4296,7 +4303,7 @@ További fejlesztések hamarosan! Incognito mode protects your privacy by using a new random profile for each contact. - Az inkognitómód védi személyes adatait azáltal, hogy az összes ismerőséhez új, véletlenszerű profilt használ. + Az inkognitómód úgy védi a személyes adatait, hogy az összes partneréhez új, véletlenszerű profilt használ. No comment provided by engineer. @@ -4483,12 +4490,12 @@ További fejlesztések hamarosan! It allows having many anonymous connections without any shared data between them in a single chat profile. - Lehetővé teszi, hogy egyetlen csevegőprofilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük. + 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 az ismerőse régi adatbázis biztonsági mentést használt. + Ez akkor fordulhat elő, ha Ön vagy a partnere régi adatbázis biztonsági mentést használt. No comment provided by engineer. @@ -4498,7 +4505,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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy az ismerőse régebbi adatbázis biztonsági mentést használt. +2. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt. 3. A kapcsolat sérült. No comment provided by engineer. @@ -4846,6 +4853,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Mention members 👋 + Tagok említése 👋 No comment provided by engineer. @@ -5090,7 +5098,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Multiple chat profiles - Több csevegőprofil + Több csevegési profil No comment provided by engineer. @@ -5265,12 +5273,12 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No contacts selected - Nincs ismerős kijelölve + Nincs partner kijelölve No comment provided by engineer. No contacts to add - Nincs hozzáadandó ismerős + Nincs hozzáadandó partner No comment provided by engineer. @@ -5315,6 +5323,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! No message + Nincs üzenet No comment provided by engineer. @@ -5487,12 +5496,12 @@ VPN engedélyezése szükséges. Only chat owners can change preferences. - Csak a csevegés tulajdonosai módosíthatják a csevegés-beállításokat. + Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat. No comment provided by engineer. Only client devices store user profiles, contacts, groups, and messages. - A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. + A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. No comment provided by engineer. @@ -5502,7 +5511,7 @@ VPN engedélyezése szükséges. Only group owners can change group preferences. - Csak a csoport tulajdonosai módosíthatják a csoport-beállításokat. + Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat. No comment provided by engineer. @@ -5532,7 +5541,7 @@ VPN engedélyezése szükséges. Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours) - Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti meg őket ). (24 óra) + Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) No comment provided by engineer. @@ -5552,27 +5561,27 @@ VPN engedélyezése szükséges. Only your contact can add message reactions. - Csak az ismerőse adhat hozzá reakciókat az üzenetekhez. + Csak a partnere adhat hozzá reakciókat az üzenetekhez. No comment provided by engineer. Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours) - Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) No comment provided by engineer. Only your contact can make calls. - Csak az ismerőse tud hívást indítani. + Csak a partnere tud hívást indítani. No comment provided by engineer. Only your contact can send disappearing messages. - Csak az ismerőse tud eltűnő üzeneteket küldeni. + Csak a partnere tud eltűnő üzeneteket küldeni. No comment provided by engineer. Only your contact can send voice messages. - Csak az ismerőse tud hangüzeneteket küldeni. + Csak a partnere tud hangüzeneteket küldeni. No comment provided by engineer. @@ -5677,6 +5686,7 @@ VPN engedélyezése szükséges. Organize chats into lists + Csevegések listákba szervezése No comment provided by engineer. @@ -5788,12 +5798,12 @@ VPN engedélyezése szükséges. Please ask your contact to enable calls. - Kérje meg az ismerősét, hogy engedélyezze a hívásokat. + Kérje meg a partnerét, hogy engedélyezze a hívásokat. No comment provided by engineer. Please ask your contact to enable sending voice messages. - Kérje meg az ismerősét, hogy engedélyezze a hangüzenetek küldését. + Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését. No comment provided by engineer. @@ -5805,7 +5815,7 @@ Minden további problémát osszon meg a fejlesztőkkel. Please check that you used the correct link or ask your contact to send you another one. - Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat. + Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat. No comment provided by engineer. @@ -5815,7 +5825,7 @@ Minden további problémát osszon meg a fejlesztőkkel. Please check yours and your contact preferences. - Ellenőrizze a saját- és az ismerőse beállításait. + Ellenőrizze a saját- és a partnere beállításait. No comment provided by engineer. @@ -5947,6 +5957,7 @@ Hiba: %@ Private media file names. + Privát nevek a médiafájlokhoz. No comment provided by engineer. @@ -6001,7 +6012,7 @@ Hiba: %@ Profile update will be sent to your contacts. - A profilfrissítés el lesz küldve az Ön ismerősei számára. + A profilfrissítés el lesz küldve az Ön partnerei számára. alert message @@ -6067,7 +6078,7 @@ Hiba: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben. + Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben. Engedélyezze a *Hálózat és kiszolgálók* menüben. No comment provided by engineer. @@ -6319,12 +6330,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. 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ímet. 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 IP-címet, de megfigyelheti a hívás időtartamát. + A továbbítókiszolgáló megvédi az Ön IP-címét, de megfigyelheti a hívás időtartamát. No comment provided by engineer. @@ -6600,12 +6611,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Save (and notify contacts) - Mentés és az ismerősök értesítése + Mentés (és a partnerek értesítése) alert button Save and notify contact - Mentés és az ismerős értesítése + Mentés és a partner értesítése alert button @@ -6725,7 +6736,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Scan security code from your contact's app. - Biztonsági kód beolvasása az ismerősének alkalmazásából. + Biztonsági kód beolvasása a partnere alkalmazásából. No comment provided by engineer. @@ -6750,7 +6761,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Secondary - Másodlagos + Másodlagos szín No comment provided by engineer. @@ -6790,7 +6801,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Selected chat preferences prohibit this message. - A kijelölt csevegés-beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -6875,6 +6886,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Send private reports + Privát jelentések küldése No comment provided by engineer. @@ -6909,12 +6921,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Sending delivery receipts will be enabled for all contacts in all visible chat profiles. - A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára. + A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. No comment provided by engineer. Sending delivery receipts will be enabled for all contacts. - A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz. + A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. No comment provided by engineer. @@ -6924,7 +6936,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Sending receipts is disabled for %lld contacts - A kézbesítési jelentések le vannak tiltva %lld ismerősnél + A kézbesítési jelentések le vannak tiltva %lld partnernél No comment provided by engineer. @@ -6934,7 +6946,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Sending receipts is enabled for %lld contacts - A kézbesítési jelentések engedélyezve vannak %lld ismerősnél + A kézbesítési jelentések engedélyezve vannak %lld partnernél No comment provided by engineer. @@ -7099,7 +7111,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Set contact name… - Ismerős nevének beállítása… + Partner nevének beállítása… No comment provided by engineer. @@ -7109,7 +7121,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Set group preferences - Csoport-beállítások megadása + Csoportbeállítások megadása No comment provided by engineer. @@ -7119,6 +7131,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Set message expiration in chats. + Üzenetek eltűnési idejének módosítása a csevegésekben. No comment provided by engineer. @@ -7194,7 +7207,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Share address with contacts? - Megosztja a címet az ismerőseivel? + Megosztja a címet a partnereivel? alert title @@ -7224,7 +7237,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Share with contacts - Megosztás az ismerősökkel + Megosztás a partnerekkel No comment provided by engineer. @@ -7319,7 +7332,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. SimpleX address and 1-time links are safe to share via any messenger. - A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. + A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetváltón keresztül. No comment provided by engineer. @@ -7734,7 +7747,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The connection reached the limit of undelivered messages, your contact may be offline. - A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van. + A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön partnere lehet, hogy offline állapotban van. No comment provided by engineer. @@ -7744,7 +7757,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The contact you shared this link with will NOT be able to connect! - Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! + A partnere, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! No comment provided by engineer. @@ -7794,7 +7807,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The profile is only shared with your contacts. - A profilja csak az ismerőseivel van megosztva. + A profilja csak a partnereivel van megosztva. No comment provided by engineer. @@ -7824,12 +7837,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The servers for new connections of your current chat profile **%@**. - A jelenlegi **%@** nevű csevegő-profiljához tartozó új kapcsolatok kiszolgálói. + A jelenlegi **%@** nevű csevegési profiljához tartozó új kapcsolatok kiszolgálói. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - A jelenlegi **%@** nevű csevegő-profiljához tartozó új fájlok kiszolgálói. + A jelenlegi **%@** nevű csevegési profiljához tartozó új fájlok kiszolgálói. No comment provided by engineer. @@ -7854,12 +7867,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. These settings are for your current profile **%@**. - Ezek a beállítások csak a jelenlegi **%@** nevű csevegő-profiljára vonatkoznak. + Ezek a beállítások csak a jelenlegi **%@** nevű csevegési profiljára vonatkoznak. No comment provided by engineer. They can be overridden in contact and group settings. - Ezek felülbírálhatók az ismerős- és csoport-beállításokban. + Ezek felülbírálhatók a partner- és csoportbeállításokban. No comment provided by engineer. @@ -7879,7 +7892,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek. + Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek. No comment provided by engineer. @@ -7929,11 +7942,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This message was deleted or not received yet. + Ez az üzenet törölve lett vagy még nem érkezett meg. No comment provided by engineer. This setting applies to messages in your current chat profile **%@**. - Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegő-profiljában lévő üzenetekre vonatkozik. + Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegési profiljában lévő üzenetekre vonatkozik. No comment provided by engineer. @@ -7948,7 +7962,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. To connect, your contact can scan QR code or use the link in the app. - A kapcsolódáshoz az ismerőse beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. + A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. No comment provided by engineer. @@ -7963,7 +7977,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. To protect against your link being replaced, you can compare contact security codes. - A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat az ismerősével. + A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével. No comment provided by engineer. @@ -8030,12 +8044,12 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To verify end-to-end encryption with your contact compare (or scan) the code on your devices. - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal. + A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. No comment provided by engineer. Toggle chat list: - Csevegőlista átváltása: + Csevegési lista átváltása: No comment provided by engineer. @@ -8070,12 +8084,12 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Trying to connect to the server used to receive messages from this contact (error: %@). - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %@). + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@). No comment provided by engineer. Trying to connect to the server used to receive messages from this contact. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. No comment provided by engineer. @@ -8186,8 +8200,8 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. - Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát. -A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. + Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát. +A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. No comment provided by engineer. @@ -8245,6 +8259,10 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Beállítások frissítése? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. @@ -8637,12 +8655,12 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc 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ó lesz a fájlkiszolgálók számára. + Tor vagy VPN nélkül az Ön IP-címe látható lesz 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 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 Ön IP-címe látható lesz a következő XFTP-továbbítókiszolgálók számára: %@. alert message @@ -8744,7 +8762,7 @@ Csatlakozáskérés megismétlése? You are connected to the server used to receive messages from this contact. - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. No comment provided by engineer. @@ -8804,7 +8822,7 @@ Csatlakozáskérés megismétlése? You can make it visible to your SimpleX contacts via Settings. - Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”. + Láthatóvá teheti a SimpleXbeli partnerei számára a „Beállításokban”. No comment provided by engineer. @@ -8814,12 +8832,12 @@ Csatlakozáskérés megismétlése? You can send messages to %@ from Archived contacts. - Az „Archivált ismerősökből” továbbra is küldhet üzeneteket neki: %@. + Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %@. No comment provided by engineer. You can set connection name, to remember who the link was shared with. - Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. + Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. No comment provided by engineer. @@ -8834,7 +8852,7 @@ Csatlakozáskérés megismétlése? You can share this address with your contacts to let them connect with **%@**. - Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: **%@**. + Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: **%@**. No comment provided by engineer. @@ -8844,7 +8862,7 @@ Csatlakozáskérés megismétlése? You can still view conversation with %@ in the list of chats. - A(z) %@ nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. + A(z) %@ nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. No comment provided by engineer. @@ -8896,7 +8914,7 @@ Kapcsolatkérés megismétlése? You invited a contact - Ön meghívta egy ismerősét + Ön meghívta egy partnerét No comment provided by engineer. @@ -8921,17 +8939,17 @@ Kapcsolatkérés megismétlése? You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. - A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől. + A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. No comment provided by engineer. You need to allow your contact to call to be able to call them. - Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást. + Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást. No comment provided by engineer. You need to allow your contact to send voice messages to be able to send them. - Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak. + Engedélyeznie kell a hangüzenetek küldését a partnere számára, hogy hangüzeneteket küldhessenek egymásnak. No comment provided by engineer. @@ -8966,7 +8984,7 @@ Kapcsolatkérés megismétlése? You will be connected when your contact's device is online, please wait or check later! - Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. @@ -8996,12 +9014,12 @@ Kapcsolatkérés megismétlése? You won't lose your contacts if you later delete your address. - Nem veszíti el az ismerőseit, ha később törli a címét. + Nem veszíti el a partnereit, ha később törli a címét. No comment provided by engineer. You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile - Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban + Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban No comment provided by engineer. @@ -9041,7 +9059,7 @@ Kapcsolatkérés megismétlése? Your chat preferences - Az Ön csevegés-beállításai + Az Ön csevegési beállításai alert title @@ -9056,17 +9074,17 @@ Kapcsolatkérés megismétlése? Your contact sent a file that is larger than currently supported maximum size (%@). - Az ismerőse a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött. + A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött. No comment provided by engineer. Your contacts can allow full message deletion. - Az ismerősei engedélyezhetik a teljes üzenet törlést. + A partnerei engedélyezhetik a teljes üzenet törlését. No comment provided by engineer. Your contacts will remain connected. - Az ismerősei továbbra is kapcsolódva maradnak. + A partnerei továbbra is kapcsolódva maradnak. No comment provided by engineer. @@ -9106,17 +9124,17 @@ Kapcsolatkérés megismétlése? 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 az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. + 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. No comment provided by engineer. 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 az Ön ismerősei számára. + A profilja módosult. Ha elmenti, a profilfrissítés el lesz küldve az Ön partnerei számára. alert message Your profile, contacts and delivered messages are stored on your device. - A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva. + A profilja, a partnerei és az elküldött üzenetei az Ön eszközén vannak tárolva. No comment provided by engineer. @@ -9391,12 +9409,12 @@ Kapcsolatkérés megismétlése? contact has e2e encryption - az ismerős e2e titkosítással rendelkezik + a partner e2e titkosítással rendelkezik No comment provided by engineer. contact has no e2e encryption - az ismerős nem rendelkezik e2e titkosítással + a partner nem rendelkezik e2e titkosítással No comment provided by engineer. @@ -9447,7 +9465,7 @@ Kapcsolatkérés megismétlése? deleted contact - törölt ismerős + törölt partner rcv direct event chat item @@ -9492,7 +9510,7 @@ Kapcsolatkérés megismétlése? enabled for contact - engedélyezve az ismerős számára + engedélyezve a partner számára enabled status @@ -9822,6 +9840,14 @@ Kapcsolatkérés megismétlése? egyenrangú No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption végpontok közötti kvantumálló titkosítás @@ -9837,6 +9863,10 @@ Kapcsolatkérés megismétlése? visszaigazolás fogadása… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call elutasított hívás @@ -9948,7 +9978,7 @@ utoljára fogadott üzenet: %2$@ this contact - ez az ismerős + ez a partner notification title @@ -10013,7 +10043,7 @@ utoljára fogadott üzenet: %2$@ via relay - egy továbbító-kiszolgálón keresztül + egy továbbítókiszolgálón keresztül No comment provided by engineer. @@ -10155,7 +10185,7 @@ utoljára fogadott üzenet: %2$@ SimpleX uses local network access to allow using user chat profile via desktop app on the same network. - A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegőprofil használatát számítógépen keresztül ugyanazon a hálózaton. + A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegési profil használatát számítógépen keresztül ugyanazon a hálózaton. Privacy - Local Network Usage Description @@ -10388,7 +10418,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kijelölt csevegés-beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index c01699933c..7bc503c097 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -1245,6 +1245,7 @@ Better groups performance + Prestazioni dei gruppi migliorate No comment provided by engineer. @@ -1269,6 +1270,7 @@ Better privacy and security + Privacy e sicurezza migliorate No comment provided by engineer. @@ -2865,6 +2867,7 @@ Questo è il tuo link una tantum! Don't miss important messages. + Non perdere messaggi importanti. No comment provided by engineer. @@ -3625,6 +3628,7 @@ Questo è il tuo link una tantum! Faster deletion of groups. + Eliminazione dei gruppi più veloce. No comment provided by engineer. @@ -3634,6 +3638,7 @@ Questo è il tuo link una tantum! Faster sending messages. + Invio dei messaggi più veloce. No comment provided by engineer. @@ -3941,6 +3946,7 @@ Errore: %2$@ Get notified when mentioned. + Ricevi una notifica quando menzionato. No comment provided by engineer. @@ -4060,6 +4066,7 @@ Errore: %2$@ Help admins moderating their groups. + Aiuta gli amministratori a moderare i loro gruppi. No comment provided by engineer. @@ -4846,6 +4853,7 @@ Questo è il tuo link per il gruppo %@! Mention members 👋 + Menziona i membri 👋 No comment provided by engineer. @@ -5315,6 +5323,7 @@ Questo è il tuo link per il gruppo %@! No message + Nessun messaggio No comment provided by engineer. @@ -5677,6 +5686,7 @@ Richiede l'attivazione della VPN. Organize chats into lists + Organizza le chat in elenchi No comment provided by engineer. @@ -5947,6 +5957,7 @@ Errore: %@ Private media file names. + Nomi privati dei file multimediali. No comment provided by engineer. @@ -6875,6 +6886,7 @@ Attivalo nelle impostazioni *Rete e server*. Send private reports + Invia segnalazioni private No comment provided by engineer. @@ -7119,6 +7131,7 @@ Attivalo nelle impostazioni *Rete e server*. Set message expiration in chats. + Imposta la scadenza dei messaggi nelle chat. No comment provided by engineer. @@ -7929,6 +7942,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. This message was deleted or not received yet. + Questo messaggio è stato eliminato o non ancora ricevuto. No comment provided by engineer. @@ -8245,6 +8259,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Aggiornare le impostazioni? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. L'aggiornamento delle impostazioni riconnetterà il client a tutti i server. @@ -9822,6 +9840,14 @@ Ripetere la richiesta di connessione? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption crittografia e2e resistente alla quantistica @@ -9837,6 +9863,10 @@ Ripetere la richiesta di connessione? conferma ricevuta… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call chiamata rifiutata 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 81ceeaecba..1a79d251eb 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -7575,6 +7575,10 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. 設定を更新すると、全サーバにクライントの再接続が行われます。 @@ -9042,6 +9046,14 @@ Repeat connection request? P2P No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -9056,6 +9068,10 @@ Repeat connection request? 確認を受け取りました… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call 拒否した通話 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 5da67ed1e6..aeb9c96696 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1245,6 +1245,7 @@ Better groups performance + Betere prestaties van groepen No comment provided by engineer. @@ -1269,6 +1270,7 @@ Better privacy and security + Betere privacy en veiligheid No comment provided by engineer. @@ -2865,6 +2867,7 @@ Dit is uw eigen eenmalige link! Don't miss important messages. + ‐Mis geen belangrijke berichten. No comment provided by engineer. @@ -3625,6 +3628,7 @@ Dit is uw eigen eenmalige link! Faster deletion of groups. + Sneller verwijderen van groepen. No comment provided by engineer. @@ -3634,6 +3638,7 @@ Dit is uw eigen eenmalige link! Faster sending messages. + Sneller verzenden van berichten. No comment provided by engineer. @@ -3941,6 +3946,7 @@ Fout: %2$@ Get notified when mentioned. + Ontvang een melding als u vermeld wordt. No comment provided by engineer. @@ -4060,6 +4066,7 @@ Fout: %2$@ Help admins moderating their groups. + Help beheerders bij het modereren van hun groepen. No comment provided by engineer. @@ -4846,6 +4853,7 @@ Dit is jouw link voor groep %@! Mention members 👋 + Vermeld leden 👋 No comment provided by engineer. @@ -5315,6 +5323,7 @@ Dit is jouw link voor groep %@! No message + Geen bericht No comment provided by engineer. @@ -5677,6 +5686,7 @@ Vereist het inschakelen van VPN. Organize chats into lists + Organiseer chats in lijsten No comment provided by engineer. @@ -5947,6 +5957,7 @@ Fout: %@ Private media file names. + Namen van persoonlijke mediabestanden. No comment provided by engineer. @@ -6875,6 +6886,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Send private reports + Verstuur rapporten privé No comment provided by engineer. @@ -7119,6 +7131,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Set message expiration in chats. + Stel de berichtvervaldatum in chats in. No comment provided by engineer. @@ -7929,6 +7942,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This message was deleted or not received yet. + Dit bericht is verwijderd of nog niet ontvangen. No comment provided by engineer. @@ -8245,6 +8259,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Instellingen actualiseren? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Door de instellingen bij te werken, wordt de client opnieuw verbonden met alle servers. @@ -9822,6 +9840,14 @@ Verbindingsverzoek herhalen? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption quantum bestendige e2e-codering @@ -9837,6 +9863,10 @@ Verbindingsverzoek herhalen? bevestiging ontvangen… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call geweigerde oproep 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 87200f0c24..aa257a2322 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -8069,6 +8069,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Zaktualizować ustawienia? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Aktualizacja ustawień spowoduje ponowne połączenie klienta ze wszystkimi serwerami. @@ -9626,6 +9630,14 @@ Powtórzyć prośbę połączenia? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption kwantowo odporne szyfrowanie e2e @@ -9641,6 +9653,10 @@ Powtórzyć prośbę połączenia? otrzymano potwierdzenie… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call odrzucone połączenie 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 a7a8f1290a..567e36c53a 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -8142,6 +8142,10 @@ To connect, please ask your contact to create another connection link and check Обновить настройки? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами. @@ -9713,6 +9717,14 @@ Repeat connection request? peer-to-peer No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption квантово-устойчивое e2e шифрование @@ -9728,6 +9740,10 @@ Repeat connection request? получено подтверждение… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call отклонённый звонок 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 bfbfe297b2..05ebf4337d 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -7477,6 +7477,10 @@ To connect, please ask your contact to create another connection link and check Update settings? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. การอัปเดตการตั้งค่าจะเชื่อมต่อไคลเอนต์กับเซิร์ฟเวอร์ทั้งหมดอีกครั้ง @@ -8937,6 +8941,14 @@ Repeat connection request? เพื่อนต่อเพื่อน No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8951,6 +8963,10 @@ Repeat connection request? ได้รับการยืนยัน… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call สายถูกปฏิเสธ 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 de90854e24..9b832b61fc 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -8084,6 +8084,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Ayarları güncelleyelim mi? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Ayarların güncellenmesi, istemciyi tüm sunuculara yeniden bağlayacaktır. @@ -9640,6 +9644,14 @@ Bağlantı isteği tekrarlansın mı? eşler arası No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption kuantuma dayanıklı e2e şifreleme @@ -9655,6 +9667,10 @@ Bağlantı isteği tekrarlansın mı? onaylama alındı… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call geri çevrilmiş çağrı 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 61eb8d8206..bb9ae7d3cd 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -8134,6 +8134,10 @@ To connect, please ask your contact to create another connection link and check Оновити налаштування? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. Оновлення налаштувань призведе до перепідключення клієнта до всіх серверів. @@ -9705,6 +9709,14 @@ Repeat connection request? одноранговий No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption квантово-стійке шифрування e2e @@ -9720,6 +9732,10 @@ Repeat connection request? отримали підтвердження… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call відхилений виклик 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 c79f95ba8a..ef62814aff 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 @@ -7964,6 +7964,10 @@ To connect, please ask your contact to create another connection link and check 更新设置? No comment provided by engineer. + + Updated conditions + No comment provided by engineer. + Updating settings will re-connect the client to all servers. 更新设置会将客户端重新连接到所有服务器。 @@ -9514,6 +9518,14 @@ Repeat connection request? 点对点 No comment provided by engineer. + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + quantum resistant e2e encryption 抗量子端到端加密 @@ -9529,6 +9541,10 @@ Repeat connection request? 已受到确认…… No comment provided by engineer. + + rejected + No comment provided by engineer. + rejected call 拒接来电 diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 2b7338396a..acdc621e0c 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Hozzon létre egy profilt a SimpleX alkalmazásban"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegés-beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Az üzenet elküldése a vártnál tovább tart."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index c391c1882d..eb3238b43b 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -422,10 +422,10 @@ "Add profile" = "Profil hinzufügen"; /* No comment provided by engineer. */ -"Add server" = "Füge Server hinzu"; +"Add server" = "Server hinzufügen"; /* No comment provided by engineer. */ -"Add servers by scanning QR codes." = "Fügen Sie Server durch Scannen der QR Codes hinzu."; +"Add servers by scanning QR codes." = "Server durch Scannen von QR Codes hinzufügen."; /* No comment provided by engineer. */ "Add team members" = "Team-Mitglieder aufnehmen"; @@ -796,6 +796,9 @@ /* No comment provided by engineer. */ "Better groups" = "Bessere Gruppen"; +/* No comment provided by engineer. */ +"Better groups performance" = "Bessere Leistung von Gruppen"; + /* No comment provided by engineer. */ "Better message dates." = "Verbesserte Nachrichten-Datumsinformation"; @@ -808,6 +811,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Verbesserte Benachrichtigungen"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Bessere(r) Security und Datenschutz"; + /* No comment provided by engineer. */ "Better security ✅" = "Verbesserte Sicherheit ✅"; @@ -1892,6 +1898,9 @@ /* No comment provided by engineer. */ "Don't enable" = "Nicht aktivieren"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Verpassen Sie keine wichtigen Nachrichten."; + /* No comment provided by engineer. */ "Don't show again" = "Nicht nochmals anzeigen"; @@ -2403,9 +2412,15 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Schnell und ohne warten auf den Absender, bis er online ist!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Schnelleres löschen von Gruppen."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Schnelleres versenden von Nachrichten."; + /* swipe action */ "Favorite" = "Favorit"; @@ -2586,6 +2601,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Weiter reduzierter Batterieverbrauch"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Bei Erwähnung benachrichtigt werden."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIFs und Sticker"; @@ -2664,6 +2682,9 @@ /* No comment provided by engineer. */ "Help" = "Hilfe"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Helfen Sie Administratoren bei der Moderation ihrer Gruppen."; + /* No comment provided by engineer. */ "Hidden" = "Verborgen"; @@ -3198,6 +3219,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "Gruppenmitglieder können Sprachnachrichten versenden."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Erwähnung von Mitgliedern 👋"; + /* No comment provided by engineer. */ "Menus" = "Menüs"; @@ -3513,6 +3537,9 @@ /* servers error */ "No media & file servers." = "Keine Medien- und Dateiserver."; +/* No comment provided by engineer. */ +"No message" = "Keine Nachricht"; + /* servers error */ "No message servers." = "Keine Nachrichten-Server."; @@ -3743,6 +3770,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "Oder zum privaten Teilen"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Chats in Listen verwalten"; + /* No comment provided by engineer. */ "other" = "Andere"; @@ -3914,6 +3944,9 @@ /* No comment provided by engineer. */ "Private filenames" = "Neutrale Dateinamen"; +/* No comment provided by engineer. */ +"Private media file names." = "Medien mit anonymisierten Dateinamen."; + /* No comment provided by engineer. */ "Private message routing" = "Privates Nachrichten-Routing"; @@ -4516,6 +4549,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Benachrichtigungen senden"; +/* No comment provided by engineer. */ +"Send private reports" = "Private Meldungen senden"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Senden Sie Fragen und Ideen"; @@ -4663,6 +4699,9 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Anstelle der System-Authentifizierung festlegen."; +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Verfallsdatum von Nachrichten in Chats festlegen."; + /* profile update event chat item */ "set new contact address" = "Es wurde eine neue Kontaktadresse festgelegt"; @@ -5169,6 +5208,9 @@ /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Dieser Link wurde schon mit einem anderen Mobiltelefon genutzt. Bitte erstellen sie einen neuen Link in der Desktop-App."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Diese Nachricht wurde gelöscht oder bisher noch nicht empfangen."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 6e5d0e3d03..b70bf16ea8 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -796,6 +796,9 @@ /* No comment provided by engineer. */ "Better groups" = "Grupos mejorados"; +/* No comment provided by engineer. */ +"Better groups performance" = "Mejor rendimiento de los grupos"; + /* No comment provided by engineer. */ "Better message dates." = "Sistema de fechas mejorado."; @@ -808,6 +811,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Notificaciones mejoradas"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Mejor privacidad y seguridad"; + /* No comment provided by engineer. */ "Better security ✅" = "Seguridad mejorada ✅"; @@ -1892,6 +1898,9 @@ /* No comment provided by engineer. */ "Don't enable" = "No activar"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "No pierdas los mensajes importantes."; + /* No comment provided by engineer. */ "Don't show again" = "No volver a mostrar"; @@ -2403,9 +2412,15 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "¡Rápido y sin necesidad de esperar a que el remitente esté en línea!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Eliminación más rápida de grupos."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Mensajería más segura y conexión más rápida."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Envío más rápido de mensajes."; + /* swipe action */ "Favorite" = "Favoritos"; @@ -2586,6 +2601,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Reducción consumo de batería"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Las menciones ahora notifican."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIFs y stickers"; @@ -2664,6 +2682,9 @@ /* No comment provided by engineer. */ "Help" = "Ayuda"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Ayuda a los admins moderar sus grupos."; + /* No comment provided by engineer. */ "Hidden" = "Oculto"; @@ -3198,6 +3219,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "Los miembros del grupo pueden enviar mensajes de voz."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Mencionar miembros 👋"; + /* No comment provided by engineer. */ "Menus" = "Menus"; @@ -3513,6 +3537,9 @@ /* servers error */ "No media & file servers." = "Sin servidores para archivos y multimedia."; +/* No comment provided by engineer. */ +"No message" = "Ningún mensaje"; + /* servers error */ "No message servers." = "Sin servidores para mensajes."; @@ -3743,6 +3770,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "O para compartir en privado"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organiza los chats en listas"; + /* No comment provided by engineer. */ "other" = "otros"; @@ -3914,6 +3944,9 @@ /* No comment provided by engineer. */ "Private filenames" = "Nombres de archivos privados"; +/* No comment provided by engineer. */ +"Private media file names." = "Nombres privados de archivos de media."; + /* No comment provided by engineer. */ "Private message routing" = "Enrutamiento privado de mensajes"; @@ -4516,6 +4549,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Enviar notificaciones"; +/* No comment provided by engineer. */ +"Send private reports" = "Envía informes privados"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Consultas y sugerencias"; @@ -4663,6 +4699,9 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Úsalo en lugar de la autenticación del sistema."; +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Establece vencimiento de los mensajes en chats."; + /* profile update event chat item */ "set new contact address" = "nueva dirección de contacto"; @@ -5169,6 +5208,9 @@ /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "El mensaje ha sido borrado o aún no se ha recibido."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Esta configuración se aplica a los mensajes del perfil actual **%@**."; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index b926536f04..5a01b41d2e 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -20,7 +20,7 @@ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más!"; /* No comment provided by engineer. */ -"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- értesíti az ismerősöket a törlésről (nem kötelező)\n- profil nevek szóközökkel\n- és még sok más!"; +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- partnerek értesítése a törlésről (nem kötelező)\n- profilnevek szóközökkel\n- és még sok más!"; /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; @@ -65,7 +65,7 @@ "@%@" = "@%@"; /* No comment provided by engineer. */ -"**Create 1-time link**: to create and share a new invitation link." = "**Ismerős hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; +"**Create 1-time link**: to create and share a new invitation link." = "**Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; /* No comment provided by engineer. */ "**Create group**: to create a new group." = "**Csoport létrehozása:** új csoport létrehozásához."; @@ -77,13 +77,13 @@ "**e2e encrypted** video call" = "**e2e titkosított** videóhívás"; /* No comment provided by engineer. */ -"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van."; +"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van."; /* No comment provided by engineer. */ "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." = "**Legprivátabb:** ne használja a SimpleX Chat értesítési kiszolgálót, rendszeresen ellenőrizze az üzeneteket a háttérben (attól függően, hogy milyen gyakran használja az alkalmazást)."; /* No comment provided by engineer. */ -"**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését."; +"**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését."; /* No comment provided by engineer. */ "**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti."; @@ -224,7 +224,7 @@ "%lld %@" = "%lld %@"; /* No comment provided by engineer. */ -"%lld contact(s) selected" = "%lld ismerős kijelölve"; +"%lld contact(s) selected" = "%lld partner kijelölve"; /* No comment provided by engineer. */ "%lld file(s) with total size of %@" = "%lld fájl, %@ összméretben"; @@ -323,7 +323,7 @@ "1-time link" = "Egyszer használható meghívási hivatkozás"; /* No comment provided by engineer. */ -"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívási hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívási hivatkozás csak *egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltón keresztül megosztható."; /* No comment provided by engineer. */ "5 minutes" = "5 perc"; @@ -338,7 +338,7 @@ "A few more things" = "Néhány további dolog"; /* notification title */ -"A new contact" = "Egy új ismerős"; +"A new contact" = "Egy új partner"; /* No comment provided by engineer. */ "A new random profile will be shared." = "Egy új, véletlenszerű profil lesz megosztva."; @@ -347,7 +347,7 @@ "A separate TCP connection will be used **for each chat profile you have in the app**." = "**Az összes csevegési profiljához az alkalmazásban** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**Az összes ismerőséhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\n**Megjegyzés:** ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**Az összes partneréhez és csoporttaghoz** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\n**Megjegyzés:** ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet."; /* No comment provided by engineer. */ "Abort" = "Megszakítás"; @@ -368,7 +368,7 @@ "above, then choose:" = "gombra fent, majd válassza ki:"; /* No comment provided by engineer. */ -"Accent" = "Kiemelés"; +"Accent" = "Kiemelőszín"; /* accept contact request via notification accept incoming call via notification @@ -410,7 +410,7 @@ "Active connections" = "Aktív kapcsolatok száma"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára."; +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára."; /* No comment provided by engineer. */ "Add friends" = "Barátok hozzáadása"; @@ -449,13 +449,13 @@ "Added message servers" = "Hozzáadott üzenetkiszolgálók"; /* No comment provided by engineer. */ -"Additional accent" = "További kiemelés"; +"Additional accent" = "További kiemelőszín"; /* No comment provided by engineer. */ -"Additional accent 2" = "További kiemelés 2"; +"Additional accent 2" = "További kiemelőszín 2"; /* No comment provided by engineer. */ -"Additional secondary" = "További másodlagos"; +"Additional secondary" = "További másodlagos szín"; /* No comment provided by engineer. */ "Address" = "Cím"; @@ -536,34 +536,34 @@ "All reports will be archived for you." = "Az összes jelentés archiválva lesz az Ön számára."; /* No comment provided by engineer. */ -"All your contacts will remain connected." = "Az összes ismerősével kapcsolatban marad."; +"All your contacts will remain connected." = "Az összes partnerével kapcsolatban marad."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön partnerei számára."; /* No comment provided by engineer. */ -"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra."; +"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra."; /* No comment provided by engineer. */ "Allow" = "Engedélyezés"; /* No comment provided by engineer. */ -"Allow calls only if your contact allows them." = "A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; +"Allow calls only if your contact allows them." = "A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ "Allow calls?" = "Hívások engedélyezése?"; /* No comment provided by engineer. */ -"Allow disappearing messages only if your contact allows it to you." = "Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi az Ön számára."; +"Allow disappearing messages only if your contact allows it to you." = "Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi az Ön számára."; /* No comment provided by engineer. */ "Allow downgrade" = "Visszafejlesztés engedélyezése"; /* No comment provided by engineer. */ -"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra)"; +"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra)"; /* No comment provided by engineer. */ -"Allow message reactions only if your contact allows them." = "A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; +"Allow message reactions only if your contact allows them." = "A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ "Allow message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van."; @@ -581,7 +581,7 @@ "Allow to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra)"; /* No comment provided by engineer. */ -"Allow to report messsages to moderators." = "Lehetővé teszi az üzenetek jelentését a moderátorok felé."; +"Allow to report messsages to moderators." = "Az üzenetek jelentése a moderátorok felé engedélyezve van."; /* No comment provided by engineer. */ "Allow to send files and media." = "A fájlok- és a médiatartalmak küldése engedélyezve van."; @@ -593,25 +593,25 @@ "Allow to send voice messages." = "A hangüzenetek küldése engedélyezve van."; /* No comment provided by engineer. */ -"Allow voice messages only if your contact allows them." = "A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi."; +"Allow voice messages only if your contact allows them." = "A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ "Allow voice messages?" = "Hangüzenetek engedélyezése?"; /* No comment provided by engineer. */ -"Allow your contacts adding message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van az ismerősei számára."; +"Allow your contacts adding message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ -"Allow your contacts to call you." = "A hívások kezdeményezése engedélyezve van az ismerősei számára."; +"Allow your contacts to call you." = "A hívások kezdeményezése engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ -"Allow your contacts to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra)"; +"Allow your contacts to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra)"; /* No comment provided by engineer. */ -"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldésének engedélyezése az ismerősei számára."; +"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldésének engedélyezése a partnerei számára."; /* No comment provided by engineer. */ -"Allow your contacts to send voice messages." = "A hangüzenetek küldése engedélyezve van az ismerősei számára."; +"Allow your contacts to send voice messages." = "A hangüzenetek küldése engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ "Already connected?" = "Már kapcsolódott?"; @@ -629,7 +629,7 @@ "Always use private routing." = "Mindig használjon privát útválasztást."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig használjon továbbító-kiszolgálót"; +"Always use relay" = "Mindig használjon továbbítókiszolgálót"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; @@ -698,7 +698,7 @@ "Archive and upload" = "Archiválás és feltöltés"; /* No comment provided by engineer. */ -"Archive contacts to chat later." = "Az ismerősök archiválása a későbbi csevegéshez."; +"Archive contacts to chat later." = "A partnerek archiválása a későbbi csevegéshez."; /* No comment provided by engineer. */ "Archive report" = "Jelentés archiválása"; @@ -710,7 +710,7 @@ "Archive reports" = "Jelentések archiválása"; /* No comment provided by engineer. */ -"Archived contacts" = "Archivált ismerősök"; +"Archived contacts" = "Archivált partnerek"; /* No comment provided by engineer. */ "archived report" = "archivált jelentés"; @@ -796,6 +796,9 @@ /* No comment provided by engineer. */ "Better groups" = "Továbbfejlesztett csoportok"; +/* No comment provided by engineer. */ +"Better groups performance" = "Továbbfejlesztett, gyorsabb csoportok"; + /* No comment provided by engineer. */ "Better message dates." = "Továbbfejlesztett üzenetdátumok."; @@ -808,6 +811,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Továbbfejlesztett értesítések"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Továbbfejlesztett adatvédelem és biztonság"; + /* No comment provided by engineer. */ "Better security ✅" = "Továbbfejlesztett biztonság ✅"; @@ -911,16 +917,16 @@ "Camera not available" = "A kamera nem elérhető"; /* No comment provided by engineer. */ -"Can't call contact" = "Nem lehet felhívni az ismerőst"; +"Can't call contact" = "Nem lehet felhívni a partnert"; /* No comment provided by engineer. */ "Can't call member" = "Nem lehet felhívni a tagot"; /* No comment provided by engineer. */ -"Can't invite contact!" = "Nem lehet meghívni az ismerőst!"; +"Can't invite contact!" = "Nem lehet meghívni a partnert!"; /* No comment provided by engineer. */ -"Can't invite contacts!" = "Nem lehet meghívni az ismerősöket!"; +"Can't invite contacts!" = "Nem lehet meghívni a partnereket!"; /* No comment provided by engineer. */ "Can't message member" = "Nem lehet üzenetet küldeni a tagnak"; @@ -972,7 +978,7 @@ "Change passcode" = "Jelkód módosítása"; /* No comment provided by engineer. */ -"Change receiving address" = "A fogadási cím módosítása"; +"Change receiving address" = "Fogadási cím módosítása"; /* No comment provided by engineer. */ "Change receiving address?" = "Módosítja a fogadási címet?"; @@ -1039,16 +1045,16 @@ "Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt."; /* No comment provided by engineer. */ -"Chat list" = "Csevegőlista"; +"Chat list" = "Csevegési lista"; /* No comment provided by engineer. */ "Chat migrated!" = "A csevegés átköltöztetve!"; /* No comment provided by engineer. */ -"Chat preferences" = "Csevegés-beállítások"; +"Chat preferences" = "Csevegési beállítások"; /* alert message */ -"Chat preferences were changed." = "A csevegés-beállítások módosultak."; +"Chat preferences were changed." = "A csevegési beállítások módosultak."; /* No comment provided by engineer. */ "Chat profile" = "Csevegési profil"; @@ -1132,7 +1138,7 @@ "Compare file" = "Fájl-összehasonlítás"; /* No comment provided by engineer. */ -"Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása az ismerősökével."; +"Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása a partnerekével."; /* No comment provided by engineer. */ "complete" = "befejezett"; @@ -1174,7 +1180,7 @@ "Confirm" = "Megerősítés"; /* No comment provided by engineer. */ -"Confirm contact deletion?" = "Biztosan törli az ismerőst?"; +"Confirm contact deletion?" = "Biztosan törli a partnert?"; /* No comment provided by engineer. */ "Confirm database upgrades" = "Adatbázis fejlesztésének megerősítése"; @@ -1219,7 +1225,7 @@ "connect to SimpleX Chat developers." = "kapcsolódás a SimpleX Chat fejlesztőkhöz."; /* No comment provided by engineer. */ -"Connect to your friends faster." = "Kapcsolódjon gyorsabban az ismerőseihez."; +"Connect to your friends faster." = "Kapcsolódjon gyorsabban a partnereihez."; /* No comment provided by engineer. */ "Connect to yourself?" = "Kapcsolódás saját magához?"; @@ -1288,7 +1294,7 @@ "Connecting server… (error: %@)" = "Kapcsolódás a kiszolgálóhoz… (hiba: %@)"; /* No comment provided by engineer. */ -"Connecting to contact, please wait or check later!" = "Kapcsolódás az ismerőshöz, várjon vagy ellenőrizze később!"; +"Connecting to contact, please wait or check later!" = "Kapcsolódás a partnerhez, várjon vagy ellenőrizze később!"; /* No comment provided by engineer. */ "Connecting to desktop" = "Kapcsolódás a számítógéphez"; @@ -1351,43 +1357,43 @@ "contact %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; /* No comment provided by engineer. */ -"Contact allows" = "Ismerős engedélyezi"; +"Contact allows" = "Partner engedélyezi"; /* No comment provided by engineer. */ -"Contact already exists" = "Az ismerős már létezik"; +"Contact already exists" = "A partner már létezik"; /* No comment provided by engineer. */ -"Contact deleted!" = "Ismerős törölve!"; +"Contact deleted!" = "Partner törölve!"; /* No comment provided by engineer. */ -"contact has e2e encryption" = "az ismerős e2e titkosítással rendelkezik"; +"contact has e2e encryption" = "a partner e2e titkosítással rendelkezik"; /* No comment provided by engineer. */ -"contact has no e2e encryption" = "az ismerős nem rendelkezik e2e titkosítással"; +"contact has no e2e encryption" = "a partner nem rendelkezik e2e titkosítással"; /* notification */ "Contact hidden:" = "Rejtett név:"; /* notification */ -"Contact is connected" = "Ismerőse kapcsolódott"; +"Contact is connected" = "Partnere kapcsolódott"; /* No comment provided by engineer. */ -"Contact is deleted." = "Törölt ismerős."; +"Contact is deleted." = "Törölt partner."; /* No comment provided by engineer. */ "Contact name" = "Csak név"; /* No comment provided by engineer. */ -"Contact preferences" = "Ismerős-beállítások"; +"Contact preferences" = "Partnerbeállítások"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "Az ismerős törölve lesz – ez a művelet nem vonható vissza!"; +"Contact will be deleted - this cannot be undone!" = "A partner törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Contacts" = "Ismerősök"; +"Contacts" = "Partnerek"; /* No comment provided by engineer. */ -"Contacts can mark messages for deletion; you will be able to view them." = "Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat."; +"Contacts can mark messages for deletion; you will be able to view them." = "A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat."; /* blocking reason */ "Content violates conditions of use" = "A tartalom sérti a használati feltételeket"; @@ -1611,7 +1617,7 @@ "Delete all files" = "Az összes fájl törlése"; /* No comment provided by engineer. */ -"Delete and notify contact" = "Törlés, és az ismerős értesítése"; +"Delete and notify contact" = "Törlés, és a partner értesítése"; /* No comment provided by engineer. */ "Delete chat" = "Csevegés törlése"; @@ -1632,10 +1638,10 @@ "Delete connection" = "Kapcsolat törlése"; /* No comment provided by engineer. */ -"Delete contact" = "Ismerős törlése"; +"Delete contact" = "Partner törlése"; /* No comment provided by engineer. */ -"Delete contact?" = "Ismerős törlése?"; +"Delete contact?" = "Partner törlése?"; /* No comment provided by engineer. */ "Delete database" = "Adatbázis törlése"; @@ -1656,7 +1662,7 @@ "Delete for everyone" = "Törlés az összes tagnál"; /* No comment provided by engineer. */ -"Delete for me" = "Csak nálam"; +"Delete for me" = "Csak számomra"; /* No comment provided by engineer. */ "Delete group" = "Csoport törlése"; @@ -1698,7 +1704,7 @@ "Delete or moderate up to 200 messages." = "Legfeljebb 200 üzenet egyszerre való törlése, vagy moderálása."; /* No comment provided by engineer. */ -"Delete pending connection?" = "Függőben lévő ismerőskérelem törlése?"; +"Delete pending connection?" = "Függőben lévő partnerkérelem törlése?"; /* No comment provided by engineer. */ "Delete profile" = "Profil törlése"; @@ -1731,7 +1737,7 @@ "Deleted at: %@" = "Törölve: %@"; /* rcv direct event chat item */ -"deleted contact" = "törölt ismerős"; +"deleted contact" = "törölt partner"; /* rcv group event chat item */ "deleted group" = "törölt csoport"; @@ -1764,13 +1770,13 @@ "Desktop devices" = "Számítógépek"; /* No comment provided by engineer. */ -"Destination server address of %@ is incompatible with forwarding server %@ settings." = "A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbító kiszolgáló beállításaival."; +"Destination server address of %@ is incompatible with forwarding server %@ settings." = "A(z) %@ célkiszolgáló címe nem kompatibilis a(z) %@ továbbítókiszolgáló beállításaival."; /* snd error text */ "Destination server error: %@" = "Célkiszolgáló-hiba: %@"; /* No comment provided by engineer. */ -"Destination server version of %@ is incompatible with forwarding server %@." = "A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbító kiszolgálóval."; +"Destination server version of %@ is incompatible with forwarding server %@." = "A(z) %@ célkiszolgáló verziója nem kompatibilis a(z) %@ továbbítókiszolgálóval."; /* No comment provided by engineer. */ "Detailed statistics" = "Részletes statisztikák"; @@ -1892,6 +1898,9 @@ /* No comment provided by engineer. */ "Don't enable" = "Ne engedélyezze"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Ne maradjon le a fontos üzenetekről."; + /* No comment provided by engineer. */ "Don't show again" = "Ne mutasd újra"; @@ -1951,7 +1960,7 @@ "Edit" = "Szerkesztés"; /* No comment provided by engineer. */ -"Edit group profile" = "A csoport profiljának szerkesztése"; +"Edit group profile" = "Csoportprofil szerkesztése"; /* No comment provided by engineer. */ "Enable" = "Engedélyezés"; @@ -2008,7 +2017,7 @@ "Enabled for" = "Számukra engedélyezve"; /* enabled status */ -"enabled for contact" = "engedélyezve az ismerős számára"; +"enabled for contact" = "engedélyezve a partner számára"; /* enabled status */ "enabled for you" = "engedélyezve az Ön számára"; @@ -2161,7 +2170,7 @@ "Error checking token status" = "Hiba a token állapotának ellenőrzésekor"; /* No comment provided by engineer. */ -"Error connecting to forwarding server %@. Please try later." = "Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; +"Error connecting to forwarding server %@. Please try later." = "Hiba a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; /* No comment provided by engineer. */ "Error creating address" = "Hiba a cím létrehozásakor"; @@ -2176,7 +2185,7 @@ "Error creating list" = "Hiba a lista létrehozásakor"; /* No comment provided by engineer. */ -"Error creating member contact" = "Hiba az ismerőssel történő kapcsolat létrehozásában"; +"Error creating member contact" = "Hiba a partnerrel történő kapcsolat létrehozásában"; /* No comment provided by engineer. */ "Error creating message" = "Hiba az üzenet létrehozásakor"; @@ -2403,9 +2412,15 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Gyors és nem kell várni, amíg a feladó online lesz!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Gyorsabb csoporttörlés."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Gyorsabb üzenetküldés."; + /* swipe action */ "Favorite" = "Kedvenc"; @@ -2491,7 +2506,7 @@ "Fix encryption after restoring backups." = "Titkosítás javítása az adatmentések helyreállítása után."; /* No comment provided by engineer. */ -"Fix not supported by contact" = "Ismerős általi javítás nem támogatott"; +"Fix not supported by contact" = "Partner általi javítás nem támogatott"; /* No comment provided by engineer. */ "Fix not supported by group member" = "Csoporttag általi javítás nem támogatott"; @@ -2509,7 +2524,7 @@ "For console" = "Konzolhoz"; /* No comment provided by engineer. */ -"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni."; +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni."; /* No comment provided by engineer. */ "For me" = "Csak magamnak"; @@ -2551,19 +2566,19 @@ "Forwarding %lld messages" = "%lld üzenet továbbítása"; /* No comment provided by engineer. */ -"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %@ továbbító-kiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később."; +"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %@ továbbítókiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később."; /* No comment provided by engineer. */ -"Forwarding server address is incompatible with network settings: %@." = "A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@."; +"Forwarding server address is incompatible with network settings: %@." = "A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@."; /* No comment provided by engineer. */ -"Forwarding server version is incompatible with network settings: %@." = "A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@."; +"Forwarding server version is incompatible with network settings: %@." = "A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %@."; /* snd error text */ -"Forwarding server: %@\nDestination server error: %@" = "Továbbító-kiszolgáló: %1$@\nCélkiszolgáló-hiba: %2$@"; +"Forwarding server: %@\nDestination server error: %@" = "Továbbítókiszolgáló: %1$@\nCélkiszolgáló-hiba: %2$@"; /* snd error text */ -"Forwarding server: %@\nError: %@" = "Továbbító-kiszolgáló: %1$@\nHiba: %2$@"; +"Forwarding server: %@\nError: %@" = "Továbbítókiszolgáló: %1$@\nHiba: %2$@"; /* No comment provided by engineer. */ "Found desktop" = "Megtalált számítógép"; @@ -2586,6 +2601,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Tovább csökkentett akkumulátor-használat"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Kapjon értesítést, ha megemlítik."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF-ek és matricák"; @@ -2638,7 +2656,7 @@ "Group moderation" = "Csoport moderálása"; /* No comment provided by engineer. */ -"Group preferences" = "Csoport-beállítások"; +"Group preferences" = "Csoportbeállítások"; /* No comment provided by engineer. */ "Group profile" = "Csoportprofil"; @@ -2664,6 +2682,9 @@ /* No comment provided by engineer. */ "Help" = "Súgó"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Segítsen az adminisztrátoroknak a csoportjaik moderálásában."; + /* No comment provided by engineer. */ "Hidden" = "Se név, se üzenet"; @@ -2806,7 +2827,7 @@ "Incognito mode" = "Inkognitómód"; /* No comment provided by engineer. */ -"Incognito mode protects your privacy by using a new random profile for each contact." = "Az inkognitómód védi személyes adatait azáltal, hogy az összes ismerőséhez új, véletlenszerű profilt használ."; +"Incognito mode protects your privacy by using a new random profile for each contact." = "Az inkognitómód úgy védi a személyes adatait, hogy az összes partneréhez új, véletlenszerű profilt használ."; /* chat list item description */ "incognito via contact address link" = "inkognitó a kapcsolattartási címhivatkozáson keresztül"; @@ -2965,13 +2986,13 @@ "Irreversible message deletion is prohibited." = "Az üzenetek végleges törlése le van tiltva."; /* No comment provided by engineer. */ -"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Lehetővé teszi, hogy egyetlen csevegőprofilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük."; +"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 az ismerőse 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 régi adatbázis biztonsági mentést használt."; /* 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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy az ismerőse 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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt.\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."; @@ -3198,6 +3219,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "A tagok küldhetnek hangüzeneteket."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Tagok említése 👋"; + /* No comment provided by engineer. */ "Menus" = "Menük"; @@ -3367,7 +3391,7 @@ "Most likely this connection is deleted." = "Valószínűleg ez a kapcsolat törölve lett."; /* No comment provided by engineer. */ -"Multiple chat profiles" = "Több csevegőprofil"; +"Multiple chat profiles" = "Több csevegési profil"; /* notification label action */ "Mute" = "Némítás"; @@ -3481,10 +3505,10 @@ "No chats in list %@" = "Nincsenek csevegések a(z) %@ nevű listában"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs ismerős kijelölve"; +"No contacts selected" = "Nincs partner kijelölve"; /* No comment provided by engineer. */ -"No contacts to add" = "Nincs hozzáadandó ismerős"; +"No contacts to add" = "Nincs hozzáadandó partner"; /* No comment provided by engineer. */ "No delivery information" = "Nincs kézbesítési információ"; @@ -3513,6 +3537,9 @@ /* servers error */ "No media & file servers." = "Nincsenek média- és fájlkiszolgálók."; +/* No comment provided by engineer. */ +"No message" = "Nincs üzenet"; + /* servers error */ "No message servers." = "Nincsenek üzenet-kiszolgálók."; @@ -3630,16 +3657,16 @@ "Onion hosts will not be used." = "Az onion-kiszolgálók nem lesznek használva."; /* No comment provided by engineer. */ -"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a csevegés-beállításokat."; +"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat."; /* No comment provided by engineer. */ -"Only client devices store user profiles, contacts, groups, and messages." = "A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül."; +"Only client devices store user profiles, contacts, groups, and messages." = "A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül."; /* No comment provided by engineer. */ "Only delete conversation" = "Csak a beszélgetés törlése"; /* No comment provided by engineer. */ -"Only group owners can change group preferences." = "Csak a csoport tulajdonosai módosíthatják a csoport-beállításokat."; +"Only group owners can change group preferences." = "Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat."; /* No comment provided by engineer. */ "Only group owners can enable files and media." = "Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését."; @@ -3657,7 +3684,7 @@ "Only you can add message reactions." = "Csak Ön adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti meg őket ). (24 óra)"; +"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra)"; /* No comment provided by engineer. */ "Only you can make calls." = "Csak Ön tud hívásokat indítani."; @@ -3669,19 +3696,19 @@ "Only you can send voice messages." = "Csak Ön tud hangüzeneteket küldeni."; /* No comment provided by engineer. */ -"Only your contact can add message reactions." = "Csak az ismerőse adhat hozzá reakciókat az üzenetekhez."; +"Only your contact can add message reactions." = "Csak a partnere adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; +"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; /* No comment provided by engineer. */ -"Only your contact can make calls." = "Csak az ismerőse tud hívást indítani."; +"Only your contact can make calls." = "Csak a partnere tud hívást indítani."; /* No comment provided by engineer. */ -"Only your contact can send disappearing messages." = "Csak az ismerőse tud eltűnő üzeneteket küldeni."; +"Only your contact can send disappearing messages." = "Csak a partnere tud eltűnő üzeneteket küldeni."; /* No comment provided by engineer. */ -"Only your contact can send voice messages." = "Csak az ismerőse tud hangüzeneteket küldeni."; +"Only your contact can send voice messages." = "Csak a partnere tud hangüzeneteket küldeni."; /* No comment provided by engineer. */ "Open" = "Megnyitás"; @@ -3743,6 +3770,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "Vagy a privát megosztáshoz"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Csevegések listákba szervezése"; + /* No comment provided by engineer. */ "other" = "egyéb"; @@ -3822,22 +3852,22 @@ "Play from the chat list." = "Lejátszás a csevegési listából."; /* No comment provided by engineer. */ -"Please ask your contact to enable calls." = "Kérje meg az ismerősét, hogy engedélyezze a hívásokat."; +"Please ask your contact to enable calls." = "Kérje meg a partnerét, hogy engedélyezze a hívásokat."; /* No comment provided by engineer. */ -"Please ask your contact to enable sending voice messages." = "Kérje meg az ismerősét, hogy engedélyezze a hangüzenetek küldését."; +"Please ask your contact to enable sending voice messages." = "Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését."; /* No comment provided by engineer. */ "Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers." = "Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel."; /* No comment provided by engineer. */ -"Please check that you used the correct link or ask your contact to send you another one." = "Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat."; +"Please check that you used the correct link or ask your contact to send you another one." = "Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat."; /* No comment provided by engineer. */ "Please check your network connection with %@ and try again." = "Ellenőrizze a hálózati kapcsolatát a következővel: %@, és próbálja újra."; /* No comment provided by engineer. */ -"Please check yours and your contact preferences." = "Ellenőrizze a saját- és az ismerőse beállításait."; +"Please check yours and your contact preferences." = "Ellenőrizze a saját- és a partnere beállításait."; /* No comment provided by engineer. */ "Please confirm that network settings are correct for this device." = "Ellenőrizze, hogy a hálózati beállítások megfelelők-e ehhez az eszközhöz."; @@ -3914,6 +3944,9 @@ /* No comment provided by engineer. */ "Private filenames" = "Privát fájlnevek"; +/* No comment provided by engineer. */ +"Private media file names." = "Privát nevek a médiafájlokhoz."; + /* No comment provided by engineer. */ "Private message routing" = "Privát üzenet-útválasztás"; @@ -3945,7 +3978,7 @@ "Profile theme" = "Profiltéma"; /* alert message */ -"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve az Ön ismerősei számára."; +"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve az Ön partnerei számára."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "A hívások kezdeményezése le van tiltva."; @@ -3987,7 +4020,7 @@ "Protect your chat profiles with a password!" = "Védje meg a csevegési profiljait egy jelszóval!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben."; /* No comment provided by engineer. */ "Protocol timeout" = "Protokoll időtúllépése"; @@ -4147,10 +4180,10 @@ "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ímet."; /* 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 IP-címet, 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 Ön IP-címét, de megfigyelheti a hívás időtartamát."; /* No comment provided by engineer. */ "Remove" = "Eltávolítás"; @@ -4328,10 +4361,10 @@ "Save" = "Mentés"; /* alert button */ -"Save (and notify contacts)" = "Mentés és az ismerősök értesítése"; +"Save (and notify contacts)" = "Mentés (és a partnerek értesítése)"; /* alert button */ -"Save and notify contact" = "Mentés és az ismerős értesítése"; +"Save and notify contact" = "Mentés és a partner értesítése"; /* No comment provided by engineer. */ "Save and notify group members" = "Mentés és a csoporttagok értesítése"; @@ -4409,7 +4442,7 @@ "Scan QR code from desktop" = "QR-kód beolvasása számítógépről"; /* No comment provided by engineer. */ -"Scan security code from your contact's app." = "Biztonsági kód beolvasása az ismerősének alkalmazásából."; +"Scan security code from your contact's app." = "Biztonsági kód beolvasása a partnere alkalmazásából."; /* No comment provided by engineer. */ "Scan server QR code" = "A kiszolgáló QR-kódjának beolvasása"; @@ -4430,7 +4463,7 @@ "sec" = "mp"; /* No comment provided by engineer. */ -"Secondary" = "Másodlagos"; +"Secondary" = "Másodlagos szín"; /* time unit */ "seconds" = "másodperc"; @@ -4463,7 +4496,7 @@ "Selected %lld" = "%lld kijelölve"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegés-beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; @@ -4516,6 +4549,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Értesítések küldése"; +/* No comment provided by engineer. */ +"Send private reports" = "Privát jelentések küldése"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Ötletek és javaslatok"; @@ -4535,22 +4571,22 @@ "Sender may have deleted the connection request." = "A küldő törölhette a kapcsolatkérést."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára."; +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz."; +"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz."; /* No comment provided by engineer. */ "Sending file will be stopped." = "A fájl küldése le fog állni."; /* No comment provided by engineer. */ -"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld ismerősnél"; +"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partnernél"; /* No comment provided by engineer. */ "Sending receipts is disabled for %lld groups" = "A kézbesítési jelentések le vannak tiltva %lld csoportban"; /* No comment provided by engineer. */ -"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld ismerősnél"; +"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partnernél"; /* No comment provided by engineer. */ "Sending receipts is enabled for %lld groups" = "A kézbesítési jelentések engedélyezve vannak %lld csoportban"; @@ -4652,17 +4688,20 @@ "Set chat name…" = "Csevegés nevének beállítása…"; /* No comment provided by engineer. */ -"Set contact name…" = "Ismerős nevének beállítása…"; +"Set contact name…" = "Partner nevének beállítása…"; /* No comment provided by engineer. */ "Set default theme" = "Alapértelmezett téma beállítása"; /* No comment provided by engineer. */ -"Set group preferences" = "Csoport-beállítások megadása"; +"Set group preferences" = "Csoportbeállítások megadása"; /* 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 message expiration in chats." = "Üzenetek eltűnési idejének módosítása a csevegésekben."; + /* profile update event chat item */ "set new contact address" = "új kapcsolattartási címet állított be"; @@ -4710,7 +4749,7 @@ "Share address publicly" = "Cím nyilvános megosztása"; /* alert title */ -"Share address with contacts?" = "Megosztja a címet az ismerőseivel?"; +"Share address with contacts?" = "Megosztja a címet a partnereivel?"; /* No comment provided by engineer. */ "Share from other apps." = "Megosztás más alkalmazásokból."; @@ -4731,7 +4770,7 @@ "Share to SimpleX" = "Megosztás a SimpleXben"; /* No comment provided by engineer. */ -"Share with contacts" = "Megosztás az ismerősökkel"; +"Share with contacts" = "Megosztás a partnerekkel"; /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Egy „→” jel megjelenítése a privát útválasztáson keresztül küldött üzeneteknél."; @@ -4770,7 +4809,7 @@ "SimpleX Address" = "SimpleX-cím"; /* No comment provided by engineer. */ -"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül."; +"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetváltón keresztül."; /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívási hivatkozás?"; @@ -5047,13 +5086,13 @@ "The code you scanned is not a SimpleX link QR code." = "A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás."; /* No comment provided by engineer. */ -"The connection reached the limit of undelivered messages, your contact may be offline." = "A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van."; +"The connection reached the limit of undelivered messages, your contact may be offline." = "A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön partnere lehet, hogy offline állapotban van."; /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "Az Ön által elfogadott kérelem vissza lesz vonva!"; /* No comment provided by engineer. */ -"The contact you shared this link with will NOT be able to connect!" = "Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni!"; +"The contact you shared this link with will NOT be able to connect!" = "A partnere, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni!"; /* No comment provided by engineer. */ "The created archive is available via app Settings / Database / Old database archive." = "A létrehozott archívum a „Beállítások / Adatbázis / Régi adatbázis-archívum” menüben érhető el."; @@ -5086,7 +5125,7 @@ "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. */ -"The profile is only shared with your contacts." = "A profilja csak az ismerőseivel van megosztva."; +"The profile is 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: **%@**."; @@ -5104,10 +5143,10 @@ "The sender will NOT be notified" = "A feladó NEM fog értesítést kapni"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegő-profiljához tartozó új kapcsolatok kiszolgálói."; +"The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegési profiljához tartozó új kapcsolatok kiszolgálói."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegő-profiljához tartozó új fájlok kiszolgálói."; +"The servers for new files of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegési profiljához tartozó új fájlok kiszolgálói."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "A beillesztett szöveg nem egy SimpleX-hivatkozás."; @@ -5122,10 +5161,10 @@ "These conditions will also apply for: **%@**." = "Ezek a feltételek lesznek elfogadva a következő számára is: **%@**."; /* No comment provided by engineer. */ -"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi **%@** nevű csevegő-profiljára vonatkoznak."; +"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi **%@** nevű csevegési profiljára vonatkoznak."; /* No comment provided by engineer. */ -"They can be overridden in contact and group settings." = "Ezek felülbírálhatók az ismerős- és csoport-beállításokban."; +"They can be overridden in contact and group settings." = "Ezek felülbírálhatók a partner- és csoportbeállításokban."; /* No comment provided by engineer. */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; @@ -5137,7 +5176,7 @@ "This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek."; /* E2EE info chat item */ "This chat is protected by end-to-end encryption." = "Ez a csevegés végpontok közötti titkosítással védett."; @@ -5146,7 +5185,7 @@ "This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumálló titkosítással védett."; /* notification title */ -"this contact" = "ez az ismerős"; +"this contact" = "ez a partner"; /* No comment provided by engineer. */ "This device name" = "Ennek az eszköznek a neve"; @@ -5170,7 +5209,10 @@ "This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén."; /* No comment provided by engineer. */ -"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegő-profiljában lévő üzenetekre vonatkozik."; +"This message was deleted or not received yet." = "Ez az üzenet törölve lett vagy még nem érkezett meg."; + +/* No comment provided by engineer. */ +"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegési profiljában lévő üzenetekre vonatkozik."; /* No comment provided by engineer. */ "Title" = "Cím"; @@ -5179,7 +5221,7 @@ "To ask any questions and to receive updates:" = "Bármilyen kérdés feltevéséhez és a frissítésekért:"; /* No comment provided by engineer. */ -"To connect, your contact can scan QR code or use the link in the app." = "A kapcsolódáshoz az ismerőse beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást."; +"To connect, your contact can scan QR code or use the link in the app." = "A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást."; /* No comment provided by engineer. */ "To hide unwanted messages." = "Kéretlen üzenetek elrejtése."; @@ -5188,7 +5230,7 @@ "To make a new connection" = "Új kapcsolat létrehozásához"; /* No comment provided by engineer. */ -"To protect against your link being replaced, you can compare contact security codes." = "A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat az ismerősével."; +"To protect against your link being replaced, you can compare contact security codes." = "A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével."; /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak."; @@ -5227,10 +5269,10 @@ "To use the servers of **%@**, accept conditions of use." = "A(z) **%@** kiszolgálóinak használatához fogadja el a használati feltételeket."; /* No comment provided by engineer. */ -"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal."; +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; /* No comment provided by engineer. */ -"Toggle chat list:" = "Csevegőlista átváltása:"; +"Toggle chat list:" = "Csevegési lista átváltása:"; /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Inkognitóra váltás kapcsolódáskor."; @@ -5251,10 +5293,10 @@ "Transport sessions" = "Munkamenetek átvitele"; /* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %@)."; +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@)."; /* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál."; +"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "Turkish interface" = "Török kezelőfelület"; @@ -5332,7 +5374,7 @@ "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Hacsak nem az iOS hívási felületét használja, engedélyezze a Ne zavarjanak módot a megszakítások elkerülése érdekében."; /* No comment provided by engineer. */ -"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e."; +"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e."; /* No comment provided by engineer. */ "Unlink" = "Szétkapcsolás"; @@ -5512,7 +5554,7 @@ "via one-time link" = "egy egyszer használható meghívási hivatkozáson keresztül"; /* No comment provided by engineer. */ -"via relay" = "egy továbbító-kiszolgálón keresztül"; +"via relay" = "egy továbbítókiszolgálón keresztül"; /* No comment provided by engineer. */ "Via secure quantum resistant protocol." = "Biztonságos kvantumálló-protokollon keresztül."; @@ -5650,10 +5692,10 @@ "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 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 Ön IP-címe látható lesz 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 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 Ön IP-címe látható lesz 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"; @@ -5719,7 +5761,7 @@ "You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nCsatlakozáskérés megismétlése?"; /* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál."; +"You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "you are invited to group" = "Ön meghívást kapott a csoportba"; @@ -5764,16 +5806,16 @@ "You can hide or mute a user profile - swipe it to the right." = "Elrejtheti vagy lenémíthatja a felhasználó -profiljait – csúsztassa jobbra a profilt."; /* No comment provided by engineer. */ -"You can make it visible to your SimpleX contacts via Settings." = "Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”."; +"You can make it visible to your SimpleX contacts via Settings." = "Láthatóvá teheti a SimpleXbeli partnerei számára a „Beállításokban”."; /* notification body */ "You can now chat with %@" = "Mostantól küldhet üzeneteket %@ számára"; /* No comment provided by engineer. */ -"You can send messages to %@ from Archived contacts." = "Az „Archivált ismerősökből” továbbra is küldhet üzeneteket neki: %@."; +"You can send messages to %@ from Archived contacts." = "Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %@."; /* No comment provided by engineer. */ -"You can set connection name, to remember who the link was shared with." = "Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást."; +"You can set connection name, to remember who the link was shared with." = "Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást."; /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "A lezárási képernyő értesítési előnézetét az „Értesítések” menüben állíthatja be."; @@ -5782,13 +5824,13 @@ "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." = "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."; /* No comment provided by engineer. */ -"You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: **%@**."; +"You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: **%@**."; /* No comment provided by engineer. */ "You can start chat via app Settings / Database or by restarting the app" = "A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el"; /* No comment provided by engineer. */ -"You can still view conversation with %@ in the list of chats." = "A(z) %@ nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában."; +"You can still view conversation with %@ in the list of chats." = "A(z) %@ nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában."; /* No comment provided by engineer. */ "You can turn on SimpleX Lock via Settings." = "A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be."; @@ -5830,7 +5872,7 @@ "You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva."; /* No comment provided by engineer. */ -"You invited a contact" = "Ön meghívta egy ismerősét"; +"You invited a contact" = "Ön meghívta egy partnerét"; /* No comment provided by engineer. */ "You joined this group" = "Ön csatlakozott ehhez a csoporthoz"; @@ -5848,13 +5890,13 @@ "You may save the exported archive." = "Az exportált archívumot elmentheti."; /* No comment provided by engineer. */ -"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől."; +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől."; /* No comment provided by engineer. */ -"You need to allow your contact to call to be able to call them." = "Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást."; +"You need to allow your contact to call to be able to call them." = "Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást."; /* No comment provided by engineer. */ -"You need to allow your contact to send voice messages to be able to send them." = "Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak."; +"You need to allow your contact to send voice messages to be able to send them." = "Engedélyeznie kell a hangüzenetek küldését a partnere számára, hogy hangüzeneteket küldhessenek egymásnak."; /* No comment provided by engineer. */ "You rejected group invitation" = "Csoportmeghívó elutasítva"; @@ -5887,7 +5929,7 @@ "You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később!"; +"You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség."; @@ -5905,13 +5947,13 @@ "You will stop receiving messages from this group. Chat history will be preserved." = "Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak."; /* No comment provided by engineer. */ -"You won't lose your contacts if you later delete your address." = "Nem veszíti el az ismerőseit, ha később törli a címét."; +"You won't lose your contacts if you later delete your address." = "Nem veszíti el a partnereit, ha később törli a címét."; /* No comment provided by engineer. */ "you: " = "Ön: "; /* No comment provided by engineer. */ -"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban"; +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban"; /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "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"; @@ -5926,7 +5968,7 @@ "Your chat database is not encrypted - set passphrase to encrypt it." = "A csevegési adatbázis nincs titkosítva – adjon meg egy jelmondatot a titkosításhoz."; /* alert title */ -"Your chat preferences" = "Az Ön csevegés-beállításai"; +"Your chat preferences" = "Az Ön csevegési beállításai"; /* No comment provided by engineer. */ "Your chat profiles" = "Csevegési profilok"; @@ -5935,13 +5977,13 @@ "Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." = "A kapcsolata át lett helyezve ide: %@, de egy váratlan hiba történt a profilra való átirányításkor."; /* No comment provided by engineer. */ -"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Az ismerőse a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött."; +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött."; /* No comment provided by engineer. */ -"Your contacts can allow full message deletion." = "Az ismerősei engedélyezhetik a teljes üzenet törlést."; +"Your contacts can allow full message deletion." = "A partnerei engedélyezhetik a teljes üzenet törlését."; /* No comment provided by engineer. */ -"Your contacts will remain connected." = "Az ismerősei továbbra is kapcsolódva maradnak."; +"Your contacts will remain connected." = "A partnerei továbbra is kapcsolódva maradnak."; /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "A hitelesítőadatai titkosítatlanul is elküldhetők."; @@ -5968,13 +6010,13 @@ "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 shared only with your contacts. SimpleX servers cannot see your profile." = "A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját."; +"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 az Ön ismerősei számára."; +"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 az Ön partnerei számára."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva."; +"Your profile, contacts and delivered messages are stored on your device." = "A profilja, a partnerei és az elküldött üzenetei az Ön eszközén vannak tárolva."; /* No comment provided by engineer. */ "Your random profile" = "Véletlenszerű profil"; diff --git a/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings index 434f906b4e..f389e41458 100644 --- a/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/hu.lproj/SimpleX--iOS--InfoPlist.strings @@ -8,7 +8,7 @@ "NSFaceIDUsageDescription" = "A SimpleX Face ID-t használ a helyi hitelesítéshez"; /* Privacy - Local Network Usage Description */ -"NSLocalNetworkUsageDescription" = "A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegőprofil használatát számítógépen keresztül ugyanazon a hálózaton."; +"NSLocalNetworkUsageDescription" = "A SimpleX helyi hálózati hozzáférést használ, hogy lehetővé tegye a felhasználói csevegési profil használatát számítógépen keresztül ugyanazon a hálózaton."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "A SimpleXnek mikrofon-hozzáférésre van szüksége hang- és videohívásokhoz, valamint hangüzenetek rögzítéséhez."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 98854fefac..586ddd1ffa 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -796,6 +796,9 @@ /* No comment provided by engineer. */ "Better groups" = "Gruppi migliorati"; +/* No comment provided by engineer. */ +"Better groups performance" = "Prestazioni dei gruppi migliorate"; + /* No comment provided by engineer. */ "Better message dates." = "Date dei messaggi migliorate."; @@ -808,6 +811,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Notifiche migliorate"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Privacy e sicurezza migliorate"; + /* No comment provided by engineer. */ "Better security ✅" = "Sicurezza migliorata ✅"; @@ -1892,6 +1898,9 @@ /* No comment provided by engineer. */ "Don't enable" = "Non attivare"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Non perdere messaggi importanti."; + /* No comment provided by engineer. */ "Don't show again" = "Non mostrare più"; @@ -2403,9 +2412,15 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Veloce e senza aspettare che il mittente sia in linea!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Eliminazione dei gruppi più veloce."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Ingresso più veloce e messaggi più affidabili."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Invio dei messaggi più veloce."; + /* swipe action */ "Favorite" = "Preferito"; @@ -2586,6 +2601,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Ulteriore riduzione del consumo della batteria"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Ricevi una notifica quando menzionato."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF e adesivi"; @@ -2664,6 +2682,9 @@ /* No comment provided by engineer. */ "Help" = "Aiuto"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Aiuta gli amministratori a moderare i loro gruppi."; + /* No comment provided by engineer. */ "Hidden" = "Nascosta"; @@ -3198,6 +3219,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "I membri del gruppo possono inviare messaggi vocali."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Menziona i membri 👋"; + /* No comment provided by engineer. */ "Menus" = "Menu"; @@ -3513,6 +3537,9 @@ /* servers error */ "No media & file servers." = "Nessun server di multimediali e file."; +/* No comment provided by engineer. */ +"No message" = "Nessun messaggio"; + /* servers error */ "No message servers." = "Nessun server dei messaggi."; @@ -3743,6 +3770,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "O per condividere in modo privato"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organizza le chat in elenchi"; + /* No comment provided by engineer. */ "other" = "altro"; @@ -3914,6 +3944,9 @@ /* No comment provided by engineer. */ "Private filenames" = "Nomi di file privati"; +/* No comment provided by engineer. */ +"Private media file names." = "Nomi privati dei file multimediali."; + /* No comment provided by engineer. */ "Private message routing" = "Instradamento privato dei messaggi"; @@ -4516,6 +4549,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Invia notifiche"; +/* No comment provided by engineer. */ +"Send private reports" = "Invia segnalazioni private"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Invia domande e idee"; @@ -4663,6 +4699,9 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Impostalo al posto dell'autenticazione di sistema."; +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Imposta la scadenza dei messaggi nelle chat."; + /* profile update event chat item */ "set new contact address" = "impostato nuovo indirizzo di contatto"; @@ -5169,6 +5208,9 @@ /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Questo link è stato usato con un altro dispositivo mobile, creane uno nuovo sul desktop."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Questo messaggio è stato eliminato o non ancora ricevuto."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**."; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 587074589c..1b2ab30025 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -796,6 +796,9 @@ /* No comment provided by engineer. */ "Better groups" = "Betere groepen"; +/* No comment provided by engineer. */ +"Better groups performance" = "Betere prestaties van groepen"; + /* No comment provided by engineer. */ "Better message dates." = "Betere datums voor berichten."; @@ -808,6 +811,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Betere meldingen"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Betere privacy en veiligheid"; + /* No comment provided by engineer. */ "Better security ✅" = "Betere beveiliging ✅"; @@ -1892,6 +1898,9 @@ /* No comment provided by engineer. */ "Don't enable" = "Niet inschakelen"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "‐Mis geen belangrijke berichten."; + /* No comment provided by engineer. */ "Don't show again" = "Niet meer weergeven"; @@ -2403,9 +2412,15 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Snel en niet wachten tot de afzender online is!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Sneller verwijderen van groepen."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Snellere deelname en betrouwbaardere berichten."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Sneller verzenden van berichten."; + /* swipe action */ "Favorite" = "Favoriet"; @@ -2586,6 +2601,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Verder verminderd batterij verbruik"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Ontvang een melding als u vermeld wordt."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF's en stickers"; @@ -2664,6 +2682,9 @@ /* No comment provided by engineer. */ "Help" = "Help"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Help beheerders bij het modereren van hun groepen."; + /* No comment provided by engineer. */ "Hidden" = "Verborgen"; @@ -3198,6 +3219,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "Groepsleden kunnen spraak berichten verzenden."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Vermeld leden 👋"; + /* No comment provided by engineer. */ "Menus" = "Menu's"; @@ -3513,6 +3537,9 @@ /* servers error */ "No media & file servers." = "Geen media- en bestandsservers."; +/* No comment provided by engineer. */ +"No message" = "Geen bericht"; + /* servers error */ "No message servers." = "Geen berichtenservers."; @@ -3743,6 +3770,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "Of om privé te delen"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organiseer chats in lijsten"; + /* No comment provided by engineer. */ "other" = "overig"; @@ -3914,6 +3944,9 @@ /* No comment provided by engineer. */ "Private filenames" = "Privé bestandsnamen"; +/* No comment provided by engineer. */ +"Private media file names." = "Namen van persoonlijke mediabestanden."; + /* No comment provided by engineer. */ "Private message routing" = "Routering van privéberichten"; @@ -4516,6 +4549,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Meldingen verzenden"; +/* No comment provided by engineer. */ +"Send private reports" = "Verstuur rapporten privé"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Stuur vragen en ideeën"; @@ -4663,6 +4699,9 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Stel het in in plaats van systeemverificatie."; +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Stel de berichtvervaldatum in chats in."; + /* profile update event chat item */ "set new contact address" = "nieuw contactadres instellen"; @@ -5169,6 +5208,9 @@ /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Deze link is gebruikt met een ander mobiel apparaat. Maak een nieuwe link op de desktop."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Dit bericht is verwijderd of nog niet ontvangen."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Deze instelling is van toepassing op berichten in je huidige chatprofiel **%@**."; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 28b2398018..3a9d72b40e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -56,7 +56,7 @@ السماح بإرسال رسائل صوتية. تطبيق ثانوي إضافي - السماح لجهات اتصالك بإضافة ردود الفعل الرسالة. + السماح لجهات اتصالك بإضافة ردود الفعل للرسالة. السماح لجهات اتصالك بالاتصال بك. السماح بردود الفعل على الرسائل. يتم مسح جميع البيانات عند إدخالها. @@ -68,7 +68,7 @@ يمكن للتطبيق استلام الإشعارات فقط عند تشغيله، ولن يتم بدء تشغيل أي خدمة في الخلفية السماح بالرسائل الصوتية؟ ستبقى جميع جهات اتصالك متصلة. - استخدم التتابع دائمًا + استخدم الموجه دائمًا النسخ الاحتياطي لبيانات التطبيق حُذفت جميع بيانات التطبيق. السماح بحذف الرسائل المرسلة بشكل لا رجعة فيه. (24 ساعة) @@ -286,7 +286,7 @@ الاتصال %1$d انشأت الاتصال مكالمة جارية… - عَمِّ + تشفير أدخل عبارة المرور… المنشئ خطأ في إضافة الأعضاء @@ -1003,7 +1003,7 @@ تغيّرت رمز المرور للتدمير الذاتي! فعّلت رمز المرور للتدمير الذاتي! الإعدادات - دعوة لمرة واحدة SimpleX + دعوة SimpleX لمرة واحدة عرض جهة الاتصال والرسالة قفل SimpleX لم يتحقق من %s @@ -1030,7 +1030,7 @@ إيقاف عرض المعاينة السماعة متوقفة - وضع قفل SimpleX + SimpleX وضع القفل مشاركة الرابط الرسائل التي تم تخطيها عنوان SimpleX @@ -1612,7 +1612,7 @@ يمكن للمُدراء حظر عضو للجميع. ترحيل بيانات التطبيق جارِ أرشفة قاعدة البيانات - سيتم تعمية جميع جهات الاتصال والمحادثات والملفات الخاصة بك بشكل آمن ورفعها في أجزاء إلى مُرحلات XFTP التي ضُبطت. + جميع جهات الاتصال، المحادثات والملفات الخاصة بك سيتم تشفيرها بأمان ورفعها على شكل أجزاء إلى موجهات XFTP المُعدة. طبّق يُرجى ملاحظة: استخدام نفس قاعدة البيانات على جهازين سيؤدي إلى كسر فك تعمية الرسائل من اتصالاتك، كحماية أمنية.]]> تحذير: سيتم حذف الأرشيف.]]> @@ -1735,7 +1735,7 @@ إدارة الشبكة اتصال شبكة أكثر موثوقية. صور ملف التعريف - شكل الصور التعريفية + شكّل الصور التعريفية واجهة المستخدم الليتوانية مربع أو دائرة أو أي شيء بينهما. عنوان الخادم غير متوافق مع إعدادات الشبكة. @@ -2119,7 +2119,7 @@ يمكنك تضبيط المُشغلين في إعدادات الشبكة والخوادم. حدّث تابع - قُبل الشروط + الشروط المتفق عليها راجع الشروط الخوادم المُعدة مسبقًا سيتم قبول الشروط تلقائيًا للمُشغلين المفعّلين في: %s. @@ -2346,4 +2346,6 @@ أداء مجموعات أفضل خصوصية وأمان أفضل إرسال أسرع للرسائل. + رُفض + رُفض diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index de4042b8f5..0d071e4cce 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -2323,4 +2323,6 @@ Millor rendiment dels grups Supressió més ràpida de grups. Ajudar els administradors a moderar els seus grups. + rebutjat + rebutjat diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 75ecc38515..19e93e6973 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -72,10 +72,10 @@ Nevratně mazat zprávy můžete pouze vy (váš kontakt je může označit ke smazání). (24 hodin) Nevratné mazání zpráv je v tomto chatu zakázáno. Přímé zprávy členům zakázány. - %d sec + %d s %ds %d min - %d hodinu + %d hodina nabízeno %s: %2s Odkazy na skupiny Hlasové zprávy @@ -208,7 +208,7 @@ Požadavek na připojení byl odeslán! Jednorázová pozvánka Bezpečnostní kód - %s je ověřeno + %s ověřen Chat konzole SMP servery Přednastavená adresa serveru @@ -243,7 +243,7 @@ Decentralizovaná Jak to funguje Jak funguje SimpleX - 2 vrstvého koncového šifrování.]]> + Pouze klientská zařízení ukládají uživatelské profily, kontakty, skupiny a zprávy. Soukromé oznámení Pravidelné Ignorovat @@ -320,7 +320,7 @@ Zabezpečit frontu Okamžitá oznámení! V nastavení ji lze vypnout - oznámení se budou zobrazovat pokud aplikace běží.]]> - povolte pro SimpleX běh na pozadí v dalším dialogu. Jinak budou oznámení vypnuta.]]> + Povolte v příštím dotazu okamžité přijímání notifikací.]]> Aplikace pravidelně načítá nové zprávy - denně spotřebuje několik procent baterie. Aplikace nepoužívá push oznámení - data ze zařízení nejsou odesílána na servery. Je vyžadována přístupová fráze Chcete-li dostávat oznámení, zadejte přístupovou frázi do databáze. @@ -343,8 +343,8 @@ volání… připojen ukončen - Příští generace soukromé komunikace - Lidé se s vámi mohou spojit pouze prostřednictvím odkazu, který sdílíte. + Budoucnost soukromé komunikace + Rozhodněte, kdo se může připojit. špatný kontrolní součet zprávy Databáze chatu importována Nová přístupová fráze… @@ -384,7 +384,7 @@ Smazat adresu Celé jméno: Váš současný profil - SimpleX službu na pozadí - denně využije několik procent baterie.]]> + SimpleX běží na pozadí místo používání oznámení.]]> Pravidelná oznámení SimpleX Chat služba Příjem zpráv… @@ -468,7 +468,7 @@ Hlasové zprávy Vy i váš kontakt můžete nevratně mazat odeslané zprávy. (24 hodin) %dm - %dmth + %dm %d hodin %dh %dd @@ -574,7 +574,7 @@ Připojení prostřednictvím odkazu Pokud jste dostali SimpleX Chat pozvánku, můžete ji otevřít v prohlížeči: Pokud zvolíte odmítnutí, odesílatel NEBUDE upozorněn. - Otevřete v mobilní aplikaci, potom klikněte na Připojit.]]> + Otevřete v mobilní aplikaci, potom v aplikaci klepněte na Připojit.]]> Odmítnout Smazat chat Vyčistit @@ -604,7 +604,7 @@ Vložit Tento řetězec není odkazem na připojení! Otevřít v mobilní aplikaci.]]> - %s není ověřeno + %s neověřen Návod k použití Nápověda k markdown Uložit servery @@ -664,10 +664,10 @@ Nové vymezení soukromí Bez uživatelských identifikátorů Odolná vůči spamu - K ochraně soukromí, místo uživatelských ID užívaných všemi ostatními platformami, SimpleX používá identifikátory pro fronty zpráv, zvlášť pro každý z vašich kontaktů. + K ochraně soukromí, SimpleX používá ID pro každý z vašich kontaktů. úložišti GitHub.]]> Použijte chat - Lze změnit později v nastavení. + Jak ovlivňuje baterii Když aplikace běží Okamžité Nejlepší pro baterii. Budete přijímat oznámení pouze když aplikace běží (žádná služba na pozadí).]]> @@ -678,7 +678,7 @@ Příchozí zvukový hovor %1$s se s vámi chce spojit prostřednictvím videohovoru (nešifrovaného e2e). - zvukový hovor (nešifrováno e2e) + zvukový hovor (nešifrován e2e) Odmítnout Vaše hovory Spojení přes relé @@ -730,9 +730,9 @@ Odstranit všechny soubory Tuto akci nelze vrátit zpět - všechny přijaté a odeslané soubory a média budou smazány. Obrázky s nízkým rozlišením zůstanou zachovány. Žádné přijaté ani odeslané soubory - %d soubor(ů) s celkovou velikostí %s + %d soubor(y) s celkovou velikostí %s nikdy - %s vteřin(y) + %s sekund(y) Zprávy Toto nastavení se vztahuje na zprávy ve vašem aktuálním chat profilu. Smazat zprávy po @@ -893,22 +893,22 @@ Posílání mizících zpráv zakázáno. Nevratné mazání odeslaných zpráv zakázáno. Hlasové zprávy zakázány. - Členové skupiny mohou posílat mizící zprávy. - Mizící zprávy jsou v této skupině zakázány. - Členové skupiny mohou posílat přímé zprávy. + Členové mohou posílat mizící zprávy. + Mizící zprávy jsou zakázány. + Členové mohou posílat přímé zprávy. Přímé zprávy mezi členy jsou v této skupině zakázány. - Členové skupin mohou nevratně mazat odeslané zprávy. (24 hodin) - Nevratné mazání zpráv je v této skupině zakázáno. - Členové skupiny mohou posílat hlasové zprávy. - Hlasové zprávy jsou v této skupině zakázány. + Členové mohou nevratně mazat odeslané zprávy. (24 hodin) + Nevratné mazání zpráv je zakázáno. + Členové mohou posílat hlasové zprávy. + Hlasové zprávy jsou zakázány. Smazat za %d měsíc %d měsíců %d den - %d dnů + %d dní %d týden %d týdnů - %dw + %dt nabízeno %s zrušeno %s Co je nového @@ -1038,7 +1038,7 @@ Bez hesla aplikace Nemohli jste být ověřeni; Zkuste to prosím znovu. %d minut - %d vteřin + %d sekund Ihned Zapamatujte si jej nebo bezpečně uložte - neexistuje způsob, jak obnovit ztracené heslo! Zámek SimpleX není povolen! @@ -1063,7 +1063,7 @@ Chyba dešifrování Špatný hash zprávy Špatné ID zprávy - Hash předchozí zprávy se liší. + Kontrolní součet předchozí zprávy se liší. ID další zprávy je nesprávné (menší nebo rovno předchozí). \nMůže se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitováno. %1$d zpráv se nepodařilo dešifrovat. @@ -1114,7 +1114,7 @@ Chyba načítání podrobností Info Hledat - Otevřít chat profily + Změnit chat profily Historie Přijatá zpráva Poslaná zpráva @@ -1141,7 +1141,7 @@ Posláno: %s Zmizí: %s Upraveno: %s - %s (aktuální) + %s (aktuálně) Tmavý motiv Import motivu SimpleX @@ -1154,8 +1154,8 @@ Reakce na zprávy jsou v tomto chatu zakázány. Zakázat reakce na zprávy. Zakázat reakce na zprávy. - Členové skupin mohou přidávat reakce na zprávy. - Reakce na zprávy jsou v této skupině zakázány. + Členové mohou přidávat reakce na zprávy. + Reakce na zprávy jsou zakázány. měsíců Zjistit více Sdílet s kontakty @@ -1192,9 +1192,7 @@ minut vteřin Díky uživatelům - překládejte prostřednictvím Weblate! - - 5 minutové hlasové zprávy. -\n- vlastní čas mizení. -\n- historie úprav. + - 5 minutové hlasové zprávy.\n- volitelný čas mizení zpráv.\n- historie úprav. dní hodin týdnů @@ -1228,7 +1226,7 @@ Pokud později adresu odstraníte, o kontakty nepřijdete. Přístupový kód aplikace je nahrazen sebedestrukčním přístupovým heslem. žádný text - Během importu došlo k nezávažným chybám - podrobnosti naleznete v chat konzoli. + Během importu došlo k nezávažným chybám: Vypnout\? Oznámení přestanou fungovat až do nového spuštění aplikace Vypnout @@ -1246,9 +1244,7 @@ Opravit nepodporované členem skupiny Opravit nepodporované kontaktem Zachovat vaše připojení - - více stabilní doručovaní zpráv. -\n- o trochu lepší skupiny. -\n- a více! + - stabilnější doručování zpráv.\n- trochu lepší skupiny.\n- a více! šifrování povoleno pro %s vyžadováno opětovné vyjednávání šifrování pro %s Odesílání potvrzení o doručení je vypnuto pro %d kontakty. @@ -1267,9 +1263,9 @@ Odoblíbit Přerušit změnu adresy Povolit odesílání souborů a médii. - Členové skupiny mohou posílat soubory a média. + Členové mohou posílat soubory a média. Pouze majitelé skupiny mohou povolit soubory a média. - Soubory a média jsou zakázány v této skupině. + Soubory a média jsou zakázány. Vypnout (zachovat přepsání) Povolit pro všechny Povolit (zachovat přepisování) @@ -1345,7 +1341,7 @@ %s: %s %s a %s připojen %s, %s a %s připojen - %s, %s a %d další členové připojeni + %s, %s a %d jiných členů připojeno Rozepsáno Zobrazit poslední zprávy Tato skupina má více než %1$d členů, doručenky nejsou odeslány. @@ -1390,7 +1386,7 @@ Adresa počítače Připojit se do skupiny? Skupina již existuje! - %s připojený + %s připojen Toto zařízení Počítač Jméno tohoto zařízení @@ -1429,8 +1425,8 @@ %1$s.]]> Propojit mobilní a stolní aplikace! 🔗 To je váš vlastní jednorázový odkaz! - %d zpráv označeno jako smazaná - Prostřednictvím zabezpečeného kvant rezistentního protokolu. + %d zpráv označeno jako smazané + Díky kvantům odolnému protokolu zabezpečení. Použijte z PC v aplikaci telefonu a naskenujte QR kód.]]> Již připojuji! Skrýt nežádoucí zprávy. @@ -1472,7 +1468,7 @@ Připojit s %1$s? Blokovat Odblokovat člena? - %d zpráv zablokováno + %d zpráv blokováno Chcete-li povolit telefonu připojení k PC, otevřete tento port ve vašem firewallu, pokud je povolen Blokovat člena Použít z PC @@ -1489,18 +1485,16 @@ Ověřit kód v telefonu Otevřít port ve firewallu Zadejte jméno tohoto zařízení… - Sdílíte neplatnou cestu souboru. Nahlaste problém vývojářům aplikace. + Sdíleli jste neplatnou cestu souboru. Nahlaste problém vývojářům aplikace. Odpojit mobilní telefony Blokovat člena? - %d skupinových událostí + %d událostí skupiny %1$s!]]> Ověřit kód s PC Skenovat QR kód z PC Odblokovat Objevitelný přes lokální síť - - volitelně oznámení odstraněným kontaktům. -\n- profilová jména s mezeramy. -\n- a více! + - volitelné oznámení odstraněným kontaktům.\n- profilová jména s mezeramy.\n- a více! Neplatná cesta souboru Již jste požádali o spojení přes tuto adresu! Zobrazit konzoli v novém okně @@ -1543,7 +1537,7 @@ Provedení funkce trvá příliš dlouho: %1$d vteřin: %2$s Připojit se ke skupině blokováno adminem - %d zpráv zablokováno správcem + %d zpráv blokováno správcem Chyba vytváření zprávy Chyba odstranění soukromých poznámek Smazat soukromé poznámky? @@ -1735,7 +1729,7 @@ Souběžné přijímání SimpleX odkazy Povoleno pro - Členové skupiny mohou odesílat SimpleX odkazy. + Členové mohou odesílat SimpleX odkazy. Zvuky v hovoru Zdroje zpráv zůstávají důvěrné. Správa sítě @@ -1751,7 +1745,7 @@ Kvantům odolné šifrování Kamera Kamera a mikrofon - SimpleX odkazy jsou v této skupině zakázány. + SimpleX odkazy jsou zakázány. koncovým šifrováním s dokonalým dopředným utajením, odmítnutím a obnovením po vloupání.]]> Pokročilé nastavení Všechny barevné režimy @@ -1860,7 +1854,7 @@ Nastavené SMP servery Probíhá Části nahrány - %1$d chyba souboru(ů):\n%2$s + %1$d chyba souboru:\n%2$s %1$d jiná chyba souboru(ů). Chyba přeposílaní zpráv Adresa serveru není kompatibilní s nastavením sítě. @@ -1940,7 +1934,7 @@ Nové přihlašovací údaje SOCKS budou použity pro každý server. Znovu připojte všechny připojené servery pro vynucení doručení. Využívá další provoz. Resetovat všechny tipy - %1$d soubor(y) stále stahuji. + %1$d soubor(y) se stále stahují. Lepší datování zpráv. Lepší zabezpečení ✅ Části odstraněny @@ -2014,7 +2008,7 @@ Odeslané zprávy Odeslaných celkem Adresa serveru - Dosažitelný panel nástrojů chatu + Dosažitelné panely nástrojů Chyba potvrzení Připojení Vytvořen @@ -2050,7 +2044,7 @@ Žádné servery pro odesílání souborů. Pokračovat Otevřít změny - Žádné chaty nebyly nalezeny + Nenalezen žádný chat archivovaná hlášení podle %s přijmuté pozvání Porušení pokynů komunity @@ -2060,7 +2054,7 @@ Nevhodný profil Pro profil chatu %s: Žádné chatovací servery. - Žádné servery pro soukromé směřování chatů. + Žádné servery pro soukromé směrování chatů. Žádné servery pro příjem souborů. Žádné servery pro příjem zpráv. Všechny chaty budou ze seznamu odebrány %s, a seznam bude smazán @@ -2161,7 +2155,7 @@ Archivovat %d hlášení? Pro všechny moderátory Pro mě - %d hlášení + %d reportů Skupiny Seznam Hlášení členů @@ -2179,7 +2173,7 @@ Členové mohou zprávy nahlásit moderátorům. Zakázat nahlašování zpráv moderátorům. Chyba aktualizace serveru - Operátor server + Operátor serveru Decentralizace sítě Archivována hlášení členů Archivovat hlášení @@ -2197,7 +2191,7 @@ Vymazat chat? %s.]]> %s.]]> - %1$s.]]> + %1$s.]]> Relace aplikace Podmínky budou přijaty pro povolené operátory po 30 dnech. Připojení vyžaduje opětovné vyjednání šifrování. @@ -2211,4 +2205,153 @@ Chyba vytváření hlášení 1 rok výchozí (%s) + Použijte náhodné přihlašovací údaje + Nahlásit spam: uvidí pouze skupinový moderátoři. + Přečíst podmínky + Webové stránky + Odebírán + požádáno o připojení + Spam + Žádné mediální a souborové servery. + Chyba dočasného souboru + Přesunout sezení + TCP připojení + Použité servery + Použit %s + Pro příjem + Systém + Spam + Nedoručené zprávy + Tato zpráva byla smazána, nebo dosud nebyla přijata. + Důvod nahlášení? + Report bude archivován. + Zprávy budou pro všechny členy označeny jako moderované. + Reporty + Nahlašte profil člena: uvidí pouze skupinový moderátoři. + Nahlásit porušení: Uvidí pouze moderátoři skupiny. + Zobrazit stav zpráv + Přeskočit tuto verzi + Zobrazit seznam v novém okně + Aktualizovat aplikaci automaticky + Některé soubory nebyly exportovány + Nepřečtené zmínky + Sdílet adresu veřejně + Sdílent SimpleX adresu na sociálních médiích. + Sdílejte 1 rázový odkaz s přítelem + SOCKS Proxy + Dostupná aktualizace: %s + Stabilní + Můžete nastavit operátory v nastavení sítě a serverů. + Ocas + Zastavíte přijímání zpráv z tohoto chatu. Chat historie bude zachována. + Servery pro nové soubory vašeho aktuálního chat profilu + Protokolu serveru se změnil. + Operátor serveru se změnil. + Zoom + Nastavit výchozí téma + Nahráno + Ano + Přepnout chat seznam: + Tuto akci nelze zrušit - zprávy odeslané a přijaté v tomto chatu dříve než vybraná, budou smazány. + Statistiky serverů budou obnoveny - nemůže být vráceno! + Odeslat soukromý report + Pomozte administrátorům moderovat své skupiny. + Rychlejší mazání skupin. + Od %s. + Můžete zmínit až %1$s členů ve zprávě! + Musíte kopírovat a snížit velikost zprávy, abyste ji poslali. + Jméno + SimpleX adresa nebo 1 rázový odkaz? + Nastavení + Uložit seznam + Stažení aktualizace zrušeno + Můžete uložit exportovaný archiv. + Použit pro zprávy + Server přidán k operátoru %s. + Průhlednost + Přepínání chat profilu pro 1-rázové pozvánky. + video + Sdílet profil + Reportování zpráv je zakázáno v této skupině. + XFTP servr + Nahrané soubory + Odběr ignorován + Verze serveru není kompatibilní s nastavením sítě. + Pro pozdější vytvoření adresy, klepněte v menu na Vytvořit SimpleX adresu. + Vaše servery + Zobrazit aktualizované podmínky + 1 report + zamítnuto + Report: %s + Nahlásit další: uvidí pouze skupinový moderátoři. + Můžete nastavit název připojení, pro pamatování, s kým byl odkaz sdílen. + Vaše připojení bylo přesunuto na %s, ale došlo k neočekávané chybě při přesměrování na profil. + Pro každý profil použijte různé přihlašovací údaje + Neznámé servery + Pro ochranu vaší IP adresy, soukromé směrování používá vaše servery SMP k doručování zpráv. + Použít web portu + TCP port pro zprávy + Váš chat profil bude zaslán členům + Režim systému + Zmínky členů 👋 + Organizujte konverzace do seznamů + Lepší výkon skupin + Lepší soukromí a bezpečnost + Nenechte si ujít důležité zprávy. + Rychlejší odesílání zpráv. + Vlastní názvy souborů médií. + Zobrazuji informace pro + Chyby nahrávání + Soukromé směrování se používá k doručování zpráv těmto serverům, protože k nim nejste připojeni. + Velikost + Přečíst později + Aktualizovat + %s servery + Celkem + Verze serveru není kompatibilní s vaší aplikací: %1$s. + Připojení dosáhlo limitu nedoručených zpráv, váš kontakt je asi offline. + Pro odeslílání + Použit pro soubory + Druhý přednastavený operátor v aplikaci! + Chcete-li volat, povolte použití mikrofonu. Ukončete hovor a zkuste to znovu. + Statistiky + Chyby odběru + O operátorech + SimpleX Chat a Flux udělali dohodu, a zahrnuly servery spravované Flux do aplikace. + SMP server + Vaše kontakty + Od %s.\nJsou všechna data uchovávána ve vašem zařízení. + Serverový operátoři + Vybrat provozovatele sítě pro použití. + Můžete nakonfigurovat servery v nastavení. + Zvuk ztlumen + Používat aplikaci jednou rukou. + SinpleX protokoly přezkoumány Trail Bits. + Přepnínání zvuku a videa během hovoru. + Vaše přihlašovací údaje mohou být zaslány nešifrované. + Pokud je povolen více než jeden operátor, nikdo z nich nemá metadata, aby poznal, kdo s kým komunikuje. + Nastavit název chatu.. + Použít TCP port %1$s, když není zadán žádný port. + Tento odkaz byl použit s jiným mobilním zařízením, vytvořte na počítači nový odkaz. + Získejte upozornění, když jste zmíněni. + SimpleX adresa a 1 rázové odkazy je bezpečné sdílet přes všechny komunikátory. + Zprávy budou smazány pro všechny členy. + Aplikace vyžaduje potvrzení stahování z neznámých serverů (s výjimkou .onion nebo při aktivaci SOCKS proxy). + Musíte povolit kontaktům volání, abyste jim mohli zavolat. + Nastavit expirace zpráv. + Zobrazit procenta + Nahraný archiv databáze bude ze serverů trvale odstraněn. + Pro ochranu před záměnou odkazů, můžete porovnat bezpečnostní kódy. + Stále si můžete prohlédnout rozhovor s %1$s v chat seznamu. + Aplikace chrání vaše soukromí pomocí různých operátorů v každé konverzaci. + Můžete jej změnit v nastavení Vzhledu. + Role se změní na %s. Každý v chatu bude upozorněn. + Chcete-li být informováni o nových verzích, zapněte periodickou kontrolu pro Stabilní nebo Beta verze. + Pro každé připojení použijte různé přihlašovací údaje. + Silný + Zobrazit podmínky + Můžete posílat zprávy %1$s z archivovaných kontaktů. + Můžete přenést exportovanou databázi. + zamítnuto + Jemný 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 e79746c8f1..444c7fedaa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -355,8 +355,8 @@ Chat-Konsole SMP-Server Voreingestellte Serveradresse - Füge voreingestellte Server hinzu - Füge Server hinzu + Voreingestellte Server hinzufügen + Server hinzufügen Teste Server Teste alle Server Alle Server speichern @@ -923,7 +923,7 @@ Das Senden von verschwindenden Nachrichten nicht erlauben. Verschwindende Nachrichten sind nicht erlaubt. Mitglieder können verschwindende Nachrichten versenden. - Fügen Sie Server durch Scannen der QR-Codes hinzu. + Server durch Scannen von QR-Codes hinzufügen. Verschwindende Nachrichten Übernehmen Einen Tag festlegen @@ -1304,7 +1304,7 @@ Gelöscht um: %s Verschwindet um Verschwindet um: %s - Nachrichtenverlauf bearbeiten + Nachrichtenverlauf In diesem Chat sind Reaktionen auf Nachrichten nicht erlaubt. Kein Text Während des Imports sind nicht schwerwiegende Fehler aufgetreten: @@ -2420,4 +2420,18 @@ Melden von Nachrichten an Moderatoren erlauben. Alle Meldungen werden für Sie archiviert. Meldung: %s + Helfen Sie Administratoren bei der Moderation ihrer Gruppen. + Erwähnung von Mitgliedern 👋 + Private Meldungen senden + Bei Erwähnung benachrichtigt werden. + Medien mit anonymisierten Dateinamen. + Verfallsdatum von Nachrichten in Chats festlegen. + Bessere Leistung von Gruppen + Schnelleres löschen von Gruppen + Schnelleres versenden von Nachrichten. + abgelehnt + Bessere(r) Security und Datenschutz + Verpassen Sie keine wichtigen Nachrichten. + Chats in Listen verwalten + abgelehnt diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index cce810b10c..56ae24a08b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1758,7 +1758,7 @@ \nError del servidor de destino: %2$s Servidor de reenvío: %1$s \nError: %2$s - Problema en la red - el mensaje ha expirado tras muchos intentos de envío. + Problema en la red - el mensaje ha caducado tras muchos intentos de envío. La versión del servidor es incompatible con la configuración de la red. Enrutamiento privado Servidores desconocidos @@ -1951,7 +1951,7 @@ Archivos descargados Errores de descarga duplicados - expirados + caducados Abrir configuración del servidor otros otros errores @@ -2357,6 +2357,8 @@ Ayuda a los admins moderar sus grupos. Mejor rendimiento de los grupos Mejor privacidad y seguridad - Envia informes privados - Establece la expiración de mensajes en chats. + Envía informes privados + Establece vencimiento de los mensajes en chats. + rechazado + rechazado diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index f434feb4cd..3084b8569b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -301,7 +301,7 @@ Onemogućiti obavještenja %s nije verifikovan Sve poruke biće odstranjene - ovo ne može biti vraćeno! - Profil razgovora + Profil Chata Onemogućiti %d nedelja - neobavezno obavestiti o obrisanim kontaktima.\n- imena profila sa razmacima.\n- i još mnogo toga!\" @@ -309,11 +309,11 @@ Datoteke Otpremljeno %s je verifikovan - Konzola razgovora + Konzola chata Skenirati QR kod Server Onemogućiti - BAZA PODATAKA RAZGOVORA + BAZA PODATAKA CHATA onemogućeno Greška pri uvoženju teme Datoteke i medijski sadržaji su zabranjeni. @@ -332,13 +332,13 @@ RAZGOVORI Datoteke i medijski sadržaji su zabranjeni! Poruke koje nestaju su zabranjene u ovom razgovoru. - Razgovor je zaustavljen + Chat je zaustavljen Datoteka sačuvana Verzija aplikacije Verzija aplikacije: v%s Tema razgovora %s sekunda(i) - Baza podataka razgovora izvezena + Baza podataka Chata izvezena Operator Operacioni server Poruke koje nestaju @@ -356,32 +356,32 @@ Dozvoliti Direktne poruke Onemogućeno za sve - Razgovor migriran! + Chat migriran! %s, %s i %d članovi Prekinuti vezu - Razgovor već postoji! - Baza podataka razgovora odstranjena + Chat već postoji! + Baza podataka Chata odstranjena Proveri ažuriranje - Razgovor je zaustavljen. Ako već koristiš ovu bazu podataka na drugom uređaju, trebala bi se vratiti pre nego što se pokrene razgovor. + Chat je zaustavljen. Ako već koristiš ovu bazu podataka na drugom uređaju, trebala bi se vratiti pre nego što se pokrene chat. Proveri poruke svakih 10 minuta %s povezan %s, %s i %s povezani direktno - Razgovor + Chat QR kod - Razgovor je pokrenut + Chat je pokrenut Uvesti bazu podataka - BAZA PODATAKA RAZGOVORA - Razgovor je zaustavljen + BAZA PODATAKA CHATA + Chat je zaustavljen %s, %s i %d ostali članovi povezani Uvoz neuspešan Razgovor će biti odstranjen za tebe - ovo se ne može vratiti! Uvesti temu - Baza podataka razgovora uvezena + Baza podataka Chata uvezena %s (sadašnji) Nestaje u: %s Uvesti - Boje razgovora + Boje Chata %s: %s Ili uvesti arhiviranu datoteku Razgovor će biti odstranjen za sve članove - ovo se ne može vratiti! @@ -598,14 +598,14 @@ Obnoviti boje Povezati telefon Arhiviraj izveštaj? - Dostava + Prijem Izveštaj U odgovoru na Izveštaji Beleške Poslati živu poruku Odstraniti veze na čekanju? - Skenirati / Nalepiti vezu + Skenirati / Nalepiti link Neispravan bezbednostni kod! odbijeni poziv Pozvati @@ -975,7 +975,7 @@ Poslana slika Potvrditi Još nekoliko stvari - Povezati pomoću jednokratne veze? + Povezati pomoću jednokratnog linka? Pin kod Vi i Vaš kontakt, možete da šaljete poruke koje nestaju. Zvukovi u pozivu @@ -1170,7 +1170,7 @@ Ukupno primljeno %s je neaktivan]]> Ime kontakta - Greška u isporuci poruke + Greška pri isporuci poruke Poruke će biti označene za odstranjivanje. Primaoci će moći da otkriju te poruke. Odstraniti datoteke i medijski sadržaj? Primanje datoteke biće zaustavljeno. @@ -1245,7 +1245,7 @@ Greška pri pristupanju grupi Opcije za programere Alati za programere - Preference razgovora + Preference chata Potvrdite da su mrežna podešavanja za ovaj uređaj ispravna. Sadržaj krši uslove korišćenja Nije moguće primiti datoteku @@ -1412,4 +1412,76 @@ Vaši ICE serveri Koristiti nasumičnu pristupnu frazu Onemogućiti SimpleX Zaključavanje + Ponovo povezati server? + Nema primljenih niti poslanih datoteka + neispravan hash poruke + Neispravan ID poruke + Ponovo povezati servere? + Prikazati kontakt i poruku + Prikazati opcije za programere + Neispravan hash poruke + Ponovo povezati server kako biste prisilili dostavu poruke. To koristi dodatni saobraćaj. + neispravan ID poruke + Konfigurisani XFTP serveri + Upozorenje pri isporuci poruke + Povezati se pomoću linka / QR koda + Već imate chat profil sa istim prikaznim imenom. Molimo vas da odaberete drugo ime. + Greška u autentifikaciji + Unapređena konfiguracija servera + %1$d ostale greška datoteke(a). + Autentifikacija nije dostupna + Konfigurisani SMP serveri + Automatsko prihvatanje + Sačuvati preference? + Za pozive je potreban podrazumevani veb pretraživač. Molimo vas da konfigurišete podrazumevani pretraživač u sistemu i podelite više informacija sa programerima. + odblokirali ste %s + Vi ste posmatrač. + Unapređena privatnost i bezbednost + Migriraj na drugi uređaj pomoću QR koda. + odbijeno + Nova pristupna fraza… + Automatsko prihvatanje zahteva za kontakt + Samo Vaši kontakti mogu slati poruke koje nestaju. + Samo Vaši kontakti mogu slati glasovne poruke. + Vi dozvoljavate + Otvoriti fasciklu baze podataka + Sačuvati i ažurirati grupni profil + Uz smanjenu potrošnju baterije. + (skenirati ili nalepiti iz memorije) + Greška u vezi (AUTH) + Ažurirajte aplikaciju i kontaktirajte programere. + Tokom uvoza došlo je do nekih nefatalnih grešaka: + Sačuvati pristupnu frazu u podešavanjima + Poslali ste grupnu pozivnicu + kvantno otporna e2e šifrovanju + Samo Vaši kontakti mogu upućivati pozive. + Ne slati istoriju novim članovima. + Članovi mogu da šalju glasovne poruke. + Uz smanjenu potrošnju baterije. + Greška pri preuzimanju arhive + Svi razgovori biće uklonjeni sa liste %s, a lista odstranjena + Ovaj QR kod nije link! + Već se povezujete! + Migriraj na drugi uređaj + Otvoriti promene + Decentralizovano + Nema informacija o prijem + Povezati se pomoću linka + Zadržati Vaše konekcije + Migriraj ovde + Povezati se pomoću linka? + Instaliraj SimpleX Chat za terminal + Koristiti web port + Samo Vi možete da upućujete pozive. + Otvoriti lokaciju datoteke + odbijeno + Sačuvati grupni profil + Samo vi možete slati poruke koje nestaju. + Uporedite sigurnosne kodove sa vašim kontaktima. + Automatsko prihvatanje slika + Naslov + Neispravna pristupna fraza baze podataka + Migriraj sa drugog uređaja + Samo vi možete slati glasovne poruke + Samo vi možete stavljati reakcije na poruke. 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 9100c029b7..8aa0dd28cc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -19,7 +19,7 @@ 1 nap Cím módosításának megszakítása A SimpleXről - Kiemelés + Kiemelőszín fogadott hívás Hozzáférés a kiszolgálókhoz SOCKS-proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. Elfogadás @@ -29,8 +29,8 @@ Kapcsolatkérés elfogadása? Elfogadás Elfogadás - Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára. - További kiemelés + Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára. + További kiemelőszín híváshiba Csoporttagok letiltása Hitelesítés @@ -38,28 +38,28 @@ %s visszavonva Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva ebben a csevegésben. - Az összes ismerőséhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> + Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> hivatkozás előnézetének visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. Érvénytelen az üzenet hasítóértéke Háttér - Megjegyzés: az üzenet- és fájltovábbító-kiszolgálók SOCKS-proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> + Megjegyzés: az üzenet- és fájltovábbító kiszolgálók SOCKS-proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára. + A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön partnerei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új, véletlenszerű profil lesz megosztva. - A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Az alkalmazás összeállítási száma: %s Hang- és videóhívások Speciális hálózati beállítások - A hangüzenetek küldése engedélyezve van az ismerősei számára. + A hangüzenetek küldése engedélyezve van a partnerei számára. Hang- és videóhívások Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). Hívás fogadása - Az eltűnő üzenetek küldésének engedélyezése az ismerősei számára. + Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. Kapcsolódás folyamatban! Nem lehet fogadni a fájlt Hitelesítés elérhetetlen @@ -80,20 +80,20 @@ Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) Mindkét fél küldhet hangüzeneteket. Téves üzenet ID - A reakciók hozzáadása az üzenetekhez engedélyezve van az ismerősei számára. + A reakciók hozzáadása az üzenetekhez engedélyezve van a partnerei számára. A hangüzenetek küldése engedélyezve van. - A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A reakciók hozzáadása az üzenetekhez csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Vissza Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. Hívások a zárolási képernyőn: titkosítás elfogadása… - Nem lehet meghívni az ismerőst! + Nem lehet meghívni a partnert! téves üzenet ID Kapcsolatkérések automatikus elfogadása Megjegyzés: NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> hívás… - További másodlagos + További másodlagos szín Hozzáadás egy másik eszközhöz A reakciók hozzáadása az üzenetekhez engedélyezve van. Fájlelőnézet visszavonása @@ -114,13 +114,13 @@ Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. Hangüzenetek engedélyezése? - Mindig használjon továbbító-kiszolgálót + Mindig használjon továbbítókiszolgálót mindig A hívás már befejeződött! Engedélyezés - Az összes ismerősével kapcsolatban marad. + Az összes partnerével kapcsolatban marad. Élő csevegési üzenet visszavonása - Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra) + Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) Hang- és videóhívások érvénytelen az üzenet hasítóértéke Mindig fut @@ -133,7 +133,7 @@ %1$s hívása befejeződött Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> szerző - Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra) + Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra) Mégse Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem fog elindulni Továbbfejlesztett üzenetek @@ -148,11 +148,11 @@ Nem érhető el a Keystore az adatbázis jelszavának mentéséhez hívás folyamatban Képek automatikus elfogadása - A hívások kezdeményezése engedélyezve van az ismerősei számára. + A hívások kezdeményezése engedélyezve van a partnerei számára. ALKALMAZÁSIKON Kiszolgáló hozzáadása QR-kód beolvasásával. Az eltűnő üzenetek küldése engedélyezve van. - Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Hang kikapcsolva A közvetlen üzenetek küldése a tagok között engedélyezve van. ALKALMAZÁS @@ -162,7 +162,7 @@ Sikertelen hitelesítés Az összes %s által írt új üzenet el lesz rejtve! Az alkalmazás verziója: v%s - A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. + A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva hanghívás (nem e2e titkosított) @@ -171,7 +171,7 @@ kapcsolódva Jelkód módosítása a következőre módosította %s szerepkörét: „%s” - A fogadási cím módosítása + Fogadási cím módosítása Módosítás Jelkód megerősítése Jelszó megerősítése @@ -195,10 +195,10 @@ Kapcsolódás közvetlenül kapcsolódott kapcsolat %1$d - az ismerős e2e titkosítással rendelkezik + a partner e2e titkosítással rendelkezik Csoport létrehozása véletlenszerű profillal. - Az ismerős és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! - Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. + A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! + A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. Kapcsolódás egyszer használható meghívási hivatkozással? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) @@ -208,9 +208,9 @@ Másolás Folytatás Kapcsolódás egy hivatkozáson keresztül? - Az ismerős már létezik + A partner már létezik Fő verzió: v%s - Ismerős ellenőrizve + Partner ellenőrizve Kapcsolódás saját magához? Kimásolva a vágólapra Kapcsolatkérés elküldve! @@ -220,24 +220,24 @@ Időtúllépés kapcsolódáskor Kapcsolódás a következővel: %1$s? Létrehozás - Ismerős-beállítások + Partnerbeállítások Kapcsolat Kapcsolat megszakítva kapcsolat létrehozva - az ismerős nem rendelkezik e2e titkosítással - Ismerős engedélyezi + a partner nem rendelkezik e2e titkosítással + Partner engedélyezi Rejtett név: Társítás számítógéppel Szövegkörnyezeti ikon Kapcsolódás egy hivatkozáson keresztül - Ismerősök + Partnerek Kapcsolódási hiba - Az ismerős még nem kapcsolódott! + A partnere még nem kapcsolódott! - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. Hozzájárulás kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása - törölt ismerős + törölt partner Csoporttag üzenetének törlése? A csevegés fut Egyszer használható meghívási hivatkozás létrehozása @@ -251,14 +251,14 @@ Törölve: %s Törölve Kínai és spanyol kezelőfelület - Nem lehet meghívni az ismerősöket! + Nem lehet meghívni a partnereket! A csevegés megállt Sötét Profil létrehozása törölt csoport Törlés az összes tagnál Hivatkozás létrehozása - Csevegés-beállítások + Csevegési beállítások Profil törlése Jelenlegi jelkód kapcsolódás @@ -287,7 +287,7 @@ Hitelesítés törlése készítő Megerősítés - Csak nálam + Csak számomra %d üzenet törlése? Egyéni témák kapcsolódás (elfogadva) @@ -323,7 +323,7 @@ Csevegési konzol Fájlok törlése az összes csevegési profilból Sorba állítás törlése - Ismerős törlése + Partner törlése cím módosítása… Társítva a hordozható eszközhöz Jelenlegi jelmondat… @@ -332,14 +332,14 @@ Fájl létrehozása Tikos csoport létrehozása Elvetés - Ismerős törlése? + Partner törlése? Kiürítés Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. - Biztonsági kódok összehasonlítása az ismerősökével. + Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás Csevegések Üzenet törlése? - Függőben lévő ismerőskérelem törlése? + Függőben lévő partnerkérelem törlése? Adatbázis titkosítva! Üzenetek kiürítése? Adatbázis visszafejlesztése @@ -358,7 +358,7 @@ Letiltás Letiltás az összes csoport számára Engedélyezés az összes csoport számára - engedélyezve az ismerős számára + engedélyezve a partner számára Az eltűnő üzenetek küldése le van tiltva. Cím törlése %d hét @@ -398,7 +398,7 @@ közvetlen Számítógép %d perc - %d ismerős kijelölve + %d partner kijelölve Engedélyezés %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. @@ -416,7 +416,7 @@ engedélyezve az Ön számára Eltűnő üzenetek Törlés - Törlés, és az ismerős értesítése + Törlés, és a partner értesítése letiltva %d mp Az összes fájl törlése @@ -434,7 +434,7 @@ Letiltás (csoport felülírások megtartásával) %d csoportesemény %d hónap - A csoport profiljának szerkesztése + Csoportprofil szerkesztése e2e titkosított hanghívás %d mp Decentralizált @@ -545,7 +545,7 @@ Hiba a csoporthivatkozás létrehozásakor A galériából Engedélyezés (csoport felülírások megtartásával) - Hiba az ismerős törlésekor + Hiba a partner törlésekor A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) Hiba a szerepkör módosításakor Javítás @@ -560,7 +560,7 @@ Hiba a csoporthivatkozás frissítésekor a csoport törölve csoportprofil frissítve - Hiba a függőben lévő ismerős kapcsolatának törlésekor + Hiba a függőben lévő partner kapcsolatának törlésekor Hiba a csevegési adatbázis importálásakor Hiba a kézbesítési jelentések engedélyezésekor! Hiba az XFTP-kiszolgálók mentésekor @@ -585,7 +585,7 @@ titkosítás rendben Hiba a kapcsolatkérés törlésekor Kézbesítési jelentések engedélyezése a csoportok számára? - Ismerős általi javítás nem támogatott + Partner általi javítás nem támogatott Fájl nem található Kapcsolat bontása A tagok reakciókat adhatnak hozzá az üzenetekhez. @@ -600,7 +600,7 @@ Teljes hivatkozás Hiba a cím módosításakor A tagok küldhetnek hangüzeneteket. - Csoport-beállítások + Csoportbeállítások Hiba: %s Eltűnő üzenetek SimpleX-zár bekapcsolása @@ -622,7 +622,7 @@ Csevegési üzenetek gyorsabb megtalálása Téma importálása Hiba a téma importálásakor - Ismerős nevének és az üzenet tartalmának elrejtése + Partner nevének és az üzenet tartalmának elrejtése Nem kompatibilis adatbázis-verzió Hogyan működik a SimpleX Nem kompatibilis verzió @@ -659,9 +659,9 @@ A fájlok- és a médiatartalmak küldése le van tiltva. Hogyan működik Elrejtés: - Hiba az ismerőssel történő kapcsolat létrehozásában + Hiba a partnerrel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) - beolvashatja a QR-kódot a videohívásban, vagy az ismerőse megoszthat egy meghívási hivatkozást.]]> + beolvashatja a QR-kódot a videohívásban, vagy a partnere megoszthat egy meghívási hivatkozást.]]> Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> @@ -682,7 +682,7 @@ A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! meghíva Érvénytelen kapcsolattartási hivatkozás - Csak említések + Némítás nincsenek részletek Nem fogadott hívás Világos @@ -695,7 +695,7 @@ moderált A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. - Nincs ismerős kijelölve + Nincs partner kijelölve Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben @@ -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 az ismerőse régi adatbázis biztonsági mentést használt. + Ez akkor fordulhat elő, ha Ön vagy a partnere régi adatbázis biztonsági mentést használt. Ú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 @@ -767,7 +767,7 @@ Nincsenek háttérhívások Üzenetek Társított hordozható eszköz - Lehetővé teszi, hogy egyetlen csevegőprofilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük. + 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. Az üzenet törlésre lesz jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Elhagyás Rendben @@ -776,7 +776,7 @@ Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését. A fájl betöltése… - Nincs hozzáadandó ismerős + Nincs hozzáadandó partner Üzenetvázlat meghívta, hogy csatlakozzon Egyszer használható meghívási hivatkozás @@ -786,15 +786,15 @@ Nem kompatibilis! Tegye priváttá a profilját! Üzenetkézbesítési hiba - Több csevegőprofil + Több csevegési profil törlésre jelölve Némítás Hordozható eszköz társítása Értesítési szolgáltatás Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését. - A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. + A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás - Csak a csoport tulajdonosai módosíthatják a csoport-beállításokat. + Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat. Nincsenek előzmények Érvénytelen QR-kód Megjelölés olvasottként @@ -810,7 +810,7 @@ %s ajánlotta Csoport elhagyása Az összes %s által írt üzenet 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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy az ismerőse 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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült. megfigyelő inkognitó a csoporthivatkozáson keresztül Onion-kiszolgálók használata, ha azok rendelkezésre állnak. @@ -859,20 +859,20 @@ Bejövő hanghívás Kulcstartóhiba Csatlakozik a csoporthoz? - Az inkognitómód védi személyes adatait azáltal, hogy az összes ismerőséhez új, véletlenszerű profilt használ. + Az inkognitómód úgy védi a személyes adatait, hogy az összes partneréhez új, véletlenszerű profilt használ. - stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más! Üzenetreakciók Nincs társított hordozható eszköz Hálózat állapota Új jelkód - Valószínűleg ez az ismerős törölte Önnel a kapcsolatot. + Valószínűleg ez a partner törölte Önnel a kapcsolatot. Csatlakozás inkognitóban Csevegés megnyitása elutasított hívás Időszakos fogadott, tiltott Kapcsolatkérés megismétlése? - Véglegesen csak Ön törölhet üzeneteket (ismerőse csak törlésre jelölheti meg őket ). (24 óra) + Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) Szerepkör SimpleX kapcsolattartási cím Megállítás @@ -911,14 +911,14 @@ %s és %s Ön engedélyezi Csökkentett akkumulátor-használat - Mentés és az ismerősök értesítése + Mentés és a partnerek értesítése Előnézet SimpleX Chat használata Megosztás Fogadott üzenet Üdvözlőüzenet %s, %s és további %d tag kapcsolódott - Csak az ismerőse tud hívást indítani. + Csak a partnere tud hívást indítani. TÉMÁK Túl sok videó! Üdvözöljük! @@ -933,15 +933,15 @@ SimpleX Hivatkozás előnézete a biztonsági kód módosult - Csak az ismerős nevének megjelenítése + Csak a partner nevének megjelenítése Hangszóró bekapcsolva Importált csevegési adatbázis használatához indítsa újra az alkalmazást. jogosulatlan küldés - Csak az ismerőse tud hangüzeneteket küldeni. + Csak a partnere tud hangüzeneteket küldeni. Beállítások - A kapcsolódáshoz az ismerőse beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. + A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. visszaigazolás fogadása… - Biztonsági kód beolvasása az ismerősének alkalmazásából. + Biztonsági kód beolvasása a partnere alkalmazásából. Lépjen kapcsolatba a csoport adminisztrátorával. Videó bekapcsolva Profilnév: @@ -957,13 +957,13 @@ Üzenet elküldése Adatbázismentés visszaállítása Visszavonás - Kérje meg az ismerősét, hogy engedélyezze a hangüzenetek küldését. + Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését. Ön egy egyszer használható meghívási hivatkozást osztott meg A hivatkozás megnyitása a böngészőben gyengítheti az adatvédelmet és a biztonságot. A megbízhatatlan SimpleX-hivatkozások pirossal vannak kiemelve. Saját ICE-kiszolgálók Kapcsolat létrehozása Elutasítás - Ismerős nevének és az üzenet tartalmának megjelenítése + Partner nevének és az üzenet tartalmának megjelenítése BEÁLLÍTÁSOK Profiljelszó mentése Fájlküldés megállítása? @@ -979,7 +979,7 @@ %s (jelenlegi) Saját SMP-kiszolgáló Véletlen - Megosztás az ismerősökkel + Megosztás a partnerekkel Ön Nincsenek csevegései Küldés @@ -995,7 +995,7 @@ Elküldve: %s Jelenlegi profil használata Ez az eszköz - Megosztja a címet az ismerőseivel? + Megosztja a címet a partnereivel? Profiljelszó Téma Jelmondat eltávolítása a beállításokból? @@ -1003,7 +1003,7 @@ Várakozás a képre Önmegsemmisítés várakozás a válaszra… - Ismerős nevének beállítása… + Partner nevének beállítása… Tag feloldása QR-kód beolvasása Kiszolgáló tesztelése @@ -1033,12 +1033,12 @@ Eltávolítás A jelkód beállítva! Elküldött üzenet - Ismerősök kijelölése + Partnerek kijelölése ismeretlen üzenetformátum Kiszolgálók mentése Üdvözlőüzenet mp - A profilfrissítés el lesz küldve az Ön ismerősei számára. + A profilfrissítés el lesz küldve az Ön partnerei számára. Egyszerűsített inkognitómód Üdvözlőüzenet mentése? Új csevegési fiók létrehozásához indítsa újra az alkalmazást. @@ -1048,7 +1048,7 @@ Leállítás? Jelmondat szükséges Privát értesítések - Ön meghívta egy ismerősét + Ön meghívta egy partnerét %s nincs hitelesítve Koppintson ide a kapcsolódáshoz Ennek az eszköznek a neve @@ -1075,7 +1075,7 @@ nem sikerült elküldeni KEZELŐFELÜLET SZÍNEI Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. - Másodlagos + Másodlagos szín SOCKS-PROXY Mentés Újraindítás @@ -1102,7 +1102,7 @@ A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása egy egyszer használható meghívási hivatkozáson keresztül - Csoport-beállítások megadása + Csoportbeállítások megadása a következőn keresztül: %1$s igen Hangüzenet @@ -1159,7 +1159,7 @@ Időszakos értesítések Kihagyott üzenetek A hangüzenetek küldése le van tiltva. - Ismerős nevének beállítása + Partner nevének beállítása Csak Ön tud eltűnő üzeneteket küldeni. Médiatartalom megosztása… Ön: %1$s @@ -1167,7 +1167,7 @@ Színek visszaállítása Mentés Váltás - A kapott hivatkozás beillesztése az ismerőshöz való kapcsolódáshoz… + A kapott hivatkozás beillesztése a partnerhez való kapcsolódáshoz… Beolvasás Port nyitása a tűzfalban indítás… @@ -1184,7 +1184,7 @@ Kézbesítési jelentések küldése SimpleX-cím Koppintson a - Mentés és az ismerős értesítése + Mentés és a partner értesítése Elutasított hívás SOCKS-proxy beállítások QR-kód @@ -1198,7 +1198,7 @@ A fájlok- és a médiatartalmak küldése le van tiltva. Fájl megosztása… Mentés - egy továbbító-kiszolgálón keresztül + egy továbbítókiszolgálón keresztül Megosztás megállítása Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása @@ -1217,7 +1217,7 @@ Rendszer-hitelesítés Böngészőn keresztül Védje meg a csevegési profiljait egy jelszóval! - Csak az ismerőse tud eltűnő üzeneteket küldeni. + Csak a partnere tud eltűnő üzeneteket küldeni. Saját ICE-kiszolgálók QR-kód beolvasása számítógépről SimpleX logó @@ -1225,14 +1225,14 @@ Némítás megszüntetése SimpleX Chat megnyitása a hívás fogadásához Fájlfogadás megállítása? - - értesíti az ismerősöket a törlésről (nem kötelező)\n- profil nevek szóközökkel\n- és még sok más! + - partnerek értesítése a törlésről (nem kötelező)\n- profilnevek szóközökkel\n- és még sok más! Lengyel kezelőfelület Kiszolgáló használata Fogadva: %s SimpleX-zár Mentés és a csoporttagok értesítése Visszaállítás - Csak az ismerőse adhat hozzá reakciókat az üzenetekhez. + Csak a partnere adhat hozzá reakciókat az üzenetekhez. Hangüzenetek Ön elhagyta a csoportot Hangüzenet rögzítése @@ -1256,7 +1256,7 @@ Beállítva 1 nap Felfedés Fogadott üzenetbuborék színe - Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) Az önmegsemmisítő-jelkód módosult! SimpleX Chat-kiszolgálók használatban. SimpleX Chat-kiszolgálók használata? @@ -1265,7 +1265,7 @@ TCP kapcsolat időtúllépése A(z) %1$s nevű profilja meg lesz osztva. Ön már kapcsolódott a következőhöz: %1$s. - A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, ismerősei, csevegési üzenetei és fájljai véglegesen törölve lesznek. + A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, partnerei, csevegési üzenetei és fájljai véglegesen törölve lesznek. Ötletek és javaslatok Figyelmeztetés: néhány adat elveszhet! Koppintson ide az új csevegés indításához @@ -1279,20 +1279,20 @@ fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre - Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. + Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. videóhívás (nem e2e titkosított) Alkalmazás új kapcsolatokhoz Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése a kapcsolattartási címhivatkozáson keresztül a SimpleX a háttérben fut a push értesítések használata helyett.]]> - Az ismerősének online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt az ismerőskérelmet és eltávolíthatja az ismerőst (ezt később ismét megpróbálhatja egy új hivatkozással). + A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a partnerkérelmet és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. - Az ismerősei továbbra is kapcsolódva maradnak. + A partnerei továbbra is kapcsolódva maradnak. A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Nem sikerült hitelesíteni; próbálja meg újra. Az üzenet az összes tag számára moderáltként lesz megjelölve. Értesítések fogadásához adja meg az adatbázis jelmondatát @@ -1301,7 +1301,7 @@ Az üzenet az összes tag számára törölve lesz. A videó nem dekódolható. Próbálja ki egy másik videóval, vagy lépjen kapcsolatba a fejlesztőkkel. Ez a szöveg a „Beállításokban” érhető el - A profilja el lesz küldve az ismerőse számára, akitől ezt a hivatkozást kapta. + A profilja el lesz küldve a partnere számára, akitől ezt a hivatkozást kapta. Az alkalmazás 1 perc után bezárható a háttérben. Ön meghívást kapott a csoportba Engedélyezze a következő párbeszédpanelen az azonnali értesítések fogadásához.]]> @@ -1313,35 +1313,35 @@ Ellenőrizze a hálózati kapcsolatát a következővel: %1$s, és próbálja újra. A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be. Az alkalmazás összeomlott - Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat. + Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat. A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel. Érvénytelen fájlelérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %1$s). + Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %1$s). A fájl fogadása le fog állni. Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani! A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Ön egy egyszer használható meghívási hivatkozást osztott meg inkognitóban - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál. + Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Később engedélyezheti a „Beállításokban” Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s %1$s.]]> Profil felfedése Ez nem egy érvényes kapcsolattartási hivatkozás! - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal. - A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől. - Ez a beállítás csak az Ön jelenlegi csevegő-profiljában lévő üzenetekre vonatkozik + A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. + A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. + Ez a beállítás csak az Ön jelenlegi csevegési profiljában lévő üzenetekre vonatkozik Ön meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Ön meghívást kapott a csoportba - Az ismerőse a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. - Az ismerősei és az üzenetek (kézbesítés után) nem a SimpleX-kiszolgálókon vannak tárolva. + A partnere a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. + A partnerei és az üzenetek (kézbesítés után) nem a SimpleX-kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> - A csevegési profilja el lesz küldve\naz ismerőse számára - Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban + A csevegési profilja el lesz küldve\na partnere számára + Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban %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 @@ -1353,16 +1353,16 @@ - legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s - Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. Onion-kiszolgálók használata opciót „Nemre”, ha a SOCKS-proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később - A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. + 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. Ön a következőre módosította %s szerepkörét: „%s” Csoportmeghívó elutasítva Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. - (a megosztáshoz az ismerősével) + (a megosztáshoz a partnerével) Csoportmeghívó elküldve Átvitel-izoláció módjának frissítése? Átvitel-izoláció @@ -1374,18 +1374,18 @@ Később engedélyezheti őket az „Adatvédelem és biztonság” menüben. Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a „Csevegési profilok” menüben. Fejlesztés és a csevegés megnyitása - Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak. + Engedélyeznie kell a hangüzenetek küldését a partnere számára, hogy hangüzeneteket küldhessenek egymásnak. %1$s nevű csoport tagja.]]> Ön módosította a címet - Az ismerősei engedélyezhetik a teljes üzenet törlést. + A partnerei engedélyezhetik a teljes üzenet törlését. A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. Ha engedélyezni szeretné a hordozható eszköz-alkalmazás társítását a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, miután engedélyezte azt - A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva. + A profilja, a partnerei és az elküldött üzenetei az Ön eszközén vannak tárolva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> Ez a karakterlánc nem egy meghívási hivatkozás! Új csevegés indításához A kapcsolódás már folyamatban van ezen az egyszer használható meghívási hivatkozáson keresztül! - Nem veszíti el az ismerőseit, ha később törli a címét. + Nem veszíti el a partnereit, ha később törli a címét. A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! Ön a következőre módosította a saját szerepkörét: „%s” @@ -1407,35 +1407,35 @@ A kapcsolatkérés el lesz küldve ezen csoporttag számára. Inkognitóprofil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott. Már küldött egy kapcsolatkérést ezen a címen keresztül! - Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: %s. + Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: %s. Amikor az emberek kapcsolatot kérnek, Ön elfogadhatja vagy elutasíthatja azokat. Megjelenítendő üzenet beállítása az új tagok számára! Köszönet a felhasználóknak – hozzájárulás a Weblate-en! - A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz. + A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. Protokoll időtúllépése KB-onként Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. - A profilja csak az ismerőseivel van megosztva. + A profilja csak a partnereivel van megosztva. Néhány kiszolgáló megbukott a teszten: Koppintson ide a csatlakozáshoz Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. - A kézbesítési jelentések engedélyezve vannak %d ismerősnél + A kézbesítési jelentések engedélyezve vannak %d partnernél Küldés a következőn keresztül: Köszönet a felhasználóknak – hozzájárulás a Weblate-en! - A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára. + A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. Bluetooth támogatás és további fejlesztések. Ez a funkció még nem támogatott. Próbálja meg a következő kiadásban. Bejegyzés frissítve: %s Tagok meghívásának kihagyása - Ezek felülbírálhatók az ismerős- és csoport-beállításokban. - Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! + Ezek felülbírálhatók a partner- és csoportbeállításokban. + A partnere, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEzt később módosíthatja. Koppintson ide az inkognitóban való kapcsolódáshoz Jelmondat beállítása az exportáláshoz 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 – hozzájárulás a Weblate-en! - 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ímet. 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 IP-címet, de megfigyelheti a hívás időtartamát. + A továbbítókiszolgáló megvédi az Ön 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 @@ -1452,7 +1452,7 @@ Profil és kiszolgálókapcsolatok Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. A profil aktiválásához koppintson az ikonra. - A kézbesítési jelentések le vannak tiltva %d ismerősnél + A kézbesítési jelentések le vannak tiltva %d partnernél Munkamenet kód Köszönet a felhasználóknak – hozzájárulás a Weblate-en! Kis csoportok (max. 20 tag) @@ -1464,24 +1464,24 @@ A címzettek a beírás közben látják a szövegváltozásokat. Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja módosítani. A jelmondat a beállításokban egyszerű szövegként lesz tárolva, miután módosította azt vagy újraindította az alkalmazást. - A jelenlegi csevegőprofiljához tartozó új kapcsolatok kiszolgálói + A jelenlegi csevegési profiljához tartozó új kapcsolatok kiszolgálói Fogadás a következőn keresztül: Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez. A tag szerepköre a következőre fog módosulni: „%s”. A tag új meghívást fog kapni. profilkép helyőrzője A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! - Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek. + Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek. Bejegyzés frissítve Használati útmutatóban olvasható.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban Az előző üzenet hasítóértéke különbözik. - Ezek a beállítások csak a jelenlegi csevegő-profiljára vonatkoznak + Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub tárolónkban.]]> hiba a tartalom megjelenítésekor hiba az üzenet megjelenítésekor - Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”. + Láthatóvá teheti a SimpleXbeli partnerei számára a „Beállításokban”. Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás. A beillesztett szöveg nem egy SimpleX-hivatkozás. @@ -1489,10 +1489,10 @@ Csevegés indítása? Látható előzmények Alkalmazás jelkód - Ismerős hozzáadása + Partner hozzáadása Koppintson ide a QR-kód beolvasásához Koppintson ide a hivatkozás beillesztéséhez - Ismerős hozzáadása: új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.]]> + Partner hozzáadása: új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.]]> Csoport létrehozása: új csoport létrehozásához.]]> A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. Az előzmények nem lesznek elküldve az új tagok számára. @@ -1597,7 +1597,7 @@ Feltöltés megerősítése Hiba az adatbázis törlésekor Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra. + Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra. Alkalmazásadatok átköltöztetése Adatbázis archiválása Átköltöztetés visszavonása @@ -1657,7 +1657,7 @@ Átköltöztetés befejezve Átköltöztetés egy másik eszközre QR-kód használatával. Átköltöztetés - Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését.]]> + Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> Megpróbálhatja még egyszer. Érvénytelen hivatkozás végpontok közötti kvantumálló titkosítás @@ -1721,11 +1721,11 @@ Profilkép alakzata Négyzet, kör vagy bármi a kettő között. Célkiszolgáló-hiba: %1$s - Továbbító-kiszolgáló: %1$s\nHiba: %2$s + Továbbítókiszolgáló: %1$s\nHiba: %2$s Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. A kiszolgáló verziója nem kompatibilis a hálózati beállításokkal. Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. - Továbbító-kiszolgáló: %1$s\nCélkiszolgáló-hiba: %2$s + Továbbítókiszolgáló: %1$s\nCélkiszolgáló-hiba: %2$s Hiba: %1$s Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. Üzenetkézbesítési figyelmeztetés @@ -1751,12 +1751,12 @@ 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. NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. - Tor vagy VPN nélkül az IP-címe látható lesz a fájlkiszolgálók számára. + Tor vagy VPN nélkül az Ön IP-címe látható lesz 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 IP-címe látható lesz az XFTP-továbbító-kiszolgálók számára:\n%1$s. + 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. Összes színmód Fekete Színmód @@ -1772,7 +1772,7 @@ Csevegés témája Kitöltés Profiltéma - Csevegőlista megjelenítése új ablakban + Csevegési lista megjelenítése új ablakban Világos Világos mód Fogadott válaszüzenet-buborék színe @@ -1785,10 +1785,10 @@ Rendszer Háttérkép kiemelőszíne Háttérkép háttérszíne - További kiemelés 2 + További kiemelőszín 2 Alkalmazás téma Perzsa kezelőfelület - Védje IP-címét az ismerősei által kiválasztott üzenetváltó-továbbító-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. + Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. Ismeretlen kiszolgálókról származó fájlok megerősítése. Javított üzenetkézbesítés Alkalmazás témájának visszaállítása @@ -1816,7 +1816,7 @@ Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel. Nem lehet üzenetet küldeni - A kijelölt csevegés-beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag @@ -1932,49 +1932,49 @@ Letiltás Letiltva Stabil - Hiba a(z) %1$s továbbító-kiszolgálóhoz való kapcsolódáskor. Próbálja meg később. - A(z) %1$s célkiszolgáló verziója nem kompatibilis a(z) %2$s továbbító kiszolgálóval. - A(z) %1$s továbbító-kiszolgáló nem tudott csatlakozni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. - A(z) %1$s célkiszolgáló címe nem kompatibilis a(z) %2$s továbbító-kiszolgáló beállításaival. + Hiba a(z) %1$s továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + A(z) %1$s célkiszolgáló verziója nem kompatibilis a(z) %2$s továbbítókiszolgálóval. + A(z) %1$s továbbítókiszolgáló nem tudott csatlakozni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. + A(z) %1$s célkiszolgáló címe nem kompatibilis a(z) %2$s továbbítókiszolgáló beállításaival. Médiatartalom elhomályosítása Közepes Kikapcsolva Enyhe Erős - A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. - A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. + A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. + A továbbítókiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. hívás - Az ismerős törölve lesz – ez a művelet nem vonható vissza! + A partner törölve lesz – ez a művelet nem vonható vissza! Csak a beszélgetés törlése megnyitás Beszélgetés törölve! - Ismerős törölve! - Archivált ismerősök - Nincsenek szűrt ismerősök + Partner törölve! + Archivált partnerek + Nincsenek szűrt partnerek Hivatkozás beillesztése A hívások le vannak tiltva! - Nem lehet felhívni az ismerőst + Nem lehet felhívni a partnert Nem lehet üzenetet küldeni a csoporttagnak - Kapcsolódás az ismerőshöz, várjon vagy ellenőrizze később! - Törölt ismerős. + Kapcsolódás a partnerhez, várjon vagy ellenőrizze később! + Törölt partner. Nem lehet felhívni a csoporttagot Hívások engedélyezése? Meghívás üzenet Beszélgetés megtartása - Biztosan törli az ismerőst? + Biztosan törli a partnert? kapcsolódás Könnyen elérhető alkalmazás-eszköztárak Törlés értesítés nélkül Beállítások keresés videó - Az „Archivált ismerősökből” továbbra is küldhet üzeneteket neki: %1$s. - Ismerősök - Kérje meg az ismerősét, hogy engedélyezze a hívásokat. + Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %1$s. + Partnerek + Kérje meg a partnerét, hogy engedélyezze a hívásokat. Üzenet küldése a hívások engedélyezéséhez. - Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást. - A(z) %1$s nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. + Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást. + A(z) %1$s nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. Üzenet… Kijelölés Az üzenetek az összes tag számára moderáltként lesznek megjelölve. @@ -1985,7 +1985,7 @@ Az üzenetek az összes tag számára törölve lesznek. Csevegési adatbázis exportálva Kapcsolatok- és kiszolgálók állapotának megjelenítése. - Kapcsolódjon gyorsabban az ismerőseihez. + Kapcsolódjon gyorsabban a partnereihez. Folytatás Ellenőrizze a hálózatát Média- és fájlkiszolgálók @@ -1998,11 +1998,11 @@ Az exportált adatbázist átköltöztetheti. Mentés és újrakapcsolódás Használja az alkalmazást egy kézzel. - Az ismerősök archiválása a későbbi csevegéshez. + A partnerek archiválása a későbbi csevegéshez. TCP kapcsolat Az exportált archívumot elmentheti. Tippek visszaállítása - Csevegőlista átváltása: + Csevegési lista átváltása: Ezt a „Megjelenés” menüben módosíthatja. Új médiabeállítások Lejátszás a csevegési listából. @@ -2090,18 +2090,18 @@ Hibák a kiszolgálók konfigurációjában. Hiba a feltételek elfogadásakor Kézbesítetlen üzenetek - A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van. + A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön partnere lehet, hogy offline állapotban van. Nincs üzenet Ez az üzenet törölve lett vagy még nem érkezett meg. Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. Egyszer használható meghívási hivatkozás megosztása egy baráttal - egyetlen ismerőssel használható – személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> - Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. + egyetlen partnerrel használható – személyesen vagy bármilyen üzenetváltón keresztül megosztható.]]> + Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. Kapcsolatbiztonság - A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. - A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat az ismerősével. + A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetváltón keresztül. + A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével. A közösségi médiához Vagy a privát megosztáshoz SimpleX-cím vagy egyszer használható meghívási hivatkozás? @@ -2109,7 +2109,7 @@ Kiszolgáló-üzemeltetők Hálózati üzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. - Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. + Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. Jelölje ki a használni kívánt hálózati üzemeltetőket. Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. @@ -2165,7 +2165,7 @@ Javított csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése - A jelenlegi csevegőprofiljához tartozó új fájlok kiszolgálói + A jelenlegi csevegési profiljához tartozó új fájlok kiszolgálói Vagy archívumfájl importálása Távoli hordozható eszközök Xiaomi eszközök: engedélyezze az automatikus indítást a rendszerbeállításokban, hogy az értesítések működjenek.]]> @@ -2187,7 +2187,7 @@ A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! Csevegés törlése? Csevegés elhagyása - Csak a csevegés tulajdonosai módosíthatják a csevegés-beállításokat. + Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat. Könnyen elérhető csevegési eszköztár A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! Csevegés @@ -2229,7 +2229,7 @@ Hiba a csevegési lista betöltésekor Hiba a csevegési lista frissítésekor Üzleti - Ismerősök + Partnerek Kedvencek Nincsenek csevegések Nem találhatók csevegések @@ -2301,7 +2301,7 @@ Olvasatlan említések Összes némítása Legfeljebb %1$s tagot említhet meg egy üzenetben! - Lehetővé teszi az üzenetek jelentését a moderátorok felé. + Az üzenetek jelentése a moderátorok felé engedélyezve van. Az üzenetek a moderátorok felé történő jelentésének megtiltása. Az összes jelentés archiválása? %d jelentés archiválása? @@ -2319,9 +2319,11 @@ Gyorsabb csoporttörlés. Segítsen az adminisztrátoroknak a csoportjaik moderálásában. Privát nevek a médiafájlokhoz. - Üzenetek lejárati idejének módosítása a csevegésekben. + Üzenetek eltűnési idejének módosítása a csevegésekben. Továbbfejlesztett, gyorsabb csoportok Továbbfejlesztett adatvédelem és biztonság Kapjon értesítést, ha megemlítik. Csevegések listákba szervezése + elutasítva + elutasítva 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 3afc376154..a1bf6014dc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2359,6 +2359,8 @@ Prestazioni dei gruppi migliorate Organizza le chat in elenchi Imposta la scadenza dei messaggi nelle chat. - Invia resoconti privati + Invia segnalazioni private Nomi privati dei file multimediali. + rifiutato + rifiutato diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index 410c65b2de..f69ec96d7c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -1501,4 +1501,26 @@ 1 년 리스트에 추가하기 보관된 보고서 + 보관된 신고: %s + 커뮤니티 가이드라인 위반 + 신고를 보관하시겠습니까? + 신고 보관 + 연락처 + 연결이 차단되었습니다 + 연결이 서버 운영자에 의해 차단되었습니다:\n%1$s + 비즈니스 + 순서 변경 + 문의 + 연결이 준비되지 않았습니다. + 자동 메시지 삭제를 변경하시겠습니까? + 그룹 성능 향상 + 모든 신고가 보관됩니다. + 모든 신고를 보관하시겠습니까? + 신고 보관 + 목록 변경 + 메시지를 운영자에게 신고할 수 있도록 허용합니다. + 더 나은 개인정보 보호 및 보안 + 신고 %d건을 보관하시겠습니까? + 이 표시 이름은 유효하지 않습니다. 다른 이름을 선택하세요. + 데스크톱에 연결하는 동안 시간 초과되었습니다 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index edfb18bc89..997979d039 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -2356,6 +2356,8 @@ Vermeld leden 👋 Organiseer chats in lijsten Namen van persoonlijke mediabestanden. - Privérapporten verzenden + Verstuur rapporten privé Sneller verzenden van berichten. + afgewezen + afgewezen 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 eef0870a5b..1280c67bb0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -2365,4 +2365,29 @@ Ошибка при загрузке списков чатов Контакты Название списка и эмодзи должны быть разными для всех списков. - \ No newline at end of file + Пожаловаться + Спам + Пожаловаться на спам: это увидит только модератор группы + Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены + Получайте уведомления от упоминаний. + Жалобы на сообщения запрещены в этой группе. + Пожаловаться на нарушение: это увидит только модератор группы + Установить имя чата… + Улучшенная производительность групп + Приватные названия медиафайлов. + Спам + Жалобы + Непрочитанные упоминания + Да + Упоминайте участников 👋 + Улучшенная приватность и безопасность + Ускоренно удаление групп. + Ускоренна отправка сообщений. + Помогайте администраторам модерировать их группы. + Организуйте чаты в списки + Отправляйте приватные жалобы + Установите время исчезания сообщений в чатах. + Вы можете упомянуть до %1$s пользователей в одном сообщении! + Причина жалобы? + Эта жалоба будет арчивирована для вас. + 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 d1afbe4c38..3c6566552d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -2346,4 +2346,18 @@ Повідомлення модераторам заборонено в цій групі. Учасники можуть повідомляти повідомлення модераторам. Дозволити повідомляти повідомлення модераторам. + відхилено + відхилено + Краща продуктивність груп + Не пропускайте важливі повідомлення. + Отримуйте сповіщення, коли вас згадують. + Допоможіть адміністраторам модерувати їхні групи. + Організовуйте чати в списки + Приватні імена медіа-файлів. + Надсилати приватні звіти + Краща конфіденційність та безпека + Швидша відправка повідомлень. + Згадуйте учасників 👋 + Встановлюйте термін придатності повідомлень у чатах. + Швидше видалення груп. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index acc3508772..cdc9721282 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -2320,4 +2320,18 @@ Cho phép báo cáo tin nhắn tới các kiểm duyệt viên. Cấm báo cáo tin nhắn tới các kiểm duyệt viên. Báo cáo tin nhắn bị cấm trong nhóm này. + Nhận thông báo khi được nhắc đến. + Giúp các quản trị viên quản lý các nhóm của họ. + Nhắc đến các thành viên 👋 + Sắp xếp các cuộc trò chuyện thành danh sách + Xóa nhóm nhanh hơn. + Gửi tin nhắn nhanh hơn. + Không còn bỏ lỡ các tin nhắn quan trọng nữa. + Bảo mật và riêng tư hơn + Nhóm hoạt động hiệu quả hơn + Gửi các báo cáo riêng tư + đã từ chối + đã từ chối + Thiết lập giờ hết hạn cho tin nhắn trong các cuộc trò chuyện. + Tên các tệp tin đa phương tiện riêng tư. 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 97f1ef9d2d..77918b1c8f 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 @@ -2345,4 +2345,6 @@ 私密媒体文件名。 发送私下举报 在聊天中设置消息过期时间。 + 被拒绝 + 被拒绝 From 518ab2cd3ec18a46344a69a3dbdcdf48e49fd8f0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 3 Mar 2025 22:13:45 +0000 Subject: [PATCH 106/289] website: translations (#5706) * blocked words * blocked words * XGrpLinkReject * core: 6.3.0.6 (simplexmq 6.3.0.6) --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * core: communicate group join rejection (#5661) * ui: rejected group previews (#5665) * Translated using Weblate (Hungarian) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Czech) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (258 of 258 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% (258 of 258 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% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: summoner001 Co-authored-by: zenobit --- website/langs/cs.json | 10 +++--- website/langs/hu.json | 78 +++++++++++++++++++++---------------------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/website/langs/cs.json b/website/langs/cs.json index 0e19fdbfe4..5d7727ba0a 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -209,7 +209,7 @@ "if-you-already-installed-simplex-chat-for-the-terminal": "Pokud jste již nainstalovali SimpleX Chat pro terminál", "if-you-already-installed": "Pokud jste již nainstalovali", "simplex-network-1-desc": "Všechny zprávy se odesílají přes servery, což zajišťuje lepší soukromí metadat a spolehlivé asynchronní doručování zpráv, přičemž se zamezuje mnoha", - "simplex-private-card-1-point-1": "Protokol s dvojitou západkou —
zprávy OTR s dokonalým dopředným utajením a obnovení po vloupání.", + "simplex-private-card-1-point-1": "Double-ratchet protokol —
OTR messaging s dokonalým dopředným utajením a obnovou po vloupání.", "guide-dropdown-1": "Rychlý start", "guide-dropdown-2": "Odesílání zpráv", "guide-dropdown-3": "Tajné skupiny", @@ -233,8 +233,8 @@ "back-to-top": "Zpět nahoru", "newer-version-of-eng-msg": "V angličtině je novější verze této stránky.", "glossary": "Rejstřík", - "hero-overlay-3-title": "Posouzení bezpečnosti", - "hero-overlay-3-textlink": "Posouzení bezpečnosti", + "hero-overlay-3-title": "Hodnocení zabezpečení", + "hero-overlay-3-textlink": "Hodnocení zabezpečení", "hero-overlay-card-3-p-1": "Trail of Bits je přední bezpečnostní a technologické poradenství, jejichž klienti zahrnují velké technologické firmy, vládní agentury a významné blockchainové projekty.", "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", @@ -254,5 +254,7 @@ "docs-dropdown-9": "Ke stažení", "docs-dropdown-10": "Transparentnost", "docs-dropdown-11": "FAQ (často kladené dotazy)", - "docs-dropdown-12": "Bezpečnost" + "docs-dropdown-12": "Bezpečnost", + "hero-overlay-card-3-p-3": "Trail of Bits přezkoumala kryptografický design síťových protokolů SimpleX v červenci 2024. Přečíst více.", + "docs-dropdown-14": "SimpleX pro podnikání" } diff --git a/website/langs/hu.json b/website/langs/hu.json index 1459c12a03..ae9ee1d12b 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -5,14 +5,14 @@ "blog": "Blog", "features": "Funkciók", "why-simplex": "Miért válassza a SimpleXet", - "simplex-privacy": "SimpleX-adatvédelem", - "simplex-network": "SimpleX-hálózat", + "simplex-privacy": "A SimpleX adatvédelme", + "simplex-network": "A SimpleX-hálózat", "simplex-explained": "A Simplex bemutatása", "simplex-explained-tab-1-text": "1. Felhasználói élmény", "simplex-explained-tab-2-text": "2. Hogyan működik", "simplex-explained-tab-3-text": "3. Mit látnak a kiszolgálók", - "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetküldőben.", - "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú üzenet sorbaállítással és felhasználói profil-azonosítók nélkül?", + "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetváltón.", + "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú üzenet-sorballítással és felhasználói profilazonosítók nélkül?", "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 egyirányú üzeneteket továbbítanak, 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.", @@ -21,17 +21,17 @@ "chat-protocol": "Csevegésprotokoll", "donate": "Támogatás", "copyright-label": "© 2020-2024 SimpleX | Nyílt forráskódú projekt", - "simplex-chat-protocol": "SimpleX Chat-protokoll", + "simplex-chat-protocol": "A SimpleX Chat-protokoll", "terminal-cli": "Terminál CLI", "terms-and-privacy-policy": "Adatvédelmi irányelvek", "hero-header": "Újradefiniált adatvédelem", - "hero-subheader": "Az első üzenetküldő
felhasználói azonosítók nélkül", + "hero-subheader": "Az első üzenetváltó
felhasználói azonosítók nélkül", "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", "hero-overlay-1-textlink": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "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 az ismerőséhez egy egyszer használható QR-kód segítségével, személyesen vagy videokapcsolaton keresztül. Ugyanakkor egy meghívó-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 videokapcsolaton 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", @@ -54,7 +54,7 @@ "simplex-private-9-title": "Egyirányú
üzenetsorbaállítás", "simplex-private-10-title": "Ideiglenes, névtelen, páronkénti azonosítók", "simplex-private-card-1-point-1": "Double-Ratchet-protokoll —
OTR-üzenetküldés, sérülés utáni titkosság-védelemmel és -helyreállítással.", - "simplex-private-card-1-point-2": "NaCL cryptobox minden egyes üzenet sorbaállításához, hogy megakadályozza a forgalom korrelációját az üzenet-sorbaállítások között, ha a TLS veszélybe kerül.", + "simplex-private-card-1-point-2": "NaCL cryptobox minden egyes üzenet sorbaállításához, hogy megakadályozza a forgalom korrelációját az üzenetsorbaállítások között, ha a TLS veszélybe kerül.", "simplex-private-card-2-point-1": "Kiegészítő kiszolgáló titkosítási réteg a címzettnek történő kézbesítéshez, hogy megakadályozza a fogadott és az elküldött kiszolgálóforgalom közötti korrelációt, ha a TLS veszélybe kerül.", "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.", @@ -111,68 +111,68 @@ "privacy-matters-overlay-card-3-p-1": "Mindenkinek törődnie kell a magánélet és a kommunikáció biztonságával — az ártalmatlan beszélgetések veszélybe sodorhatják, még akkor is, ha nincs semmi rejtegetnivalója.", "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", - "privacy-matters-overlay-card-3-p-4": "Nem elég, ha csak egy végpontok között titkosított üzenetküldőt használunk, mindannyiunknak olyan üzenetküldőket kell használnunk, amelyek védik személyes ismerőseink magánéletét — akikkel kapcsolatban állunk.", + "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ő platformoktó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 — nem tudjuk, hogy hányan használják a SimpleX-kiszolgálóinkat.", - "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. A SimpleX használata olyan, mintha minden egyes kapcsolatnak más-más “eldobható” e-mail címe vagy telefonja lenne és nem kell ezeket gondosan kezelni.", + "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. A SimpleX használata olyan, mintha minden egyes kapcsolatnak más-más “eldobható” e-mail-címe vagy telefonja lenne és nem kell ezeket gondosan kezelni.", "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 platform 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-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, 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 nem kötelező felhasználói cím esetében is, bár spam kapcsolatfelvételi kérések küldésére használható, megváltoztathatja vagy teljesen törölheti azt 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.", - "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-továbbító-kiszolgálókon tartózkodnak, amíg be nem érkeznek a címzetthez, majd véglegesen törlődnek onnan.", + "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az általunk biztosított, előre konfigurált kiszolgálókat használják.", "simplex-unique-overlay-card-4-p-2": "A SimpleX platform nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", - "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX-platformra való fejlesztést fontolgatja, például a SimpleX-alkalmazások felhasználóinak szánt chatbotot, vagy a SimpleX Chat-könvtárbot integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", + "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX-platformra való fejlesztést fontolgatja, például a SimpleX-alkalmazások felhasználóinak szánt chatbotot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", "simplex-unique-card-1-p-1": "A SimpleX védi az ön profiljához tartozó kapcsolatait és metaadatait, elrejtve azokat a SimpleX platform kiszolgálói és a megfigyelők elől.", "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő platformtól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", "simplex-unique-card-2-p-1": "Mivel a SimpleX platformon nincs azonosítója vagy állandó címe, 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-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.", - "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-továbbító-kiszolgálókon tartózkodnak, amíg meg nem érkeznek a címzetthez, majd ezt követően véglegesen törlésre kerülnek.", + "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", - "simplex-unique-card-4-p-2": "Használhatja a SimpleX-et saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", + "simplex-unique-card-4-p-2": "Használhatja a SimpleXet a saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", "join": "Csatlakozás", - "we-invite-you-to-join-the-conversation": "Meghívjuk önt, hogy csatlakozzon a beszélgetéshez", + "we-invite-you-to-join-the-conversation": "Meghívjuk Önt, hogy csatlakozzon a beszélgetéshez", "join-the-REDDIT-community": "Csatlakozzon a REDDIT közösséghez", "join-us-on-GitHub": "Csatlakozzon hozzánk a GitHubon", "donate-here-to-help-us": "Adományozzon és segítsen nekünk", "sign-up-to-receive-our-updates": "Regisztráljon az oldalra, hogy megkapja frissítéseinket", - "enter-your-email-address": "Adja meg az e-mail címét", - "get-simplex": "SimpleX Desktop alkalmazás letöltése", + "enter-your-email-address": "Adja meg az e-mail-címét", + "get-simplex": "A SimpleX számítógép-alkalmazásának letöltése", "why-simplex-is": "A SimpleX mitől", "unique": "egyedülálló", "learn-more": "Tudjon meg többet", "more-info": "További információ", "hide-info": "Információ elrejtése", - "contact-hero-subheader": "Szkennelje be a QR-kódot a SimpleX Chat alkalmazással telefonján vagy táblagépén.", - "contact-hero-p-1": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldi el a hálózaton keresztül az oldal megtekintésekor — ezeket a hivatkozás URL-jének hash-töredéke tartalmazza.", + "contact-hero-subheader": "Olvassa be a QR-kódot a SimpleX Chat alkalmazással a telefonjával vagy táblagépével.", + "contact-hero-p-1": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldi el a hálózaton keresztül, amikor megtekinti ezt az oldalt — azokat, a hivatkozás webcímének hasítóérték-töredéke tartalmazza.", "contact-hero-p-2": "Még nem töltötte le a SimpleX Chatet?", - "contact-hero-p-3": "Az alkalmazás letöltéséhez használja az alábbi linkeket.", + "contact-hero-p-3": "Az alkalmazás letöltéséhez használja az alábbi hivatkozásokat.", "scan-qr-code-from-mobile-app": "QR-kód beolvasása mobilalkalmazásból", "to-make-a-connection": "A kapcsolat létrehozásához:", "install-simplex-app": "Telepítse a SimpleX alkalmazást", "open-simplex-app": "Simplex alkalmazás megnyitása", "tap-the-connect-button-in-the-app": "Koppintson a „kapcsolódás” gombra az alkalmazásban", - "scan-the-qr-code-with-the-simplex-chat-app": "A QR-kód beolvasása a SimpleX Chat alkalmazással", - "scan-the-qr-code-with-the-simplex-chat-app-description": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldjük el a hálózaton keresztül, amikor ezt az oldalt megtekinti —
ezek a hivatkozás URL-jének hash-töredékében szerepelnek.", - "installing-simplex-chat-to-terminal": "A SimpleX chat telepítése terminálba", + "scan-the-qr-code-with-the-simplex-chat-app": "Olvassa be a QR-kódot a SimpleX Chat alkalmazással", + "scan-the-qr-code-with-the-simplex-chat-app-description": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldjük el a hálózaton keresztül, amikor ezt az oldalt megtekinti —
ezek a hivatkozás webcímének hasítóérték-töredékében szerepelnek.", + "installing-simplex-chat-to-terminal": "A SimpleX chat telepítése a terminálhoz", "use-this-command": "Használja ezt a parancsot:", - "see-simplex-chat": "Lásd SimpleX Chat", + "see-simplex-chat": "Az utasításokat megtekintheti a SimpleX Chat", "connect-in-app": "Kapcsolódás az alkalmazásban", - "the-instructions--source-code": "az utasításokat, hogyan töltse le vagy fordítsa le a forráskódból.", - "if-you-already-installed-simplex-chat-for-the-terminal": "Ha már telepítette a SimpleX Chat-et a terminálba", + "the-instructions--source-code": "és választ kaphat arra, hogy hogyan töltse le vagy fordítsa le a forráskódból.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Ha már telepítette a SimpleX Chatet a terminálhoz", "if-you-already-installed": "Ha már telepítette a", - "simplex-chat-for-the-terminal": "SimpleX Chat-et a terminálba", + "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-label": "Győződjön meg róla, hogy az üzenetküldő amit használ nem fér hozzá az adataidhoz!", + "privacy-matters-section-label": "Győződjön meg arról, hogy az üzenetváltó amit használ nem fér hozzá az adataidhoz!", "simplex-private-section-header": "Mitől lesz a SimpleX privát", "simplex-network-section-header": "SimpleX hálózat", "simplex-network-section-desc": "A Simplex Chat a P2P és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", - "simplex-network-2-desc": "A SimpleX-továbbító-kiszolgálók NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM csatlakoznak egymáshoz, és NINCS kiszolgálókönyvtár.", + "simplex-network-2-desc": "A SimpleX továbbítókiszolgálói NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM kapcsolódnak egymáshoz, és NINCS kiszolgálókönyvtár.", "simplex-network-3-header": "SimpleX hálózat", "simplex-network-3-desc": "a kiszolgálók egyirányú üzenet várakoztatásokat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", "comparison-section-header": "Összehasonlítás más protokollokkal", @@ -192,7 +192,7 @@ "comparison-section-list-point-1": "Általában telefonszám alapján, néhány esetben felhasználónév alapján", "comparison-section-list-point-2": "DNS-alapú címek", "comparison-section-list-point-3": "Nyilvános kulcs vagy más globális egyedi azonosító", - "comparison-section-list-point-4a": "A SimpleX-továbbító-kiszolgálók nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", + "comparison-section-list-point-4a": "A SimpleX továbbítókiszolgálói nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", "comparison-section-list-point-4": "Ha az üzemeltetett kiszolgálók veszélybe kerülnek. Hitelesítse a biztonsági kódot a Signal vagy más biztonságos üzenetküldő alkalmazás segítségével a támadások veszélyeinek csökkentésére", "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált - egyetlen hálózatként működnek", @@ -201,7 +201,7 @@ "guide-dropdown-1": "Gyors indítás", "guide-dropdown-2": "Üzenetek küldése", "guide-dropdown-3": "Titkos csoportok", - "guide-dropdown-4": "Csevegő profilok", + "guide-dropdown-4": "Csevegési profilok", "guide-dropdown-5": "Adatkezelés", "guide-dropdown-6": "Hang- és videó hívások", "guide-dropdown-7": "Adatvédelem és biztonság", @@ -209,11 +209,11 @@ "guide": "Útmutató", "docs-dropdown-1": "SimpleX platform", "docs-dropdown-2": "Android fájlok elérése", - "docs-dropdown-3": "Hozzáférés a csevegő adatbázishoz", - "docs-dropdown-8": "SimpleX jegyzék szolgáltatás", + "docs-dropdown-3": "Hozzáférés a csevegési adatbázishoz", + "docs-dropdown-8": "SimpleX jegyzékszolgáltatás", "docs-dropdown-9": "Letöltések", - "f-droid-page-simplex-chat-repo-section-text": "Ha hozzá szeretné adni az F-Droid klienséhez, olvassa be a QR-kódot, vagy használja ezt az URL-t:", - "signing-key-fingerprint": "Aláíró kulcs ujjlenyomata (SHA-256)", + "f-droid-page-simplex-chat-repo-section-text": "Ha hozzá szeretné adni az F-Droid klienséhez, olvassa be a QR-kódot, vagy használja ezt a webcímet:", + "signing-key-fingerprint": "Az aláírókulcs ujjlenyomata (SHA-256)", "f-droid-org-repo": "F-Droid.org tároló", "stable-versions-built-by-f-droid-org": "F-Droid.org által készített stabil verziók", "releases-to-this-repo-are-done-1-2-days-later": "A kiadások ebben a tárolóban néhány napot késnek", @@ -221,8 +221,8 @@ "jobs": "Csatlakozzon a csapathoz", "please-enable-javascript": "Engedélyezze a JavaScriptet a QR-kód megjelenítéséhez.", "please-use-link-in-mobile-app": "Használja a mobilalkalmazásban található hivatkozást", - "contact-hero-header": "Kapott egy címet a SimpleX Chat-en való kapcsolódáshoz", - "invitation-hero-header": "Kapott egy egyszer használatos hivatkozást a SimpleX Chat-en való kapcsolódáshoz", + "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, pl. WebSocketsen keresztül is működik.", "simplex-private-card-4-point-2": "A SimpleX TORon 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.", @@ -232,7 +232,7 @@ "simplex-network-overlay-card-1-p-1": "A P2P üzenetküldő protokollok és alkalmazások számos problémával küzdenek, amelyek miatt kevésbé megbízhatóak, mint a SimpleX, bonyolultabb az elemzésük és többféle támadással szemben sebezhetőek.", "chat-bot-example": "Chat bot példa", "simplex-private-3-title": "Biztonságos, hitelesített
TLS adatátvitel", - "github-repository": "GitHub tároló", + "github-repository": "GitHub tárolójában", "tap-to-close": "Koppintson a bezáráshoz", "simplex-network-1-header": "A P2P hálózatokkal ellentétben", "simplex-network-1-overlay-linktext": "a P2P hálózat problémái", @@ -253,7 +253,7 @@ "back-to-top": "Vissza a tetejére", "glossary": "Fogalomtár", "simplex-chat-via-f-droid": "SimpleX Chat az F-Droidon keresztül", - "simplex-chat-repo": "SimpleX Chat tároló", + "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.", "docs-dropdown-14": "SimpleX üzleti célra" From 81407106601482e382b6c884774bb84045a49ae6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Mar 2025 23:23:25 +0000 Subject: [PATCH 107/289] 6.3-beta.7: ios 267, android 278, desktop 95 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++++++++---------- apps/multiplatform/gradle.properties | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2091b4b862..957f58d5f5 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2012,7 +2012,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2053,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2073,7 +2073,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2098,7 +2098,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2135,7 +2135,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2172,7 +2172,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2223,7 +2223,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2274,7 +2274,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2308,7 +2308,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 266; + CURRENT_PROJECT_VERSION = 267; 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 ea0516fdaa..7bc1da0b97 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-beta.6 -android.version_code=277 +android.version_name=6.3-beta.7 +android.version_code=278 -desktop.version_name=6.3-beta.6 -desktop.version_code=94 +desktop.version_name=6.3-beta.7 +desktop.version_code=95 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 257208a99b56c71aa12f957f09a18a3264135224 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:20:14 +0700 Subject: [PATCH 108/289] ios: fix toolbar in chatList on iOS 15 (#5707) --- .../Shared/Views/ChatList/ChatListView.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 863bfb1ae2..93c18f28cc 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -148,7 +148,10 @@ struct ChatListView: View { @State private var userPickerShown: Bool = false @State private var sheet: SomeSheet? = nil @StateObject private var chatTagsModel = ChatTagsModel.shared - + + // iOS 15 is required it to show/hide toolbar while chat is hidden/visible + @State private var viewOnScreen = true + @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @@ -203,7 +206,17 @@ struct ChatListView: View { .navigationBarHidden(searchMode || oneHandUI) } .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .onDisappear() { activeUserPickerSheet = nil } + .onAppear { + if #unavailable(iOS 16.0), !viewOnScreen { + viewOnScreen = true + } + } + .onDisappear { + activeUserPickerSheet = nil + if #unavailable(iOS 16.0) { + viewOnScreen = false + } + } .refreshable { AlertManager.shared.showAlert(Alert( title: Text("Reconnect servers?"), @@ -258,7 +271,7 @@ struct ChatListView: View { } } else { if oneHandUI { - content().toolbar { bottomToolbarGroup } + content().toolbar { bottomToolbarGroup() } } else { content().toolbar { topToolbar } } @@ -286,9 +299,9 @@ struct ChatListView: View { } } - @ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent { + @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 - ToolbarItemGroup(placement: .bottomBar) { + ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) { leadingToolbarItem.padding(.bottom, padding) Spacer() SubsStatusIndicator().padding(.bottom, padding) From 3425bd0826712d06b9488e35b1a7264a40eb465a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:42:59 +0400 Subject: [PATCH 109/289] ui: fix "View conditions" view on onboarding offering to accept conditions when no operators are selected (#5710) * ios: fix "View conditions" view on onboarding * kotlin --- apps/ios/Shared/ContentView.swift | 3 +- .../Onboarding/ChooseServerOperators.swift | 8 +-- .../Views/Onboarding/WhatsNewView.swift | 3 +- .../NetworkAndServers/NetworkAndServers.swift | 67 ++++++++++++------- .../common/views/chatlist/ChatListView.kt | 1 - .../views/onboarding/ChooseServerOperators.kt | 8 +-- .../common/views/onboarding/WhatsNewView.kt | 1 - .../networkAndServers/NetworkAndServers.kt | 27 +++++--- 8 files changed, 66 insertions(+), 52 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 5418f4bcdd..65631954e5 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -303,8 +303,7 @@ struct ContentView: View { case .updatedConditions: UsageConditionsView( currUserServers: Binding.constant([]), - userServers: Binding.constant([]), - updated: true + userServers: Binding.constant([]) ) .modifier(ThemedBackground(grouped: true)) .task { await setConditionsNotified_() } diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 24379cf9e3..8523336d2b 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -161,12 +161,8 @@ struct ChooseServerOperators: View { case .showInfo: ChooseServerOperatorsInfoView() case .showConditions: - UsageConditionsView( - currUserServers: Binding.constant([]), - userServers: Binding.constant([]), - updated: false - ) - .modifier(ThemedBackground(grouped: true)) + SimpleConditionsView() + .modifier(ThemedBackground(grouped: true)) } } .frame(maxHeight: .infinity, alignment: .top) diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 4547c6d20a..f2b4dd7928 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -647,8 +647,7 @@ struct WhatsNewView: View { case .showConditions: UsageConditionsView( currUserServers: Binding.constant([]), - userServers: Binding.constant([]), - updated: true + userServers: Binding.constant([]) ) .modifier(ThemedBackground(grouped: true)) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 7d8424a67d..6f4710396a 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -20,11 +20,11 @@ private enum NetworkAlert: Identifiable { } private enum NetworkAndServersSheet: Identifiable { - case showConditions(updated: Bool) + case showConditions var id: String { switch self { - case let .showConditions(updated): return "showConditions \(updated)" + case .showConditions: return "showConditions" } } } @@ -169,11 +169,10 @@ struct NetworkAndServers: View { } .sheet(item: $sheetItem) { item in switch item { - case let .showConditions(updated): + case .showConditions: UsageConditionsView( currUserServers: $ss.servers.currUserServers, - userServers: $ss.servers.userServers, - updated: updated + userServers: $ss.servers.userServers ) .modifier(ThemedBackground(grouped: true)) } @@ -219,8 +218,7 @@ struct NetworkAndServers: View { private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View { Button { - let updated = if case .review = conditionsAction { true } else { false } - sheetItem = .showConditions(updated: updated) + sheetItem = .showConditions } label: { switch conditionsAction { case .review: @@ -237,30 +235,26 @@ struct UsageConditionsView: View { @EnvironmentObject var theme: AppTheme @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] - var updated: Bool var body: some View { VStack(alignment: .leading, spacing: 20) { - HStack { - if updated { - Text("Updated conditions").font(.largeTitle).bold() - } else { - Text("Conditions of use").font(.largeTitle).bold() - Spacer() - conditionsLinkButton() - } - } - .padding(.top) - .padding(.top) - switch ChatModel.shared.conditions.conditionsAction { case .none: + regularConditionsHeader() + .padding(.top) + .padding(.top) ConditionsTextView() .padding(.bottom) .padding(.bottom) case let .review(operators, deadline, _): + HStack { + Text("Updated conditions").font(.largeTitle).bold() + } + .padding(.top) + .padding(.top) + Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") ConditionsTextView() VStack(spacing: 8) { @@ -272,10 +266,8 @@ struct UsageConditionsView: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 32) - if updated { - conditionsDiffButton(.footnote) - } - } else if updated { + conditionsDiffButton(.footnote) + } else { conditionsDiffButton() .padding(.top) } @@ -285,6 +277,9 @@ struct UsageConditionsView: View { case let .accepted(operators): + regularConditionsHeader() + .padding(.top) + .padding(.top) Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") ConditionsTextView() .padding(.bottom) @@ -340,6 +335,30 @@ struct UsageConditionsView: View { } } +private func regularConditionsHeader() -> some View { + HStack { + Text("Conditions of use").font(.largeTitle).bold() + Spacer() + conditionsLinkButton() + } +} + +struct SimpleConditionsView: View { + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + regularConditionsHeader() + .padding(.top) + .padding(.top) + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal, 25) + .frame(maxHeight: .infinity) + } +} + func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { let userServersToValidate = userServers.wrappedValue Task { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index e551ed9635..a6774c6870 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -148,7 +148,6 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow - UsageConditionsView( - currUserServers = remember { mutableStateOf(emptyList()) }, - userServers = remember { mutableStateOf(emptyList()) }, - updated = false, - close = close, - rhId = null, - ) + SimpleConditionsView(rhId = null) } } } else if (onboarding || reviewForOperators.isEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 96e6eb2294..a4eaf172d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -169,7 +169,6 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool UsageConditionsView( userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), - updated = true, close = close, rhId = rhId ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index c211ba4c66..98f671ddc4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -188,7 +188,6 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { UsageConditionsView( currUserServers, userServers, - updated = conditionsAction is UsageConditionsAction.Review, close, rhId ) @@ -712,7 +711,6 @@ private fun UnsavedChangesIndicator() { fun UsageConditionsView( currUserServers: MutableState>, userServers: MutableState>, - updated: Boolean, close: () -> Unit, rhId: Long? ) { @@ -773,17 +771,16 @@ fun UsageConditionsView( } ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { - val title = if (updated) MR.strings.operator_updated_conditions else MR.strings.operator_conditions_of_use - AppBarTitle(stringResource(title), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) when (val conditionsAction = chatModel.conditions.value.conditionsAction) { is UsageConditionsAction.Review -> { + AppBarTitle(stringResource(MR.strings.operator_updated_conditions), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) if (conditionsAction.operators.isNotEmpty()) { ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) } Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { ConditionsTextView(rhId) } - AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null || updated) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2) + AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, DEFAULT_PADDING_HALF) if (conditionsAction.deadline != null) { SectionTextFooter( text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))), @@ -791,13 +788,12 @@ fun UsageConditionsView( ) Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) } - if (updated) { - ConditionsDiffButton() - Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) - } + ConditionsDiffButton() + Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) } is UsageConditionsAction.Accepted -> { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) if (conditionsAction.operators.isNotEmpty()) { ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) } @@ -807,6 +803,7 @@ fun UsageConditionsView( } else -> { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { ConditionsTextView(rhId) } @@ -815,6 +812,18 @@ fun UsageConditionsView( } } +@Composable +fun SimpleConditionsView( + rhId: Long? +) { + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } +} + @Composable fun ServersErrorFooter(errStr: String) { Row( From 8c7df76c24516df6188e1538c39ab04f0b126714 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 5 Mar 2025 11:20:30 +0000 Subject: [PATCH 110/289] directory: command to disable all spam filters (#5709) * directory: command to disable all spam filters * correct syntax * move deviceName to core opts --- .../src/Broadcast/Options.hs | 1 - .../src/Directory/Events.hs | 2 +- .../src/Directory/Options.hs | 1 - .../src/Directory/Service.hs | 2 +- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Mobile.hs | 2 +- src/Simplex/Chat/Options.hs | 18 +++++++++--------- tests/ChatClient.hs | 2 +- 8 files changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index e695b5069d..8107b664c4 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -75,7 +75,6 @@ mkChatOpts :: BroadcastBotOpts -> ChatOpts mkChatOpts BroadcastBotOpts {coreOptions} = ChatOpts { coreOptions, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 89099ab9df..ed4204abb7 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -235,7 +235,7 @@ directoryCmdP = acceptancePresetsP = spacesP *> A.choice - [ "no" $> noJoinFilter, + [ "off" $> noJoinFilter, "basic" $> basicJoinFilter, ("moderate" <|> "mod") $> moderateJoinFilter, "strong" $> strongJoinFilter diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 5ee52249ac..2c26905e79 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -158,7 +158,6 @@ mkChatOpts :: DirectoryOpts -> ChatOpts mkChatOpts DirectoryOpts {coreOptions} = ChatOpts { coreOptions, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 7c54f344ad..89006c9211 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -719,7 +719,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName "", -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." - "Or use */filter " <> tshow gId <> " [name] [captcha]* to configure filter." + "Use */filter " <> tshow gId <> " [name] [captcha]* to enable and */filter " <> tshow gId <> " off* to disable filter." ] showCondition = \case Nothing -> "_disabled_" diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bf07e4ae51..001e2fde1b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -194,7 +194,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable, yesToUpMigrations}, deviceName, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 3a61e6cdff..502dbc98d0 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -204,10 +204,10 @@ mobileChatOpts dbOptions = logAgent = Nothing, logFile = Nothing, tbqSize = 4096, + deviceName = Nothing, highlyAvailable = False, yesToUpMigrations = False }, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index c58c792819..de657a9d74 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -38,7 +38,6 @@ import Simplex.Chat.Options.DB data ChatOpts = ChatOpts { coreOptions :: CoreChatOpts, - deviceName :: Maybe Text, chatCmd :: String, chatCmdDelay :: Int, chatCmdLog :: ChatCmdLog, @@ -64,6 +63,7 @@ data CoreChatOpts = CoreChatOpts logAgent :: Maybe LogLevel, logFile :: Maybe FilePath, tbqSize :: Natural, + deviceName :: Maybe Text, highlyAvailable :: Bool, yesToUpMigrations :: Bool } @@ -212,6 +212,13 @@ coreChatOptsP appDir defaultDbName = do <> value 1024 <> showDefault ) + deviceName <- + optional $ + strOption + ( long "device-name" + <> metavar "DEVICE" + <> help "Device name to use in connections with remote hosts and controller" + ) highlyAvailable <- switch ( long "ha" @@ -246,6 +253,7 @@ coreChatOptsP appDir defaultDbName = do logAgent = if logAgent || logLevel == CLLDebug then Just $ agentLogLevel logLevel else Nothing, logFile, tbqSize, + deviceName, highlyAvailable, yesToUpMigrations } @@ -260,13 +268,6 @@ defaultHostMode = \case chatOptsP :: FilePath -> FilePath -> Parser ChatOpts chatOptsP appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName - deviceName <- - optional $ - strOption - ( long "device-name" - <> metavar "DEVICE" - <> help "Device name to use in connections with remote hosts and controller" - ) chatCmd <- strOption ( long "execute" @@ -356,7 +357,6 @@ chatOptsP appDir defaultDbName = do pure ChatOpts { coreOptions, - deviceName, chatCmd, chatCmdDelay, chatCmdLog, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 03671d1d94..28f7befebe 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -94,7 +94,6 @@ testOpts :: ChatOpts testOpts = ChatOpts { coreOptions = testCoreOpts, - deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatCmdLog = CCLNone, @@ -135,6 +134,7 @@ testCoreOpts = logAgent = Nothing, logFile = Nothing, tbqSize = 16, + deviceName = Nothing, highlyAvailable = False, yesToUpMigrations = False } From 9dac4721915423a4a97a40a6fc8c8a8e4b7501fd Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 5 Mar 2025 22:01:44 +0700 Subject: [PATCH 111/289] android, desktop: bulk actions with group members (#5708) * android, desktop: bulk actions with group members * fix layout * fix update * fix responsivenes when closing selecting bar * events * unused * role --- .../chat/simplex/common/model/ChatModel.kt | 9 +- .../simplex/common/views/chat/ChatView.kt | 88 ++++--- .../views/chat/SelectableChatItemToolbars.kt | 60 +++-- .../views/chat/group/GroupChatInfoView.kt | 246 +++++++++++++++--- .../views/chat/group/GroupMemberInfoView.kt | 101 +++++-- .../views/chat/group/GroupMembersToolbar.kt | 129 +++++++++ .../views/chat/group/GroupPreferences.kt | 1 + .../common/views/chat/item/ChatItemView.kt | 4 +- .../simplex/common/views/helpers/ModalView.kt | 23 +- .../commonMain/resources/MR/base/strings.xml | 8 + .../resources/MR/images/ic_checklist.svg | 1 + .../resources/MR/images/ic_person_edit.svg | 1 + 12 files changed, 531 insertions(+), 140 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 7afcd69487..c69301423e 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 @@ -1958,8 +1958,8 @@ data class GroupMember ( fun canBlockForAll(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Admin - && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive + return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Moderator + && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive } val memberIncognito = memberProfile.profileId != memberContactProfileId @@ -2439,14 +2439,14 @@ data class ChatItem ( fun memberToModerate(chatInfo: ChatInfo): Pair? { return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { val m = chatInfo.groupInfo.membership - if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) { + if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) { chatInfo.groupInfo to chatDir.groupMember } else { null } } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupSnd) { val m = chatInfo.groupInfo.membership - if (m.memberRole >= GroupMemberRole.Admin) { + if (m.memberRole >= GroupMemberRole.Moderator) { chatInfo.groupInfo to null } else { null @@ -3259,6 +3259,7 @@ sealed class CIContent: ItemContent { when (role) { GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners) GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins) + GroupMemberRole.Moderator -> generalGetString(MR.strings.feature_roles_moderators) else -> generalGetString(MR.strings.feature_roles_all_members) } 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 f9a7f25f48..a889cf6c9a 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 @@ -175,7 +175,7 @@ fun ChatView( ) } } else { - SelectedItemsBottomToolbar( + SelectedItemsButtonsToolbar( contentTag = contentTag, selectedChatItems = selectedChatItems, chatInfo = chatInfo, @@ -274,34 +274,46 @@ fun ChatView( } if (!isActive) return@launch - ModalManager.end.showModalCloseable(true) { close -> - val chatInfo = remember { activeChatInfo }.value - if (chatInfo is ChatInfo.Direct) { - var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } - var code: String? by remember { mutableStateOf(preloadedCode) } - KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { - contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) - preloadedContactInfo = contactInfo - code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second - preloadedCode = code + val selectedItems: MutableState?> = mutableStateOf(null) + ModalManager.end.showCustomModal { close -> + val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) } + ModalView(close, appBar = appBar.value) { + val chatInfo = remember { activeChatInfo }.value + if (chatInfo is ChatInfo.Direct) { + var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } + var code: String? by remember { mutableStateOf(preloadedCode) } + KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { + contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) + preloadedContactInfo = contactInfo + code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second + preloadedCode = code + } + ChatInfoView(chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) { + showSearch.value = true + } + } else if (chatInfo is ChatInfo.Group) { + var link: Pair? by remember(chatInfo.id) { mutableStateOf(preloadedLink) } + KeyChangeEffect(chatInfo.id) { + setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) + link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) + preloadedLink = link + } + GroupChatInfoView(chatRh, chatInfo.id, link?.first, link?.second, selectedItems, appBar, scrollToItemId, { + link = it + preloadedLink = it + }, close, { showSearch.value = true }) + } else { + LaunchedEffect(Unit) { + close() + } } - ChatInfoView(chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) { - showSearch.value = true - } - } else if (chatInfo is ChatInfo.Group) { - var link: Pair? by remember(chatInfo.id) { mutableStateOf(preloadedLink) } - KeyChangeEffect(chatInfo.id) { - setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) - link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) - preloadedLink = link - } - GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, scrollToItemId, { - link = it - preloadedLink = it - }, close, { showSearch.value = true }) - } else { LaunchedEffect(Unit) { - close() + snapshotFlow { activeChatInfo.value?.id } + .drop(1) + .collect { + appBar.value = null + selectedItems.value = null + } } } } @@ -788,7 +800,7 @@ fun ChatLayout( ) { AnimatedVisibility(selectedChatItems.value != null) { if (chatInfo != null) { - SelectedItemsBottomToolbar( + SelectedItemsButtonsToolbar( contentTag = contentTag, selectedChatItems = selectedChatItems, chatInfo = chatInfo, @@ -846,7 +858,7 @@ fun ChatLayout( if (selectedChatItems.value == null) { GroupReportsAppBar(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged) } else { - SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value) + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) } } } @@ -858,7 +870,7 @@ fun ChatLayout( ChatInfoToolbar(chatInfo, contentTag, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) } } else { - SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { @@ -1432,7 +1444,7 @@ fun BoxScope.ChatItemsList( fun Item() { ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier, cItem.id, selectedChatItems) + SelectedListItem(Modifier, cItem.id, selectedChatItems) } Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { val member = cItem.chatDir.groupMember @@ -1457,7 +1469,7 @@ fun BoxScope.ChatItemsList( } else { ChatItemBox { AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } Row( Modifier @@ -1472,7 +1484,7 @@ fun BoxScope.ChatItemsList( } else { ChatItemBox { AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } Box( Modifier @@ -1487,7 +1499,7 @@ fun BoxScope.ChatItemsList( } else { // direct message ChatItemBox { AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } Box( @@ -2296,12 +2308,12 @@ private fun BoxScope.BottomEndFloatingButton( } @Composable -private fun SelectedChatItem( +fun SelectedListItem( modifier: Modifier, - ciId: Long, - selectedChatItems: State?>, + id: Long, + selectedItems: State?>, ) { - val checked = remember { derivedStateOf { selectedChatItems.value?.contains(ciId) == true } } + val checked = remember { derivedStateOf { selectedItems.value?.contains(id) == true } } Icon( painterResource(if (checked.value) MR.images.ic_check_circle_filled else MR.images.ic_radio_button_unchecked), null, 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 85e3f3936e..50e6f73bca 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 @@ -9,12 +9,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.* @@ -23,32 +21,44 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @Composable -fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>, onTop: Boolean) { - val onBackClicked = { selectedChatItems.value = null } +fun BoxScope.SelectedItemsCounterToolbar(selectedItems: MutableState?>, onTop: Boolean, selectAll: (() -> Unit)? = null) { + val onBackClicked = { selectedItems.value = null } BackHandler(onBack = onBackClicked) - val count = selectedChatItems.value?.size ?: 0 - DefaultAppBar( - navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, - title = { - Text( - if (count == 0) { - stringResource(MR.strings.selected_chat_items_nothing_selected) - } else { - stringResource(MR.strings.selected_chat_items_selected_n).format(count) - }, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - onTitleClick = null, - onTop = onTop, - onSearchValueChanged = {}, - ) + val count = selectedItems.value?.size ?: 0 + Box(if (onTop) Modifier else Modifier.imePadding()) { + DefaultAppBar( + navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, + title = { + Text( + if (count == 0) { + stringResource(MR.strings.selected_chat_items_nothing_selected) + } else { + stringResource(MR.strings.selected_chat_items_selected_n).format(count) + }, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onTitleClick = null, + onTop = onTop, + onSearchValueChanged = {}, + buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}} + ) + } } @Composable -fun SelectedItemsBottomToolbar( +private fun SelectAllButton(onClick: () -> Unit) { + IconButton(onClick) { + Icon( + painterResource(MR.images.ic_checklist), stringResource(MR.strings.back), Modifier.height(24.dp), tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +fun SelectedItemsButtonsToolbar( chatInfo: ChatInfo, contentTag: MsgContentTag?, selectedChatItems: MutableState?>, @@ -162,4 +172,4 @@ private fun recheckItems(chatInfo: ChatInfo, } private fun possibleToModerate(chatInfo: ChatInfo): Boolean = - chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Admin + chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator 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 441fe313d4..a94c787eec 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 @@ -8,6 +8,8 @@ import SectionItemViewLongClickable import SectionSpacer import SectionTextFooter import SectionView +import androidx.compose.animation.* +import androidx.compose.animation.core.animateDpAsState import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* @@ -17,6 +19,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString @@ -37,7 +40,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions import chat.simplex.res.MR @@ -49,7 +52,18 @@ val MEMBER_ROW_AVATAR_SIZE = 42.dp val MEMBER_ROW_VERTICAL_PADDING = 8.dp @Composable -fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { +fun ModalData.GroupChatInfoView( + rhId: Long?, + chatId: String, + groupLink: String?, + groupLinkMemberRole: GroupMemberRole?, + selectedItems: MutableState?>, + appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, + scrollToItemId: MutableState, + onGroupLinkUpdated: (Pair?) -> Unit, + close: () -> Unit, + onSearchClicked: () -> Unit +) { BackHandler(onBack = close) // TODO derivedStateOf? val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } @@ -82,12 +96,14 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, - members = remember { chatModel.groupMembers }.value + activeSortedMembers = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, developerTools, onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, groupLink, + selectedItems, + appBar, scrollToItemId, addMembers = { scope.launch(Dispatchers.Default) { @@ -212,21 +228,23 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe text = generalGetString(messageId), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { - withBGApi { - val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, listOf(mem.groupMemberId)) - if (updatedMembers != null) { - withChats { - updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, groupInfo, updatedMember) - } - } - withReportsChatsIfOpen { - updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, groupInfo, updatedMember) - } - } - } - } + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId)) + }, + destructive = true, + ) +} + +private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { + val messageId = if (groupInfo.businessChat == null) + MR.strings.members_will_be_removed_from_group_cannot_be_undone + else + MR.strings.members_will_be_removed_from_chat_cannot_be_undone + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.button_remove_members_question), + text = generalGetString(messageId), + confirmText = generalGetString(MR.strings.remove_member_confirmation), + onConfirm = { + removeMembers(rhId, groupInfo, memberIds, onSuccess) }, destructive = true, ) @@ -309,10 +327,12 @@ fun ModalData.GroupChatInfoLayout( setSendReceipts: (SendReceipts) -> Unit, chatItemTTL: MutableState, setChatItemTTL: (ChatItemTTL?) -> Unit, - members: List, + activeSortedMembers: List, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, groupLink: String?, + selectedItems: MutableState?>, + appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, addMembers: () -> Unit, showMemberInfo: (GroupMember) -> Unit, @@ -333,20 +353,37 @@ fun ModalData.GroupChatInfoLayout( scope.launch { listState.scrollToItem(0) } } val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } } - val filteredMembers = remember(members) { + val filteredMembers = remember(activeSortedMembers) { derivedStateOf { val s = searchText.value.text.trim().lowercase() - if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) } + if (s.isEmpty()) activeSortedMembers else activeSortedMembers.filter { m -> m.anyNameContains(s) } } } Box { val oneHandUI = remember { appPrefs.oneHandUI.state } + val selectedItemsBarHeight = if (selectedItems.value != null) AppBarHeight * fontSizeSqrtMultiplier else 0.dp + val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val imePadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() LazyColumnWithScrollBar( state = listState, contentPadding = if (oneHandUI.value) { - PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) + PaddingValues( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, + bottom = navBarPadding + + imePadding + + selectedItemsBarHeight + + // TODO: that's workaround but works. Actually, something in the codebase doesn't consume padding for AppBar and it produce + // different padding when the user has NavigationBar and doesn't have it with ime shown (developer options helps to test it nav bars) + (if (navBarPadding > 0.dp && imePadding > 0.dp) 0.dp else AppBarHeight * fontSizeSqrtMultiplier) + ) } else { - PaddingValues(top = topPaddingToContent(false)) + PaddingValues( + top = topPaddingToContent(false), + bottom = navBarPadding + + imePadding + + selectedItemsBarHeight + + (if (navBarPadding > 0.dp && imePadding > 0.dp) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp) + ) } ) { item { @@ -401,7 +438,7 @@ fun ModalData.GroupChatInfoLayout( } } } - if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) } else { SendReceiptsOptionDisabled() @@ -424,7 +461,7 @@ fun ModalData.GroupChatInfoLayout( ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) - SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) { + 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) { @@ -442,7 +479,7 @@ fun ModalData.GroupChatInfoLayout( } AddMembersButton(addMembersTitleId, tint, onAddMembersClick) } - if (members.size > 8) { + if (activeSortedMembers.size > 8) { SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { SearchRowView(searchText) } @@ -452,12 +489,34 @@ fun ModalData.GroupChatInfoLayout( } } } - items(filteredMembers.value) { member -> + items(filteredMembers.value, key = { it.groupMemberId }) { member -> Divider() val showMenu = remember { mutableStateOf(false) } - SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { - DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) - MemberRow(member) + val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator + SectionItemViewLongClickable( + click = { + if (selectedItems.value != null) { + if (canBeSelected) { + toggleItemSelection(member.groupMemberId, selectedItems) + } + } else { + showMemberInfo(member) + } + }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + Box(contentAlignment = Alignment.CenterStart) { + androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.alpha(if (canBeSelected) 1f else 0f).padding(start = 2.dp), member.groupMemberId, selectedItems) + } + val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp) + DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu) + Box(Modifier.padding(start = selectionOffset)) { + MemberRow(member) + } + } } } item { @@ -482,12 +541,92 @@ fun ModalData.GroupChatInfoLayout( } } SectionBottomSpacer() - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } } if (!oneHandUI.value) { NavigationBarBackground(oneHandUI.value, oneHandUI.value) } + SelectedItemsButtonsToolbar(chat, groupInfo, selectedItems, rememberUpdatedState(activeSortedMembers)) + SelectedItemsCounterToolbarSetter(groupInfo, selectedItems, filteredMembers, appBar) + } +} + +@Composable +private fun BoxScope.SelectedItemsButtonsToolbar(chat: Chat, groupInfo: GroupInfo, selectedItems: MutableState?>, activeMembers: State>) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + Column(Modifier.align(Alignment.BottomCenter)) { + AnimatedVisibility(selectedItems.value != null) { + SelectedItemsMembersToolbar( + selectedItems = selectedItems, + activeMembers = activeMembers, + groupInfo = groupInfo, + delete = { + removeMembersAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + }, + blockForAll = { block -> + if (block) { + blockForAllAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + } else { + unblockForAllAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + } + }, + changeRole = { toRole -> + updateMembersRoleDialog(toRole, groupInfo) { + updateMembersRole(toRole, chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) { + selectedItems.value = null + } + } + } + ) + } + if (oneHandUI.value) { + // That's placeholder to take some space for bottom app bar in oneHandUI + Box(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier)) + } + } +} + +@Composable +private fun SelectedItemsCounterToolbarSetter( + groupInfo: GroupInfo, + selectedItems: MutableState?>, + filteredMembers: State>, + appBar: MutableState<@Composable (BoxScope.() -> Unit)?> +) { + LaunchedEffect( + groupInfo, + /* variable, not value - intentionally - to reduce work but handle variable change because it changes in remember(members) { derivedState {} } */ + filteredMembers + ) { + snapshotFlow { selectedItems.value == null } + .collect { nullItems -> + if (!nullItems) { + appBar.value = { + SelectedItemsCounterToolbar(selectedItems, !remember { appPrefs.oneHandUI.state }.value) { + if (!groupInfo.membership.memberActive) return@SelectedItemsCounterToolbar + val ids: MutableSet = mutableSetOf() + for (mem in filteredMembers.value) { + if (groupInfo.membership.memberActive && groupInfo.membership.memberRole >= mem.memberRole && mem.memberRole < GroupMemberRole.Moderator) { + ids.add(mem.groupMemberId) + } + } + if (ids.isNotEmpty() && (selectedItems.value ?: setOf()).containsAll(ids)) { + selectedItems.value = (selectedItems.value ?: setOf()).minus(ids) + } else { + selectedItems.value = (selectedItems.value ?: setOf()).union(ids) + } + } + } + } else { + appBar.value = null + } + } } } @@ -612,7 +751,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr Text(stringResource(MR.strings.member_info_member_blocked), color = MaterialTheme.colors.secondary) } else { val role = member.memberRole - if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Observer)) { + if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Moderator, GroupMemberRole.Observer)) { Text(role.text, color = MaterialTheme.colors.secondary) } } @@ -686,8 +825,8 @@ private fun MemberVerifiedShield() { } @Composable -private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { - if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { +private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, selectedItems: MutableState?>, showMenu: MutableState) { + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { val canBlockForAll = member.canBlockForAll(groupInfo) val canRemove = member.canBeRemoved(groupInfo) if (canBlockForAll || canRemove) { @@ -711,6 +850,10 @@ private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: G showMenu.value = false }) } + if (selectedItems.value == null && member.memberRole < GroupMemberRole.Moderator) { + Divider() + SelectItemAction(showMenu) { toggleItemSelection(member.groupMemberId, selectedItems) } + } } } } else if (!member.blockedByAdmin) { @@ -819,6 +962,37 @@ 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) { + withChats { + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, groupInfo, updatedMember) + } + } + withReportsChatsIfOpen { + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, groupInfo, updatedMember) + } + } + onSuccess() + } + } +} + +fun toggleItemSelection(itemId: T, selectedItems: MutableState?>) { + val select = selectedItems.value?.contains(itemId) != true + if (select) { + val sel = selectedItems.value ?: setOf() + selectedItems.value = sel + itemId + } else { + val sel = (selectedItems.value ?: setOf()).toMutableSet() + sel.remove(itemId) + selectedItems.value = sel + } +} + @Preview @Composable fun PreviewGroupChatInfoLayout() { @@ -835,10 +1009,12 @@ fun PreviewGroupChatInfoLayout() { setSendReceipts = {}, chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, setChatItemTTL = {}, - members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), + activeSortedMembers = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, onLocalAliasChanged = {}, groupLink = null, + 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) } ) 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 41e4df8792..38163f9b6e 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 @@ -137,26 +137,10 @@ fun GroupMemberInfoView( if (it == newRole.value) return@GroupMemberInfoLayout val prevValue = newRole.value newRole.value = it - updateMemberRoleDialog(it, groupInfo, member, onDismiss = { + updateMemberRoleDialog(it, groupInfo, member.memberCurrent, onDismiss = { newRole.value = prevValue }) { - withBGApi { - kotlin.runCatching { - val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, listOf(member.groupMemberId), it) - withChats { - members.forEach { member -> - upsertGroupMember(rhId, groupInfo, member) - } - } - withReportsChatsIfOpen { - members.forEach { member -> - upsertGroupMember(rhId, groupInfo, member) - } - } - }.onFailure { - newRole.value = prevValue - } - } + updateMembersRole(newRole.value, rhId, groupInfo, listOf(member.groupMemberId), onFailure = { newRole.value = prevValue }) } }, switchMemberAddress = { @@ -317,7 +301,7 @@ fun GroupMemberInfoLayout( } @Composable - fun AdminDestructiveSection() { + fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) val canRemove = member.canBeRemoved(groupInfo) if (canBlockForAll || canRemove) { @@ -494,8 +478,8 @@ fun GroupMemberInfoLayout( } } - if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { - AdminDestructiveSection() + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ModeratorDestructiveSection() } else { NonAdminBlockSection() } @@ -709,16 +693,37 @@ fun MemberProfileImage( ) } -private fun updateMemberRoleDialog( +fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInfo, memberIds: List, onFailure: () -> Unit = {}, onSuccess: () -> Unit = {}) { + withBGApi { + kotlin.runCatching { + val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, memberIds, newRole) + withChats { + members.forEach { member -> + upsertGroupMember(rhId, groupInfo, member) + } + } + withReportsChatsIfOpen { + members.forEach { member -> + upsertGroupMember(rhId, groupInfo, member) + } + } + onSuccess() + }.onFailure { + onFailure() + } + } +} + +fun updateMemberRoleDialog( newRole: GroupMemberRole, groupInfo: GroupInfo, - member: GroupMember, + memberCurrent: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit ) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.change_member_role_question), - text = if (member.memberCurrent) { + text = if (memberCurrent) { if (groupInfo.businessChat == null) String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text) else @@ -732,6 +737,22 @@ private fun updateMemberRoleDialog( ) } +fun updateMembersRoleDialog( + newRole: GroupMemberRole, + groupInfo: GroupInfo, + onConfirm: () -> Unit +) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_member_role_question), + text = if (groupInfo.businessChat == null) + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text) + else + String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text), + confirmText = generalGetString(MR.strings.change_verb), + onConfirm = onConfirm, + ) +} + fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { try { withBGApi { @@ -793,7 +814,19 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, true) + blockMemberForAll(rhId, gInfo, listOf(mem.groupMemberId), true) + }, + destructive = true, + ) +} + +fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.block_members_for_all_question), + text = generalGetString(MR.strings.block_members_desc), + confirmText = generalGetString(MR.strings.block_for_all), + onConfirm = { + blockMemberForAll(rhId, gInfo, memberIds, true, onSuccess) }, destructive = true, ) @@ -805,14 +838,25 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, false) + blockMemberForAll(rhId, gInfo, listOf(mem.groupMemberId), false) }, ) } -fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { +fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unblock_members_for_all_question), + text = generalGetString(MR.strings.unblock_members_desc), + confirmText = generalGetString(MR.strings.unblock_for_all), + onConfirm = { + blockMemberForAll(rhId, gInfo, memberIds, false, onSuccess) + }, + ) +} + +fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, memberIds: List, blocked: Boolean, onSuccess: () -> Unit = {}) { withBGApi { - val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, listOf(member.groupMemberId), blocked) + val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, memberIds, blocked) withChats { updatedMembers.forEach { updatedMember -> upsertGroupMember(rhId, gInfo, updatedMember) @@ -823,6 +867,7 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke upsertGroupMember(rhId, gInfo, updatedMember) } } + onSuccess() } } 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 new file mode 100644 index 0000000000..2c4d4b16a8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -0,0 +1,129 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +fun SelectedItemsMembersToolbar( + selectedItems: MutableState?>, + activeMembers: State>, + groupInfo: GroupInfo, + delete: () -> Unit, + blockForAll: (Boolean) -> Unit, // Boolean - block or unlock + changeRole: (GroupMemberRole) -> Unit, +) { + val deleteEnabled = remember { mutableStateOf(false) } + val blockForAllEnabled = remember { mutableStateOf(false) } + val unblockForAllEnabled = remember { mutableStateOf(false) } + val blockForAllButtonEnabled = remember { derivedStateOf { (blockForAllEnabled.value && !unblockForAllEnabled.value) || (!blockForAllEnabled.value && unblockForAllEnabled.value) } } + + val roleToMemberEnabled = remember { mutableStateOf(false) } + val roleToObserverEnabled = remember { mutableStateOf(false) } + val roleButtonEnabled = remember { derivedStateOf { (roleToMemberEnabled.value && !roleToObserverEnabled.value) || (!roleToMemberEnabled.value && roleToObserverEnabled.value) } } + Box( + Modifier + .background(MaterialTheme.colors.background) + .navigationBarsPadding() + .imePadding() + ) { + // 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() }) + } + Row( + Modifier + .matchParentSize() + .padding(horizontal = 2.dp) + .height(AppBarHeight * fontSizeSqrtMultiplier) + .pointerInput(Unit) { + detectGesture { + true + } + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(delete, enabled = deleteEnabled.value) { + Icon( + painterResource(MR.images.ic_delete), + null, + Modifier.size(22.dp), + tint = if (!deleteEnabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error + ) + } + + IconButton({ blockForAll(blockForAllEnabled.value) }, enabled = blockForAllButtonEnabled.value) { + Icon( + painterResource(if (unblockForAllEnabled.value && blockForAllButtonEnabled.value) MR.images.ic_do_not_touch else MR.images.ic_back_hand), + null, + Modifier.size(22.dp), + tint = if (!blockForAllButtonEnabled.value) MaterialTheme.colors.secondary else if (blockForAllEnabled.value) MaterialTheme.colors.error else WarningOrange + ) + } + + IconButton({ changeRole(if (roleToMemberEnabled.value) GroupMemberRole.Member else GroupMemberRole.Observer) }, enabled = roleButtonEnabled.value) { + Icon( + painterResource(if (roleToObserverEnabled.value || !roleButtonEnabled.value) MR.images.ic_person else MR.images.ic_person_edit), + null, + Modifier.size(22.dp), + tint = if (!roleButtonEnabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + Divider(Modifier.align(Alignment.TopStart)) + } + LaunchedEffect(groupInfo, activeMembers.value.toList(), selectedItems.value) { + recheckItems(groupInfo, selectedItems, activeMembers.value, deleteEnabled, blockForAllEnabled, unblockForAllEnabled, roleToMemberEnabled, roleToObserverEnabled) + } +} + +private fun recheckItems( + groupInfo: GroupInfo, + selectedItems: MutableState?>, + activeMembers: List, + deleteEnabled: MutableState, + blockForAllEnabled: MutableState, + unblockForAllEnabled: MutableState, + roleToMemberEnabled: MutableState, + roleToObserverEnabled: MutableState, +) { + val selected = selectedItems.value ?: return + var rDeleteEnabled = true + var rBlockForAllEnabled = true + var rUnblockForAllEnabled = true + var rRoleToMemberEnabled = true + var rRoleToObserverEnabled = true + val rSelectedItems = mutableSetOf() + for (mem in activeMembers) { + if (selected.contains(mem.groupMemberId) && groupInfo.membership.memberRole >= mem.memberRole && mem.memberRole < GroupMemberRole.Moderator && groupInfo.membership.memberActive) { + rDeleteEnabled = rDeleteEnabled && mem.memberStatus != GroupMemberStatus.MemRemoved && mem.memberStatus != GroupMemberStatus.MemLeft + rBlockForAllEnabled = rBlockForAllEnabled && !mem.blockedByAdmin + rUnblockForAllEnabled = rUnblockForAllEnabled && mem.blockedByAdmin + rRoleToMemberEnabled = rRoleToMemberEnabled && mem.memberRole != GroupMemberRole.Member + rRoleToObserverEnabled = rRoleToObserverEnabled && mem.memberRole != GroupMemberRole.Observer + rSelectedItems.add(mem.groupMemberId) // we are collecting new selected items here to account for any changes in members list + } + } + deleteEnabled.value = rDeleteEnabled + blockForAllEnabled.value = rBlockForAllEnabled + unblockForAllEnabled.value = rUnblockForAllEnabled + roleToMemberEnabled.value = rRoleToMemberEnabled + roleToObserverEnabled.value = rRoleToObserverEnabled + selectedItems.value = rSelectedItems +} 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 64195322c2..e1b0f30423 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 @@ -21,6 +21,7 @@ import chat.simplex.res.MR private val featureRoles: List> = listOf( null to generalGetString(MR.strings.feature_roles_all_members), + GroupMemberRole.Moderator to generalGetString(MR.strings.feature_roles_moderators), GroupMemberRole.Admin to generalGetString(MR.strings.feature_roles_admins), GroupMemberRole.Owner to generalGetString(MR.strings.feature_roles_owners) ) 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 e4bcc965c4..1268975f96 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 @@ -865,14 +865,14 @@ fun ModerateItemAction( @Composable fun SelectItemAction( showMenu: MutableState, - selectChatItem: () -> Unit, + selectItem: () -> Unit, ) { ItemAction( stringResource(MR.strings.select_verb), painterResource(MR.images.ic_check_circle), onClick = { showMenu.value = false - selectChatItem() + selectItem() } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 3e24629ab1..564e96945c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -32,6 +32,7 @@ fun ModalView( searchAlwaysVisible: Boolean = false, onSearchValueChanged: (String) -> Unit = {}, endButtons: @Composable RowScope.() -> Unit = {}, + appBar: @Composable (BoxScope.() -> Unit)? = null, content: @Composable BoxScope.() -> Unit, ) { if (showClose && showAppBar) { @@ -48,14 +49,20 @@ fun ModalView( StatusBarBackground() } Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) { - DefaultAppBar( - navigationButton = if (showClose) {{ NavigationButtonBack(onButtonClicked = if (enableClose) close else null) }} else null, - onTop = !oneHandUI.value, - showSearch = showSearch, - searchAlwaysVisible = searchAlwaysVisible, - onSearchValueChanged = onSearchValueChanged, - buttons = endButtons - ) + if (appBar != null) { + appBar() + } else { + DefaultAppBar( + navigationButton = if (showClose) { + { NavigationButtonBack(onButtonClicked = if (enableClose) close else null) } + } else null, + onTop = !oneHandUI.value, + showSearch = showSearch, + searchAlwaysVisible = searchAlwaysVisible, + onSearchValueChanged = onSearchValueChanged, + buttons = endButtons + ) + } } } } 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 52e25a6d8d..e5d3384f9b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1738,25 +1738,32 @@ Remove member? + Remove members? Remove member Send direct message Member will be removed from group - this cannot be undone! + Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! + Members will be removed from chat - this cannot be undone! Remove Remove member Block member? Block member Block Block member for all? + Block members for all? Block for all All new messages from %s will be hidden! + All new messages from these members will be hidden! Unblock member? Unblock member Unblock Unblock member for all? + Unblock members for all? Unblock for all Messages from %s will be shown! + Messages from these members will be shown! Blocked by admin blocked disabled @@ -2122,6 +2129,7 @@ offered %s: %2s cancelled %s all members + moderators admins owners Enabled for diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg new file mode 100644 index 0000000000..947e78c1e5 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg new file mode 100644 index 0000000000..7082a9c0d4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg @@ -0,0 +1 @@ + \ No newline at end of file From 7a3663f1e064088a2df358cda614061f4c39a0eb Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 6 Mar 2025 16:42:14 +0700 Subject: [PATCH 112/289] android, desktop: hovered item in chat (#5684) * android, desktop: hovered item in chat * circle background for button * icon size * change * change * comment * refactor --- .../simplex/common/views/chat/ChatView.kt | 6 +- .../common/views/chat/item/ChatItemView.kt | 67 +++++++++++++------ 2 files changed, 52 insertions(+), 21 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 a889cf6c9a..1c3f4b7749 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 @@ -1214,6 +1214,7 @@ fun BoxScope.ChatItemsList( } } 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() } @@ -1222,6 +1223,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value += openAroundItemId chatModel.openAroundItemId.value = null } + hoveredItemId.value = null if (reportsState != null) { reportsListState = null reportsState @@ -1331,7 +1333,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, 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(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) } } @@ -1618,6 +1620,8 @@ private fun LoadLastItems(loadingMoreItems: MutableState, resetListStat } } +// TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message +// can be reproduced by forwarding a message to notes that is (ChatPagination.INITIAL_COUNT - 1) away from bottom and going to that message @Composable private fun SmallScrollOnNewMessage(listState: State, reversedChatItems: State>) { val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() } 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 1268975f96..e399d9e88d 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 @@ -73,6 +73,7 @@ fun ChatItemView( linkMode: SimplexLinkMode, revealed: State, highlighted: State, + hoveredItemId: MutableState, range: State, selectedChatItems: MutableState?>, searchIsNotBlank: State, @@ -115,16 +116,8 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value - val bubbleInteractionSource = remember { MutableInteractionSource() } - val lineInteractionSource = remember { MutableInteractionSource() } - val bubblePressed = bubbleInteractionSource.collectIsPressedAsState() - val linePressed = lineInteractionSource.collectIsPressedAsState() - val lineHovered = lineInteractionSource.collectIsHoveredAsState() - val lineActivated = remember { derivedStateOf { lineHovered.value || linePressed.value || bubblePressed.value } } Box( - modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier) - .hoverable(lineInteractionSource) - .clickable(onClick = {}, interactionSource = lineInteractionSource, indication = null), + modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier), contentAlignment = alignment, ) { val info = cItem.meta.itemStatus.statusInto @@ -240,20 +233,42 @@ fun ChatItemView( } @Composable - fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, parentActivated: State, onClick: () -> Unit) { + fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, iconSize: Dp = 22.dp, parentActivated: State, onClick: () -> Unit) { val buttonInteractionSource = remember { MutableInteractionSource() } val buttonHovered = buttonInteractionSource.collectIsHoveredAsState() val buttonPressed = buttonInteractionSource.collectIsPressedAsState() val buttonActivated = remember { derivedStateOf { buttonHovered.value || buttonPressed.value } } + + val fullyVisible = parentActivated.value || buttonActivated.value || hoveredItemId.value == cItem.id + val mixAlpha = 0.6f + val mixedBackgroundColor = if (fullyVisible) { + if (MaterialTheme.colors.isLight) { + MaterialTheme.colors.secondary.mixWith(Color.White, mixAlpha) + } else { + MaterialTheme.colors.secondary.mixWith(Color.Black, mixAlpha) + } + } else { + Color.Unspecified + } + val iconTint = if (fullyVisible) { + Color.White + } else { + if (MaterialTheme.colors.isLight) { + MaterialTheme.colors.secondary.mixWith(Color.White, mixAlpha) + } else { + MaterialTheme.colors.secondary.mixWith(Color.Black, mixAlpha) + } + } + IconButton( onClick, Modifier .padding(start = if (alignStart) 0.dp else DEFAULT_PADDING_HALF + 3.dp, end = if (alignStart) DEFAULT_PADDING_HALF + 3.dp else 0.dp) - .size(22.dp) - .alpha(if (parentActivated.value || buttonActivated.value) 1f else 0.4f), + .then(if (fullyVisible) Modifier.background(mixedBackgroundColor, CircleShape) else Modifier) + .size(22.dp), interactionSource = buttonInteractionSource ) { - Icon(painterResource(icon), null, Modifier.size(22.dp), tint = MaterialTheme.colors.secondary) + Icon(painterResource(icon), null, Modifier.size(iconSize), tint = iconTint) } } @@ -261,14 +276,14 @@ fun ChatItemView( fun GoToItemButton(alignStart: Boolean, parentActivated: State) { val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId if (searchIsNotBlank.value) { - GoToItemInnerButton(alignStart, MR.images.ic_search, parentActivated) { + GoToItemInnerButton(alignStart, MR.images.ic_search, 17.dp, parentActivated) { withBGApi { openChat(rhId, cInfo.chatType, cInfo.apiId, null, cItem.id) closeReportsIfNeeded() } } } else if (chatTypeApiIdMsgId != null) { - GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, parentActivated) { + GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, 22.dp, parentActivated) { val (chatType, apiId, msgId) = chatTypeApiIdMsgId withBGApi { openChat(rhId, chatType, apiId, null, msgId) @@ -280,13 +295,15 @@ fun ChatItemView( Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { Row(verticalAlignment = Alignment.CenterVertically) { + val bubbleInteractionSource = remember { MutableInteractionSource() } + val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() if (cItem.chatDir.sent) { - GoToItemButton(true, lineActivated) + GoToItemButton(true, bubbleHovered) } Column(Modifier.weight(1f, fill = false)) { val enterInteraction = remember { HoverInteraction.Enter() } - KeyChangeEffect(highlighted.value) { - if (highlighted.value) { + LaunchedEffect(highlighted.value, hoveredItemId.value) { + if (highlighted.value || hoveredItemId.value == cItem.id) { bubbleInteractionSource.emit(enterInteraction) } else { bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction)) @@ -295,7 +312,15 @@ fun ChatItemView( Column( Modifier .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) + .hoverable(bubbleInteractionSource) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { + if (appPlatform.isAndroid && (searchIsNotBlank.value || cItem.meta.itemForwarded?.chatTypeApiIdMsgId != null)) { + hoveredItemId.value = if (hoveredItemId.value == cItem.id) null else cItem.id + } + onClick() + }, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) .onRightClick { showMenu.value = true }, ) { @Composable @@ -744,7 +769,7 @@ fun ChatItemView( } } if (!cItem.chatDir.sent) { - GoToItemButton(false, lineActivated) + GoToItemButton(false, bubbleHovered) } } if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { @@ -1399,6 +1424,7 @@ fun PreviewChatItemView( composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) }, + hoveredItemId = remember { mutableStateOf(null) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, searchIsNotBlank = remember { mutableStateOf(false) }, @@ -1447,6 +1473,7 @@ fun PreviewChatItemViewDeletedContent() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) }, + hoveredItemId = remember { mutableStateOf(null) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, searchIsNotBlank = remember { mutableStateOf(false) }, From a3a27b250c840fa78d1615012950a62b15faab23 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 6 Mar 2025 17:31:05 +0700 Subject: [PATCH 113/289] ios: small fixes (#5712) * ios: small fixes * main thread * fix crash * fix member opening * dismissing sheets in order * theoretical fix of some crashes --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +++--- .../ChatItem/CIMemberCreatedContactView.swift | 5 ++--- .../Shared/Views/Chat/ChatItemInfoView.swift | 5 +++-- apps/ios/Shared/Views/Chat/ChatView.swift | 3 +++ .../Shared/Views/Chat/EndlessScrollView.swift | 2 +- .../Chat/Group/GroupMemberInfoView.swift | 2 +- .../Views/ChatList/ChatListNavLink.swift | 5 +++-- .../Views/Contacts/ContactListNavLink.swift | 3 +-- .../Shared/Views/NewChat/NewChatView.swift | 20 +++++++++++-------- 9 files changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 545e90763f..875a1f27fa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -341,9 +341,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { let m = ChatModel.shared let im = ItemsModel.shared - m.chatItemStatuses = [:] - if clearItems { - await MainActor.run { + await MainActor.run { + m.chatItemStatuses = [:] + if clearItems { im.reversedChatItems = [] ItemsModel.shared.chatItemsChangesListener.cleared() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index d24c737907..e49e99c77e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -21,9 +21,8 @@ struct CIMemberCreatedContactView: View { if let contactId = groupMember.memberContactId { memberCreatedContactView(openText: "Open") .onTapGesture { - dismissAllSheets(animated: true) - DispatchQueue.main.async { - ItemsModel.shared.loadOpenChat("@\(contactId)") + ItemsModel.shared.loadOpenChat("@\(contactId)") { + dismissAllSheets(animated: true) } } } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index b03169974e..0fe9f5d4c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -354,8 +354,9 @@ struct ChatItemInfoView: View { Button { Task { await MainActor.run { - ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) - dismiss() + ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) { + dismiss() + } } } } label: { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 90c9629352..980ddd8fb3 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1317,6 +1317,9 @@ struct ChatView: View { var unreadMentions: Int = 0 for i in range { + if i < 0 || i >= im.reversedChatItems.count { + break + } let ci = im.reversedChatItems[i] if ci.isRcvNew { unreadItems.append(ci.id) diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index f04987e5f8..39cc84a71d 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -632,7 +632,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } (view as? ReusableView)?.prepareForReuse() view.isHidden = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { if view.isHidden { view.removeFromSuperview() } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 4a931ed23f..79ad242366 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -392,7 +392,7 @@ struct GroupMemberInfoView: View { await MainActor.run { progressIndicator = false chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) - ItemsModel.shared.loadOpenChat("@\(memberContact.id)") { + ItemsModel.shared.loadOpenChat(memberContact.id) { dismissAllSheets(animated: true) } NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index e3aad890bb..790af64b3f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -595,8 +595,9 @@ struct ChatListNavLink: View { Task { let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) if ok { - ItemsModel.shared.loadOpenChat(contact.id) - AlertManager.shared.showAlert(connReqSentAlert(.contact)) + ItemsModel.shared.loadOpenChat(contact.id) { + AlertManager.shared.showAlert(connReqSentAlert(.contact)) + } } } } diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index 242b492e83..fe840006cd 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -188,8 +188,7 @@ struct ContactListNavLink: View { Task { let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") }) if ok { - ItemsModel.shared.loadOpenChat(contact.id) - DispatchQueue.main.async { + ItemsModel.shared.loadOpenChat(contact.id) { dismissAllSheets(animated: true) { AlertManager.shared.showAlert(connReqSentAlert(.contact)) } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 6e898f4cdf..7a7b91880c 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1205,12 +1205,14 @@ func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: DispatchQueue.main.async { if dismiss { dismissAllSheets(animated: true) { - ItemsModel.shared.loadOpenChat(c.id) - showAlreadyExistsAlert?() + ItemsModel.shared.loadOpenChat(c.id) { + showAlreadyExistsAlert?() + } } } else { - ItemsModel.shared.loadOpenChat(c.id) - showAlreadyExistsAlert?() + ItemsModel.shared.loadOpenChat(c.id) { + showAlreadyExistsAlert?() + } } } } @@ -1224,12 +1226,14 @@ func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAler DispatchQueue.main.async { if dismiss { dismissAllSheets(animated: true) { - ItemsModel.shared.loadOpenChat(g.id) - showAlreadyExistsAlert?() + ItemsModel.shared.loadOpenChat(g.id) { + showAlreadyExistsAlert?() + } } } else { - ItemsModel.shared.loadOpenChat(g.id) - showAlreadyExistsAlert?() + ItemsModel.shared.loadOpenChat(g.id) { + showAlreadyExistsAlert?() + } } } } From a0560a5ad0777e4bda6cf5ebbaaa64251340ac18 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 6 Mar 2025 18:28:26 +0700 Subject: [PATCH 114/289] ios: fix search (#5715) --- apps/ios/Shared/Views/Chat/ChatView.swift | 41 +++++++++++++---------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 980ddd8fb3..ef343d1fb8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -494,7 +494,7 @@ struct ChatView: View { Button ("Cancel") { closeSearch() - Task { await loadChat(chat: chat) } + searchTextChanged("") } } .padding(.horizontal) @@ -585,23 +585,8 @@ struct ChatView: View { .padding(.vertical, -100) .onTapGesture { hideKeyboard() } .onChange(of: searchText) { s in - guard showSearch else { return } - Task { - await loadChat(chat: chat, search: s) - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) - await MainActor.run { - scrollView.updateItems(mergedItems.boxedValue.items) - } - if !s.isEmpty { - scrollView.scrollToBottom() - } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { - // scroll to the top unread item - scrollView.scrollToItem(index) - loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) - } else { - scrollView.scrollToBottom() - loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) - } + if showSearch { + searchTextChanged(s) } } .onChange(of: im.itemAdded) { added in @@ -652,6 +637,26 @@ 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 MainActor.run { + scrollView.updateItems(mergedItems.boxedValue.items) + } + if !s.isEmpty { + scrollView.scrollToBottom() + } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { + // scroll to the top unread item + scrollView.scrollToItem(index) + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + } else { + scrollView.scrollToBottom() + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + } + } + } + class FloatingButtonModel: ObservableObject { @Published var unreadAbove: Int = 0 @Published var unreadBelow: Int = 0 From ca31c9a5e967d44085a3e29f7440398af697fed0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 6 Mar 2025 22:59:32 +0000 Subject: [PATCH 115/289] blog: v6.3 announcement (#5714) --- .../Views/Onboarding/WhatsNewView.swift | 2 +- .../common/views/onboarding/WhatsNewView.kt | 2 +- ...user-experience-safety-in-public-groups.md | 62 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index f2b4dd7928..f7c7145dcc 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -542,7 +542,7 @@ private let versionDescriptions: [VersionDescription] = [ ), VersionDescription( version: "v6.3", - // post: URL(string: "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"), + post: URL(string: "https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html"), features: [ .feature(Description( icon: "at", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index a4eaf172d6..de9f909150 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -779,7 +779,7 @@ private val versionDescriptions: List = listOf( ), VersionDescription( version = "v6.3", - // post = "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html", + post = "https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html", features = listOf( VersionFeature.FeatureDescription( icon = MR.images.ic_at, diff --git a/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md new file mode 100644 index 0000000000..1e201825ce --- /dev/null +++ b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md @@ -0,0 +1,62 @@ +--- +layout: layouts/article.html +title: "SimpleX Chat v6.3: new user experience and safety in public groups" +date: 2025-03-08 +# previewBody: blog_previews/20241210.html +# image: images/20241210-operators-1.png +draft: true +permalink: "/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html" +--- + +# SimpleX Chat v6.3: new user experience and safety in public groups + +**Will be published:** Mar 8, 2025 + +This is a permalink for release announcement. + +What's new in v6.3: + +- improved groups: + - [mention other members](#mention-members). + - [private reports](#private-reports) to group admins. + - [preventing spam and abuse](#preventing-spam-and-abuse). + - [better performance](#better-privacy-and-security). +- [chat lists](#chat-lists) to keep track of what's important. +- [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages) +- [privacy and security improvements](#privacy-and-security-improvements). + +## What's new in v6.3 + +## SimpleX network + +Some links to answer the most common questions: + +[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers). + +[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users). + +[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-and-security-technical-details-and-limitations). + +[Frequently asked questions](../docs/FAQ.md). + +Please also see our [website](https://simplex.chat). + +## Please support us with your donations + +Huge *thank you* to everybody who donated to SimpleX Chat! + +Prioritizing users privacy and security, and also raising the investment, would have been impossible without your support and donations. + +Also, funding the work to transition the protocols to non-profit governance model would not have been possible without the donations we received from the users. + +Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. + +Your donations help us raise more funds — any amount, even the price of the cup of coffee, makes a big difference for us. + +See [this section](https://github.com/simplex-chat/simplex-chat/#please-support-us-with-your-donations) for the ways to donate. + +Thank you, + +Evgeny + +SimpleX Chat founder From 37050a99c9085c7e1961fdb1c9586dcf37f069ae Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 6 Mar 2025 23:02:47 +0000 Subject: [PATCH 116/289] android, desktop: add Catalan UI languages --- apps/multiplatform/android/build.gradle.kts | 1 + .../kotlin/chat/simplex/common/views/usersettings/Appearance.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 250616ea5c..8fc9d104d2 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -85,6 +85,7 @@ android { "en", "ar", "bg", + "ca", "cs", "de", "es", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index b4fead6692..7a1fc21b17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -1096,6 +1096,7 @@ object AppearanceScope { "en" to "English", "ar" to "العربية", "bg" to "Български", + "ca" to "Català", "cs" to "Čeština", "de" to "Deutsch", "es" to "Español", From 5bef7349d8e9fff32747fa93318409bd5d58542b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:06:39 +0400 Subject: [PATCH 117/289] ios: fix crash on migration to device (#5716) * ios: fix crash on migration to device * fix * remove logs * changes * logs * changes --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/Shared/Views/Migration/MigrateToDevice.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 2d83cdc7c8..1a2e1a4fb1 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -539,7 +539,7 @@ struct MigrateToDevice: View { chatInitControllerRemovingDatabases() } else if ChatModel.shared.chatRunning == true { // cannot delete storage if chat is running - try await apiStopChat() + try await stopChatAsync() } try await apiDeleteStorage() try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) @@ -632,6 +632,8 @@ struct MigrateToDevice: View { private func hideView() { onboardingStageDefault.set(.onboardingComplete) m.onboardingStage = .onboardingComplete + m.migrationState = nil + MigrationToDeviceState.save(nil) dismiss() } From a6631ce629ffc908a963e514c09cb8af9453f7f4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 7 Mar 2025 07:47:32 +0000 Subject: [PATCH 118/289] core: delete members with messages (#5711) * core: delete members with messages (WIP) * remove messages * fix, test * update query plans --- .../src/Directory/Service.hs | 4 +- src/Simplex/Chat/Controller.hs | 11 +-- src/Simplex/Chat/Library/Commands.hs | 73 ++++++++-------- src/Simplex/Chat/Library/Internal.hs | 36 ++++++++ src/Simplex/Chat/Library/Subscriber.hs | 18 ++-- src/Simplex/Chat/Protocol.hs | 12 +-- src/Simplex/Chat/Store/Messages.hs | 85 ++++++++++++++++--- .../SQLite/Migrations/chat_query_plans.txt | 79 ++++++++++++----- src/Simplex/Chat/View.hs | 11 +-- tests/ChatTests/Groups.hs | 63 ++++++++++++++ tests/ProtocolTests.hs | 2 +- 11 files changed, 302 insertions(+), 92 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 89006c9211..79654b4da6 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -503,8 +503,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName rejectPendingMember rjctNotice = do let gmId = groupMemberId' m sendComposedMessages cc (SRGroup groupId $ Just gmId) [MCText rjctNotice] - sendChatCmd cc (APIRemoveMembers groupId [gmId]) >>= \case - CRUserDeletedMembers _ _ (_ : _) -> do + sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case + CRUserDeletedMembers _ _ (_ : _) _ -> do atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected remove member response: " <> tshow r diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e639771f41..49ff263f6f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -41,6 +41,7 @@ import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) +import Data.Set (Set) import Data.String import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) @@ -359,7 +360,7 @@ data ChatCommand | APIAcceptMember GroupId GroupMemberId GroupMemberRole | APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole | APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool - | APIRemoveMembers GroupId (NonEmpty GroupMemberId) + | APIRemoveMembers {groupId :: GroupId, groupMemberIds :: Set GroupMemberId, withMessages :: Bool} | APILeaveGroup GroupId | APIListMembers GroupId | APIUpdateGroupProfile GroupId GroupProfile @@ -480,7 +481,7 @@ data ChatCommand | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} | MemberRole GroupName ContactName GroupMemberRole | BlockForAll GroupName ContactName Bool - | RemoveMembers GroupName (NonEmpty ContactName) + | RemoveMembers {groupName :: GroupName, members :: Set ContactName, withMessages :: Bool} | LeaveGroup GroupName | DeleteGroup GroupName | ClearGroup GroupName @@ -664,7 +665,7 @@ data ChatResponse | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} - | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} + | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus @@ -753,8 +754,8 @@ data ChatResponse | CRMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool} | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} - | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} - | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember, withMessages :: Bool} + | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, withMessages :: Bool} | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 821bc3d6ce..6a5775beb7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -43,6 +43,7 @@ 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, listToMaybe, mapMaybe, maybeToList) +import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -1043,8 +1044,7 @@ processChatCommand' vr = \case withContactLock "deleteChat direct" chatId . procCmd $ case cdm of CDMFull notify -> do - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo sendDelDeleteConns ct notify -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) @@ -1084,8 +1084,7 @@ processChatCommand' vr = \case unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId . procCmd $ do - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + 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 @@ -1103,15 +1102,13 @@ processChatCommand' vr = \case CTDirect -> do ct <- withFastStore $ \db -> getContact db vr user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withFastStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do gInfo <- withFastStore $ \db -> getGroupInfo db vr user chatId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + 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 @@ -2152,36 +2149,38 @@ processChatCommand' vr = \case let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag ts = ciContentTexts content in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing - APIRemoveMembers groupId memberIds -> withUser $ \user -> + APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId . procCmd $ do Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId - let (invitedMems, pendingMems, currentMems, maxRole, anyAdmin) = selectMembers members - when (length invitedMems + length pendingMems + length currentMems /= length memberIds) $ throwChatError CEGroupMemberNotFound - when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't remove multiple members when admins selected" + let (count, invitedMems, pendingMems, 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] + rs <- forM pendingMems $ \m -> deleteMemsSend user gInfo [m] [m] -- TODO [knocking] let (errs3, deleted3, acis3) = concatTuples rs acis = acis2 <> acis3 errs = errs1 <> errs2 <> errs3 unless (null acis) $ toView $ CRNewChatItems user acis unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2 <> deleted3) -- same order is not guaranteed + when withMessages $ deleteMessages user gInfo $ currentMems <> pendingMems + pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2 <> deleted3) withMessages -- same order is not guaranteed where - selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) - selectMembers = foldr' addMember ([], [], [], GRObserver, False) + selectMembers :: [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldl' addMember (0, [], [], [], GRObserver, False) where - addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, pending, current, maxRole, anyAdmin) - | groupMemberId `elem` memberIds = + addMember acc@(n, invited, pending, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole} + | groupMemberId `S.member` groupMemberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin - in - case memberStatus of - GSMemInvited -> (m : invited, pending, current, maxRole', anyAdmin') - GSMemPendingApproval -> (invited, m : pending, current, maxRole', anyAdmin') - _ -> (invited, pending, m : current, maxRole', anyAdmin') - | otherwise = (invited, pending, current, maxRole, anyAdmin) + 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') + | otherwise = acc deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) deleteInvitedMems user memsToDelete = do deleteMembersConnections user memsToDelete @@ -2194,7 +2193,7 @@ processChatCommand' vr = \case deleteMemsSend user gInfo sendToMems memsToDelete = case L.nonEmpty memsToDelete of Nothing -> pure ([], [], []) Just memsToDelete' -> do - let events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId) memsToDelete' + let events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' (msgs_, _gsr) <- sendGroupMessages user gInfo sendToMems events let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo) Nothing itemsData Nothing False @@ -2212,6 +2211,9 @@ processChatCommand' vr = \case delMember db m = do deleteOrUpdateMemberRecordIO db user m pure m {memberStatus = GSMemRemoved} + 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 @@ -2240,12 +2242,12 @@ processChatCommand' vr = \case processChatCommand $ APIJoinGroup groupId enableNtfs 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 -> withUser $ \user -> do + RemoveMembers gName gMemberNames withMessages -> withUser $ \user -> do (gId, gMemberIds) <- withStore $ \db -> do gId <- getGroupIdByName db user gName - gMemberIds <- forM gMemberNames $ getGroupMemberIdByName db user gId + gMemberIds <- S.fromList <$> mapM (getGroupMemberIdByName db user gId) (S.toList gMemberNames) pure (gId, gMemberIds) - processChatCommand $ APIRemoveMembers gId gMemberIds + processChatCommand $ APIRemoveMembers gId gMemberIds withMessages LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APILeaveGroup groupId @@ -3010,8 +3012,7 @@ processChatCommand' vr = \case deleteChatUser :: User -> Bool -> CM ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withFastStore' (`getUserFileInfo` user) - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) `catchChatError` \case e@(ChatErrorAgent NO_USER _) -> toView $ CRChatError (Just user) e @@ -3783,8 +3784,7 @@ expireContactChatItems user vr globalTTL ctId = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do lift waitChatStartedAndActivated filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () @@ -3795,8 +3795,7 @@ expireGroupChatItems user vr globalTTL createdAtCutoff groupId = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do lift waitChatStartedAndActivated filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo + deleteCIFiles user filesInfo withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m @@ -3939,8 +3938,8 @@ chatCommandP = "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI "/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), - "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* A.space <* "blocked=" <*> onOffP), - "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP), + "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* " blocked=" <*> onOffP), + "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)), "/_leave #" *> (APILeaveGroup <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal), "/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP), @@ -4026,7 +4025,7 @@ chatCommandP = ("/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), - ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (L.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',')), + ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (S.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',') <*> (" messages=" *> onOffP <|> pure False)), ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP), ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP), ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1b18abda37..0d7de70098 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -468,6 +468,24 @@ deleteGroupCIs user gInfo items byUser timed byGroupMember_ deletedTs = do Nothing -> Nothing <$ deleteGroupChatItem db user gInfo ci pure $ groupDeletion md gInfo ci ci' +deleteGroupMemberCIs :: MsgDirectionI d => User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> CM () +deleteGroupMemberCIs user gInfo member byGroupMember msgDir = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> deleteGroupMemberCIs_ db user gInfo member byGroupMember msgDir deletedTs + deleteCIFiles user filesInfo + +deleteGroupMembersCIs :: User -> GroupInfo -> [GroupMember] -> GroupMember -> CM () +deleteGroupMembersCIs user gInfo members byGroupMember = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> fmap concat $ forM members $ \m -> deleteGroupMemberCIs_ db user gInfo m byGroupMember SMDRcv deletedTs + deleteCIFiles user filesInfo + +deleteGroupMemberCIs_ :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> UTCTime -> IO [CIFileInfo] +deleteGroupMemberCIs_ db user gInfo member byGroupMember msgDir deletedTs = do + fs <- getGroupMemberFileInfo db user gInfo member + updateMemberCIsModerated db user gInfo member byGroupMember msgDir deletedTs + pure fs + deleteLocalCIs :: User -> NoteFolder -> [CChatItem 'CTLocal] -> Bool -> Bool -> CM ChatResponse deleteLocalCIs user nf items byUser timed = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items @@ -511,6 +529,24 @@ markGroupCIsDeleted user gInfo items byUser byGroupMember_ deletedTs = do ci' <- markGroupChatItemDeleted db user gInfo ci byGroupMember_ deletedTs pure $ groupDeletion md gInfo ci (Just ci') +markGroupMemberCIsDeleted :: User -> GroupInfo -> GroupMember -> GroupMember -> CM () +markGroupMemberCIsDeleted user gInfo member byGroupMember = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> markGroupMemberCIsDeleted_ db user gInfo member byGroupMember deletedTs + cancelFilesInProgress user filesInfo + +markGroupMembersCIsDeleted :: User -> GroupInfo -> [GroupMember] -> GroupMember -> CM () +markGroupMembersCIsDeleted user gInfo members byGroupMember = do + deletedTs <- liftIO getCurrentTime + filesInfo <- withStore' $ \db -> fmap concat $ forM members $ \m -> markGroupMemberCIsDeleted_ db user gInfo m byGroupMember deletedTs + cancelFilesInProgress user filesInfo + +markGroupMemberCIsDeleted_ :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> UTCTime -> IO [CIFileInfo] +markGroupMemberCIsDeleted_ db user gInfo member byGroupMember deletedTs = do + fs <- getGroupMemberFileInfo db user gInfo member + 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') where diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 739ab25ea9..f9fcecc5a1 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -867,7 +867,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 -> xGrpMemDel gInfo m' memId msg brokerTs + 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 @@ -2570,8 +2570,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> updateStatus introId GMIntroReConnected updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM () - xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg brokerTs = do + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> RcvMessage -> UTCTime -> CM () + xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages msg brokerTs = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId then checkRole membership $ do @@ -2580,8 +2580,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = members <- withStore' $ \db -> getGroupMembers db vr user gInfo deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved + when withMessages $ deleteMessages membership SMDSnd deleteMemberItem RGEUserDeleted - toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m + toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m withMessages else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.del with unknown member ID" @@ -2591,8 +2592,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberConnection user member -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user member + when withMessages $ deleteMessages member SMDRcv deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} + toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} withMessages where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = @@ -2601,6 +2603,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberItem gEvent = do ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo ci + deleteMessages :: MsgDirectionI d => GroupMember -> SMsgDirection d -> CM () + deleteMessages delMem msgDir + | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupMemberCIs user gInfo delMem m msgDir + | otherwise = markGroupMemberCIsDeleted user gInfo delMem m xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpLeave gInfo m msg brokerTs = do @@ -2726,7 +2732,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfo p -> xInfoMember gInfo author p msgTs XGrpMemNew memInfo -> xGrpMemNew gInfo author memInfo rcvMsg msgTs XGrpMemRole memId memRole -> xGrpMemRole gInfo author memId memRole rcvMsg msgTs - XGrpMemDel memId -> xGrpMemDel gInfo author memId rcvMsg msgTs + XGrpMemDel memId withMessages -> xGrpMemDel gInfo author memId withMessages rcvMsg msgTs XGrpLeave -> xGrpLeave gInfo author rcvMsg msgTs XGrpDel -> xGrpDel gInfo author rcvMsg msgTs XGrpInfo p' -> xGrpInfo gInfo author p' rcvMsg msgTs diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index a145914ce7..566cd04003 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -29,10 +29,12 @@ import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Either (fromRight) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -343,7 +345,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json XGrpMemCon :: MemberId -> ChatMsgEvent 'Json XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented - XGrpMemDel :: MemberId -> ChatMsgEvent 'Json + XGrpMemDel :: MemberId -> Bool -> ChatMsgEvent 'Json XGrpLeave :: ChatMsgEvent 'Json XGrpDel :: ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json @@ -384,7 +386,7 @@ isForwardedGroupMsg ev = case ev of 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) + XGrpMemDel {} -> True -- TODO there should be a special logic when deleting host member (e.g., host forwards it before deleting connections) XGrpLeave -> True XGrpDel -> True -- TODO there should be a special logic - host should forward before deleting connections XGrpInfo _ -> True @@ -991,7 +993,7 @@ toCMEventTag msg = case msg of XGrpMemRestrict _ _ -> XGrpMemRestrict_ XGrpMemCon _ -> XGrpMemCon_ XGrpMemConAll _ -> XGrpMemConAll_ - XGrpMemDel _ -> XGrpMemDel_ + XGrpMemDel {} -> XGrpMemDel_ XGrpLeave -> XGrpLeave_ XGrpDel -> XGrpDel_ XGrpInfo _ -> XGrpInfo_ @@ -1094,7 +1096,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions" XGrpMemCon_ -> XGrpMemCon <$> p "memberId" XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId" - XGrpMemDel_ -> XGrpMemDel <$> p "memberId" + XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") XGrpLeave_ -> pure XGrpLeave XGrpDel_ -> pure XGrpDel XGrpInfo_ -> XGrpInfo <$> p "groupProfile" @@ -1158,7 +1160,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions] XGrpMemCon memId -> o ["memberId" .= memId] XGrpMemConAll memId -> o ["memberId" .= memId] - XGrpMemDel memId -> o ["memberId" .= memId] + XGrpMemDel memId messages -> o $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] XGrpLeave -> JM.empty XGrpDel -> JM.empty XGrpInfo p -> o ["groupProfile" .= p] diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 00158e0054..ab7d866349 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -21,6 +21,7 @@ module Simplex.Chat.Store.Messages -- * Message and chat item functions deleteContactCIs, getGroupFileInfo, + getGroupMemberFileInfo, deleteGroupChatItemsMessages, createNewSndMessage, createSndMsgDelivery, @@ -57,8 +58,10 @@ module Simplex.Chat.Store.Messages updateGroupCIMentions, deleteGroupChatItem, updateGroupChatItemModerated, + updateMemberCIsModerated, updateGroupCIBlockedByAdmin, markGroupChatItemDeleted, + markMemberCIsDeleted, markGroupChatItemBlocked, markGroupCIBlockedByAdmin, markMessageReportsDeleted, @@ -193,6 +196,11 @@ getGroupFileInfo db User {userId} GroupInfo {groupId} = map toFileInfo <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ?") (userId, groupId) +getGroupMemberFileInfo :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO [CIFileInfo] +getGroupMemberFileInfo db User {userId} GroupInfo {groupId} GroupMember {groupMemberId} = + map toFileInfo + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ? AND i.group_member_id = ?") (userId, groupId, groupMemberId) + deleteGroupChatItemsMessages :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupChatItemsMessages db User {userId} GroupInfo {groupId} = do DB.execute db "DELETE FROM messages WHERE group_id = ?" (Only groupId) @@ -2150,18 +2158,18 @@ deleteDirectChatItem db User {userId} Contact {contactId} ci = do (userId, contactId, itemId) deleteChatItemMessages_ :: DB.Connection -> ChatItemId -> IO () -deleteChatItemMessages_ db itemId = - DB.execute - db - [sql| - DELETE FROM messages - WHERE message_id IN ( - SELECT message_id - FROM chat_item_messages - WHERE chat_item_id = ? - ) - |] - (Only itemId) +deleteChatItemMessages_ db itemId = DB.execute db deleteChatItemMessagesQuery (Only itemId) + +deleteChatItemMessagesQuery :: Query +deleteChatItemMessagesQuery = + [sql| + DELETE FROM messages + WHERE message_id IN ( + SELECT message_id + FROM chat_item_messages + WHERE chat_item_id = ? + ) + |] deleteChatItemVersions_ :: DB.Connection -> ChatItemId -> IO () deleteChatItemVersions_ db itemId = @@ -2359,6 +2367,34 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing} +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 + DB.executeMany db deleteChatItemMessagesQuery itemIds + DB.executeMany db "DELETE FROM chat_item_versions WHERE chat_item_id = ?" itemIds + where + memId = groupMemberId' member + updateQuery = + [sql| + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + |] + updateCIs :: UTCTime -> IO [Only Int64] + updateCIs currentTs + | memId == groupMemberId' membership = + DB.query + db + (updateQuery <> " AND group_member_id IS NULL AND item_sent = 1 RETURNING chat_item_id") + (columns :. (userId, groupId)) + | otherwise = + DB.query + db + (updateQuery <> " AND group_member_id = ? RETURNING chat_item_id") + (columns :. (userId, groupId, memId)) + where + columns = (deletedTs, groupMemberId' byGroupMember, msgDirToModeratedContent_ md, ciModeratedText, currentTs) + updateGroupCIBlockedByAdmin :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> UTCTime -> IO (ChatItem 'CTGroup d) updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = do currentTs <- getCurrentTime @@ -2405,6 +2441,31 @@ markGroupChatItemDeleted db User {userId} GroupInfo {groupId} ci@ChatItem {meta} (DBCIDeleted, deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) pure ci {meta = meta {itemDeleted, editable = False, deletable = False}} +markMemberCIsDeleted :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> UTCTime -> IO () +markMemberCIsDeleted db User {userId} GroupInfo {groupId, membership} member byGroupMember deletedTs = + updateCIs =<< getCurrentTime + where + memId = groupMemberId' member + updateQuery = + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + |] + updateCIs currentTs + | memId == groupMemberId' membership = + DB.execute + db + (updateQuery <> " AND group_member_id IS NULL AND item_sent = 1") + (columns :. (userId, groupId)) + | otherwise = + DB.execute + db + (updateQuery <> " AND group_member_id = ?") + (columns :. (userId, groupId, memId)) + where + columns = (DBCIDeleted, deletedTs, groupMemberId' byGroupMember, currentTs) + markGroupChatItemBlocked :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv) markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do deletedTs <- getCurrentTime 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 88836035e5..9cbd5965b7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3314,6 +3314,22 @@ Query: Plan: SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id = ? RETURNING chat_item_id +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id IS NULL AND item_sent = 1 RETURNING chat_item_id +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + Query: UPDATE chat_items SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? @@ -3322,6 +3338,22 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_member_id IS NULL AND item_sent = 1 +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + Query: UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? @@ -3861,25 +3893,6 @@ LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) SEARCH groups USING COVERING INDEX idx_groups_group_profile_id (group_profile_id=?) -Query: - DELETE FROM messages - WHERE message_id IN ( - SELECT message_id - FROM chat_item_messages - WHERE chat_item_id = ? - ) - -Plan: -SEARCH messages USING INTEGER PRIMARY KEY (rowid=?) -LIST SUBQUERY 1 -SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) -SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_message_id (message_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_created_by_msg_id (created_by_msg_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_created_by_msg_id (created_by_msg_id=?) -SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) -SEARCH chat_items USING COVERING INDEX sqlite_autoindex_chat_items_1 (created_by_msg_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_message_id (message_id=?) - Query: INSERT INTO calls (contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) @@ -4414,6 +4427,25 @@ Query: Plan: SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) +Query: + DELETE FROM messages + WHERE message_id IN ( + SELECT message_id + FROM chat_item_messages + WHERE chat_item_id = ? + ) + +Plan: +SEARCH messages USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_message_id (message_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) +SEARCH chat_items USING COVERING INDEX sqlite_autoindex_chat_items_1 (created_by_msg_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_message_id (message_id=?) + Query: SELECT -- GroupInfo @@ -4654,6 +4686,15 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.group_id = ? AND i.group_member_id = ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1c4f4258d8..5053f24ec6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -220,9 +220,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMembers u g members -> case members of - [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] - mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group"] + CRUserDeletedMembers u g members wm -> case members of + [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group" <> withMessages wm] + mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group" <> withMessages wm] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] @@ -306,8 +306,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked - CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g - CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] + CRDeletedMemberUser u g by wm -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group" <> withMessages wm] <> groupPreserved g + CRDeletedMember u g by m wm -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group" <> withMessages wm] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] CRGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] @@ -511,6 +511,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe | chatDirNtf u chat chatDir mention = s | testView = map (<> " ") s | otherwise = [] + withMessages wm = if wm then " with all messages" else "" userNtf :: User -> Bool userNtf User {showNtfs, activeUser} = showNtfs || activeUser diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 7ec4033046..2e99de4a82 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -79,6 +79,8 @@ chatGroupTests = do it "moderate message of another group member (full delete)" testGroupModerateFullDelete it "moderate message that arrives after the event of moderation" testGroupDelayedModeration it "moderate message that arrives after the event of moderation (full delete)" testGroupDelayedModerationFullDelete + it "remove member with messages (full deletion is enabled)" testDeleteMemberWithMessages + it "remove member with messages mark deleted" testDeleteMemberMarkMessagesDeleted describe "batch send messages" $ do it "send multiple messages api" testSendMulti it "send multiple timed messages" testSendMultiTimed @@ -1801,6 +1803,67 @@ testGroupDelayedModerationFullDelete ps = do -- version before forwarding, so cath doesn't expect alice to forward messages (groupForwardVersion = 4) cfg = testCfg {chatVRange = mkVersionRange (VersionChat 1) (VersionChat 3)} +testDeleteMemberWithMessages :: HasCallStack => TestParams -> IO () +testDeleteMemberWithMessages = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + threadDelay 750000 + alice ##> "/set delete #team on" + alice <## "updated group preferences:" + alice <## "Full deletion: on" + threadDelay 750000 + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "updated group preferences:" + bob <## "Full deletion: on", + do + cath <## "alice updated group #team:" + cath <## "updated group preferences:" + cath <## "Full deletion: on" + ] + threadDelay 750000 + bob #> "#team hello" + concurrently_ + (alice <# "#team bob> hello") + (cath <# "#team bob> hello") + alice #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + bob #$> ("/_get chat #1 count=1", chat, [(1, "hello")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + threadDelay 1000000 + alice ##> "/rm #team bob messages=on" + alice <## "#team: you removed bob from the group with all messages" + bob <## "#team: alice removed you from the group with all messages" + bob <## "use /d #team to delete the group" + cath <## "#team: alice removed bob from the group with all messages" + alice #$> ("/_get chat #1 count=2", chat, [(0, "moderated [deleted by you]"), (1, "removed bob (Bob)")]) + bob #$> ("/_get chat #1 count=2", chat, [(1, "moderated [deleted by alice]"), (0, "removed you")]) + cath #$> ("/_get chat #1 count=2", chat, [(0, "moderated [deleted by alice]"), (0, "removed bob (Bob)")]) + +testDeleteMemberMarkMessagesDeleted :: HasCallStack => TestParams -> IO () +testDeleteMemberMarkMessagesDeleted = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + threadDelay 1000000 + bob #> "#team hello" + concurrently_ + (alice <# "#team bob> hello") + (cath <# "#team bob> hello") + alice #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + bob #$> ("/_get chat #1 count=1", chat, [(1, "hello")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + threadDelay 1000000 + alice ##> "/rm #team bob messages=on" + alice <## "#team: you removed bob from the group with all messages" + bob <## "#team: alice removed you from the group with all messages" + bob <## "use /d #team to delete the group" + cath <## "#team: alice removed bob from the group with all messages" + alice #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by you]"), (1, "removed bob (Bob)")]) + bob #$> ("/_get chat #1 count=2", chat, [(1, "hello [marked deleted by alice]"), (0, "removed you")]) + cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "removed bob (Bob)")]) + testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 83087a89a8..5c672b2858 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -283,7 +283,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemConAll (MemberId "\1\2\3\4") it "x.grp.mem.del" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" - #==# XGrpMemDel (MemberId "\1\2\3\4") + #==# XGrpMemDel (MemberId "\1\2\3\4") False it "x.grp.leave" $ "{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave From 2d203c1a18c6b0178ab6af674edcca8c86d4753a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 7 Mar 2025 08:04:16 +0000 Subject: [PATCH 119/289] Translated using Weblate (Russian) (#5720) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ Co-authored-by: noname --- website/langs/ru.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/website/langs/ru.json b/website/langs/ru.json index 5d59ec2c76..c584d93355 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -32,7 +32,7 @@ "privacy-matters-1-overlay-1-title": "Конфиденциальность экономит ваши деньги", "simplex-private-card-5-point-1": "SimpleX использует заполнение содержимого для каждого уровня шифрования, чтобы предотвратить атаки на размер сообщения.", "privacy-matters-2-overlay-1-linkText": "Конфиденциальность дает вам власть", - "hero-overlay-3-title": "Оценка безопасности", + "hero-overlay-3-title": "Оценки безопасности", "enter-your-email-address": "Email адрес", "simplex-explained-tab-1-text": "1. Как это видят пользователи", "tap-to-close": "Нажмите, чтобы закрыть", @@ -101,7 +101,7 @@ "docs-dropdown-6": "Сервера WebRTC", "newer-version-of-eng-msg": "Существует более новая версия этой страницы на английском языке.", "install-simplex-app": "Установите приложение SimpleX", - "hero-overlay-3-textlink": "Оценка безопасности", + "hero-overlay-3-textlink": "Оценки безопасности", "comparison-point-2-text": "Возможность MITM", "scan-the-qr-code-with-the-simplex-chat-app-description": "Открытые ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", "guide-dropdown-8": "Настройки приложения", @@ -255,5 +255,6 @@ "please-enable-javascript": "Пожалуйста, включите JavaScript, чтобы увидеть QR-код.", "docs-dropdown-10": "Прозрачность", "docs-dropdown-12": "Безопасность", - "docs-dropdown-11": "Часто задаваемые вопросы" + "docs-dropdown-11": "Часто задаваемые вопросы", + "docs-dropdown-14": "SimpleX для бизнеса" } From f53b21f8c6548d6436b2738a29b63e47b3b82594 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 7 Mar 2025 08:21:17 +0000 Subject: [PATCH 120/289] ui: translations (#5719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Portuguese) Currently translated at 40.5% (946 of 2334 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt/ * Translated using Weblate (Italian) Currently translated at 100.0% (2334 of 2334 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% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2334 of 2334 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% (2065 of 2065 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% (2334 of 2334 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Czech) Currently translated at 99.7% (2328 of 2334 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2334 of 2334 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% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Czech) Currently translated at 99.8% (2331 of 2334 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Czech) Currently translated at 100.0% (2334 of 2334 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Arabic) Currently translated at 27.2% (563 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2334 of 2334 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 98.6% (2302 of 2334 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% (2334 of 2334 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2342 of 2342 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% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 92.5% (2167 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 92.5% (2168 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Arabic) Currently translated at 28.7% (593 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (Arabic) Currently translated at 29.5% (610 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (German) Currently translated at 100.0% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2342 of 2342 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% (2065 of 2065 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% (2342 of 2342 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% (2065 of 2065 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% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Russian) Currently translated at 100.0% (2342 of 2342 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 96.9% (2002 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Russian) Currently translated at 100.0% (2342 of 2342 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% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2342 of 2342 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% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Spanish) Currently translated at 99.8% (2339 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 99.8% (2061 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2342 of 2342 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% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2342 of 2342 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2065 of 2065 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * process localizations --------- Co-authored-by: Anonymous Co-authored-by: Random Co-authored-by: summoner001 Co-authored-by: mlanp Co-authored-by: zenobit Co-authored-by: Gholamy-Muh Co-authored-by: 大王叫我来巡山 Co-authored-by: noname Co-authored-by: jonnysemon Co-authored-by: Bezruchenko Simon Co-authored-by: João Moreira Co-authored-by: J R Co-authored-by: No name --- .../ar.xcloc/Localized Contents/ar.xliff | 903 ++++++++++++++++-- .../de.xcloc/Localized Contents/de.xliff | 4 + .../es.xcloc/Localized Contents/es.xliff | 22 +- .../hu.xcloc/Localized Contents/hu.xliff | 186 ++-- .../it.xcloc/Localized Contents/it.xliff | 4 + .../ru.xcloc/Localized Contents/ru.xliff | 125 +++ .../SimpleX SE/hu.lproj/Localizable.strings | 2 +- apps/ios/de.lproj/Localizable.strings | 12 + apps/ios/es.lproj/Localizable.strings | 30 +- apps/ios/hu.lproj/Localizable.strings | 190 ++-- apps/ios/it.lproj/Localizable.strings | 12 + apps/ios/ru.lproj/Localizable.strings | 373 ++++++++ .../commonMain/resources/MR/ar/strings.xml | 16 +- .../commonMain/resources/MR/cs/strings.xml | 8 +- .../commonMain/resources/MR/de/strings.xml | 28 +- .../commonMain/resources/MR/es/strings.xml | 36 +- .../commonMain/resources/MR/hu/strings.xml | 198 ++-- .../commonMain/resources/MR/it/strings.xml | 32 +- .../resources/MR/pt-rBR/strings.xml | 117 ++- .../commonMain/resources/MR/pt/strings.xml | 2 +- .../commonMain/resources/MR/ru/strings.xml | 81 +- .../commonMain/resources/MR/uk/strings.xml | 16 +- .../resources/MR/zh-rCN/strings.xml | 16 +- 23 files changed, 1978 insertions(+), 435 deletions(-) diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index ca61f88520..aba5e1384e 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -664,8 +664,9 @@ الدردشات No comment provided by engineer. - + Check server address and try again. + تحقق من عنوان الخادم وحاول مرة أخرى. No comment provided by engineer. @@ -676,44 +677,53 @@ Choose from library No comment provided by engineer. - + Clear + مسح No comment provided by engineer. - + Clear conversation + مسح الدردشة No comment provided by engineer. - + Clear conversation? + مسح الدردشة؟ No comment provided by engineer. - + Clear verification + امسح التحقُّق No comment provided by engineer. Colors No comment provided by engineer. - + Compare security codes with your contacts. + قارن رموز الأمان مع جهات اتصالك. No comment provided by engineer. - + Configure ICE servers + ضبط خوادم ICE No comment provided by engineer. - + Confirm + تأكيد No comment provided by engineer. - + Confirm new passphrase… + تأكيد عبارة المرور الجديدة… No comment provided by engineer. - + Connect + اتصل server test step @@ -724,8 +734,9 @@ Connect via group link? No comment provided by engineer. - + Connect via link + تواصل عبر الرابط No comment provided by engineer. @@ -740,52 +751,61 @@ Connect via relay No comment provided by engineer. - + Connecting to server… + جارِ الاتصال بالخادم… No comment provided by engineer. Connecting to server… (error: %@) No comment provided by engineer. - + Connection + الاتصال No comment provided by engineer. - + Connection error + خطأ في الإتصال No comment provided by engineer. - + Connection error (AUTH) + خطأ في الإتصال (المصادقة) No comment provided by engineer. Connection request No comment provided by engineer. - + Connection request sent! + أرسلت طلب الاتصال! No comment provided by engineer. - + Connection timeout + انتهت مهلة الاتصال No comment provided by engineer. - + Contact allows + تسمح جهة الاتصال No comment provided by engineer. - + Contact already exists + جهة الاتصال موجودة بالفعل No comment provided by engineer. Contact and all messages will be deleted - this cannot be undone! No comment provided by engineer. - + Contact hidden: + جهة الاتصال مخفية: notification @@ -796,24 +816,28 @@ Contact is not connected yet! No comment provided by engineer. - + Contact name + اسم جهة الاتصال No comment provided by engineer. - + Contact preferences + تفضيلات جهة الاتصال No comment provided by engineer. Contact requests No comment provided by engineer. - + Contacts can mark messages for deletion; you will be able to view them. + يمكن لجهات الاتصال تحديد الرسائل لحذفها؛ ستتمكن من مشاهدتها. No comment provided by engineer. - + Copy + نسخ chat item action @@ -824,60 +848,70 @@ Core version: v%@ No comment provided by engineer. - + Create + إنشاء No comment provided by engineer. Create address No comment provided by engineer. - + Create group link + إنشاء رابط المجموعة No comment provided by engineer. - + Create link + إنشاء رابط No comment provided by engineer. Create one-time invitation link No comment provided by engineer. - + Create queue + إنشاء قائمة انتظار server test step - + Create secret group + إنشاء مجموعة سرية No comment provided by engineer. - + Create your profile + أنشئ ملف تعريفك No comment provided by engineer. Created on %@ No comment provided by engineer. - + Current passphrase… + عبارة المرور الحالية… No comment provided by engineer. Currently maximum supported file size is %@. No comment provided by engineer. - + Dark + داكن No comment provided by engineer. - + Database ID + معرّف قاعدة البيانات No comment provided by engineer. - + Database encrypted! + قاعدة البيانات مُعمّاة! No comment provided by engineer. @@ -885,37 +919,45 @@ No comment provided by engineer. - + Database encryption passphrase will be updated. + سيتم تحديث عبارة مرور تعمية قاعدة البيانات. + No comment provided by engineer. - + Database error + خطأ في قاعدة البيانات No comment provided by engineer. - + Database is encrypted using a random passphrase, you can change it. + قاعدة البيانات مُعمّاة باستخدام عبارة مرور عشوائية، يمكنك تغييرها. No comment provided by engineer. - + Database is encrypted using a random passphrase. Please change it before exporting. + قاعدة البيانات مُعمّاة باستخدام عبارة مرور عشوائية. يُرجى تغييره قبل التصدير. No comment provided by engineer. - + Database passphrase + عبارة مرور قاعدة البيانات No comment provided by engineer. - + Database passphrase & export + عبارة مرور قاعدة البيانات وتصديرها No comment provided by engineer. Database passphrase is different from saved in the keychain. No comment provided by engineer. - + Database passphrase is required to open chat. + عبارة مرور قاعدة البيانات مطلوبة لفتح الدردشة. No comment provided by engineer. @@ -923,41 +965,49 @@ No comment provided by engineer. - + Database will be encrypted. + سيتم تعمية قاعدة البيانات. + No comment provided by engineer. Database will be migrated when the app restarts No comment provided by engineer. - + Decentralized + لامركزي No comment provided by engineer. - + Delete + حذف chat item action Delete Contact No comment provided by engineer. - + Delete address + حذف العنوان No comment provided by engineer. - + Delete address? + حذف العنوان؟ No comment provided by engineer. - + Delete after + حذف بعد No comment provided by engineer. - + Delete all files + حذف جميع الملفات No comment provided by engineer. @@ -968,48 +1018,59 @@ Delete chat archive? No comment provided by engineer. - + Delete chat profile? + حذف ملف تعريف الدردشة؟ No comment provided by engineer. - + Delete connection + حذف الاتصال No comment provided by engineer. - + Delete contact + حذف جهة الاتصال No comment provided by engineer. - + Delete contact? + حذف جهة الاتصال؟ No comment provided by engineer. - + Delete database + حذف قاعدة البيانات No comment provided by engineer. - + Delete files and media? + حذف الملفات والوسائط؟ No comment provided by engineer. - + Delete files for all chat profiles + حذف الملفات لجميع ملفات تعريف الدردشة No comment provided by engineer. - + Delete for everyone + حذف للجميع chat feature - + Delete for me + حذف بالنسبة لي No comment provided by engineer. - + Delete group + حذف المجموعة No comment provided by engineer. - + Delete group? + حذف المجموعة؟ No comment provided by engineer. @@ -1048,48 +1109,57 @@ Delete pending connection No comment provided by engineer. - + Delete pending connection? + حذف الاتصال قيد الانتظار؟ No comment provided by engineer. - + Delete queue + حذف قائمة الانتظار server test step Delete user profile? No comment provided by engineer. - + Description + الوصف No comment provided by engineer. Develop No comment provided by engineer. - + Developer tools + أدوات المطور No comment provided by engineer. - + Device + الجهاز No comment provided by engineer. - + Device authentication is disabled. Turning off SimpleX Lock. + استيثاق الجهاز مُعطَّل. جارِ إيقاف تشغيل قفل SimpleX. No comment provided by engineer. - + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + مصادقة الجهاز غير مفعّلة. يمكنك تشغيل قفل SimpleX عبر الإعدادات، بمجرد تفعيل مصادقة الجهاز. No comment provided by engineer. - + Different names, avatars and transport isolation. + أسماء مختلفة، صور الأفاتار وعزل النقل. No comment provided by engineer. - + Direct messages + رسائل مباشرة chat feature @@ -1132,44 +1202,54 @@ Do it later No comment provided by engineer. - + Duplicate display name! + اسم العرض مكرر! No comment provided by engineer. - + Edit + تحرير chat item action - + Edit group profile + حرّر ملف تعريف المجموعة No comment provided by engineer. - + Enable + تفعيل No comment provided by engineer. - + Enable SimpleX Lock + تفعيل قفل SimpleX authentication reason - + Enable TCP keep-alive + تفعيل أبقِ TCP على قيد الحياة No comment provided by engineer. - + Enable automatic message deletion? + تفعيل الحذف التلقائي للرسائل؟ No comment provided by engineer. - + Enable instant notifications? + تفعيل الإشعارات فورية؟ No comment provided by engineer. - + Enable notifications + تفعيل الإشعارات No comment provided by engineer. - + Enable periodic notifications? + تفعيل الإشعارات دورية؟ No comment provided by engineer. @@ -3597,72 +3677,87 @@ SimpleX servers cannot see your profile. secret No comment provided by engineer. - + starting… + يبدأ… No comment provided by engineer. - + strike + شطب No comment provided by engineer. this contact notification title - + unknown + غير معروف connection info - + updated group profile + حدثت ملف تعريف المجموعة rcv group event chat item v%@ (%@) No comment provided by engineer. - + via contact address link + عبر رابط عنوان الاتصال chat list item description - + via group link + عبر رابط المجموعة chat list item description - + via one-time link + عبر رابط لمرة واحدة chat list item description - + via relay + عبر المُرحل No comment provided by engineer. - + video call (not e2e encrypted) + مكالمة الفيديو ليست مُعمّاة بين الطريفين No comment provided by engineer. - + waiting for answer… + بانتظار الرد… No comment provided by engineer. - + waiting for confirmation… + في انتظار التأكيد… No comment provided by engineer. - + wants to connect to you! + يريد الاتصال بك! No comment provided by engineer. - + yes + نعم pref value - + you are invited to group + أنت مدعو إلى المجموعة No comment provided by engineer. - + you changed address + غيّرتَ العنوان chat item text @@ -3677,16 +3772,18 @@ SimpleX servers cannot see your profile. you changed role of %1$@ to %2$@ snd group event chat item - + you left + غادرت snd group event chat item you removed %@ snd group event chat item - + you shared one-time link + لقد شاركت رابط لمرة واحدة chat list item description @@ -4744,6 +4841,554 @@ SimpleX servers cannot see your profile. Chat profile ملف تعريف الدردشة + + Contact deleted! + حُذفت جهة الاتصال! + + + Conditions of use + شروط الاستخدام + + + Connecting + جارِ الاتصال + + + Connect incognito + اتصال متخفي + + + Created at + أُنشئ في + + + Connect via contact address + الاتصال عبر عنوان جهة الاتصال + + + Connected servers + الخوادم المتصلة + + + standard end-to-end encryption + التعمية القياسية بين الطرفين + + + Delete up to 20 messages at once. + حذف ما يصل إلى 20 رسالة في آن واحد. + + + Connect to your friends faster. + تواصل مع أصدقائك بشكل أسرع. + + + Developer options + خيارات المطور + + + Connect to yourself? + اتصل بنفسك؟ + + + Connect via one-time link + اتصال عبر رابط لمرة واحدة + + + Connect to yourself? +This is your own SimpleX address! + اتصل بنفسك؟ +هذا هو عنوان SimpleX الخاص بك! + + + Connecting to contact, please wait or check later! + جارِ الاتصال بجهة الاتصال، يُرجى الانتظار أو التحقق لاحقًا! + + + Database upgrade + ترقية قاعدة البيانات + + + Create list + أنشئ قائمة + + + Create profile + إنشاء ملف تعريف + + + Creating archive link + جارِ إنشاء رابط الأرشيف + + + Details + التفاصيل + + + Customize theme + تخصيص السمة + + + Dark mode colors + ألوان الوضع الداكن + + + Delete and notify contact + حذف وإشعار جهة الاتصال + + + Deleted at: %@ + حُذفت في: %@ + + + Detailed statistics + إحصائيات مفصلة + + + you are observer + أنت المراقب + + + you + أنت + + + when IP hidden + عندما يكون IP مخفيًا + + + video + فيديو + + + Clear or delete group? + مسح أو حذف المجموعة؟ + + + Clear private notes? + مسح الملاحظات الخاصة؟ + + + Community guidelines violation + انتهاك إرشادات المجتمع + + + Connection not ready. + الاتصال غير جاهز. + + + Connection requires encryption renegotiation. + يتطلب الاتصال إعادة التفاوض على التعمية. + + + Contact is deleted. + حُذفت جهة الاتصال. + + + Contacts + جهات الاتصال + + + Create SimpleX address + أنشئ عنوان SimpleX + + + Current conditions text couldn't be loaded, you can review conditions via this link: + لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: + + + Delete chat messages from your device. + احذف رسائل الدردشة من جهازك. + + + Delete or moderate up to 200 messages. + حذف أو إشراف ما يصل إلى 200 رسالة. + + + Delete profile + حذف ملف التعريف + + + Desktop devices + أجهزة سطح المكتب + + + set new profile picture + عيّن صورة تعريفية جديدة + + + weeks + أسابيع + + + Chunks uploaded + رُفع القطع + + + Color mode + وضع اللون + + + Created + أُنشئت + + + Current Passcode + رمز المرور الحالي + + + Custom time + وقت مخصّص + + + Debug delivery + تسليم التصحيح + + + Deleted + حُذفت + + + Delete file + حذف الملف + + + unknown status + حالة غير معروفة + + + unknown servers + خوادم غير معروفة + + + Connect to yourself? +This is your own one-time link! + اتصل بنفسك؟ +هذا هو الرابط الخاص بك لمرة واحدة! + + + Connect with %@ + الاتصال ب%@ + + + Connected desktop + سطح المكتب متصل + + + Connected to desktop + متصل بسطح المكتب + + + Conversation deleted! + حُذفت المحادثة! + + + Create a group using a random profile. + أنشئ مجموعة باستخدام ملف تعريف عشوائي. + + + Delete chat + احذف الدردشة + + + Delete chat profile + حذف ملف تعريف الدردشة + + + Delete chat? + حذف الدردشة؟ + + + Delete database from this device + احذف قاعدة البيانات من هذا الجهاز + + + Delivery + التوصيل + + + Delivery receipts are disabled! + إيصالات التسليم مُعطَّلة! + + + Connection terminated + انتهى الاتصال + + + Create file + إنشاء ملف + + + Create group + أنشئ مجموعة + + + Database IDs and Transport isolation option. + معرفات قاعدة البيانات وخيار عزل النقل. + + + Database downgrade + الرجوع إلى إصدار سابق من قاعدة البيانات + + + Delivery receipts! + إيصالات التسليم! + + + Desktop address + عنوان سطح المكتب + + + updated profile + حدّثت ملف التعريف + + + Connect to desktop + اتصل بسطح المكتب + + + Connecting to desktop + جار الاتصال بسطح المكتب + + + Completed + اكتملت + + + Connection notifications + إشعارات الاتصال + + + Connection and servers status. + حالة الاتصال والخوادم. + + + Continue + متابعة + + + Connections + الاتصالات + + + Content violates conditions of use + المحتوى ينتهك شروط الاستخدام + + + Corner + ركن + + + Creating link… + جارِ إنشاء الرابط… + + + Database ID: %d + معرّف قاعدة البيانات: %d + + + Decryption error + خطأ في فك التعمية + + + Delete report + احذف البلاغ + + + Delete without notification + احذف دون إشعار + + + Deleted at + حُذفت في + + + Clear group? + مسح المجموعة؟ + + + Compare file + قارن الملف + + + Connect automatically + اتصل تلقائيًا + + + Connection blocked + حُظر الاتصال + + + unprotected + غير محمي + + + Deletion errors + أخطاء الحذف + + + Conditions will be accepted for enabled operators after 30 days. + سيتم قبول الشروط للمُشغلين المفعّلين بعد 30 يومًا. + + + Connection security + أمان الاتصال + + + Contact will be deleted - this cannot be undone! + سيتم حذف جهة الاتصال - لا يمكن التراجع عن هذا! + + + Copy error + خطأ في النسخ + + + Create 1-time link + أنشئ رابط لمرة واحدة + + + Connected + متصل + + + Current profile + ملف التعريف الحالي + + + Customizable message shape. + شكل الرسالة قابل للتخصيص. + + + Chunks deleted + حُذفت القطع + + + Chinese and Spanish interface + الواجهة الصينية والاسبانية + + + Download + نزّل + + + Downloaded + نُزّلت + + + Downloaded files + الملفات التي نُزّلت + + + Don't show again + لا تُظهر مرة أخرى + + + Confirm contact deletion? + تأكيد حذف جهة الاتصال؟ + + + Confirm database upgrades + تأكيد ترقيات قاعدة البيانات + + + Download failed + فشل التنزيل + + + Download file + نزّل الملف + + + Downloading link details + جارِ تنزيل تفاصيل الرابط + + + Downloading archive + جارِ تنزيل الأرشيف + + + Don't enable + لا تُفعل + + + Confirm upload + أكّد الرفع + + + Chunks downloaded + نُزّلت القطع + + + Confirm Passcode + تأكيد رمز المرور + + + Confirm files from unknown servers. + تأكيد الملفات من خوادم غير معروفة. + + + Confirm network settings + أكّد إعدادات الشبكة + + + Confirm that you remember database passphrase to migrate it. + تأكد من أنك تتذكر عبارة مرور قاعدة البيانات لترحيلها. + + + Downgrade and open chat + الرجوع إلى إصدار سابق وفتح الدردشة + + + Don't miss important messages. + لا تفوت رسائل مهمة. + + + E2E encrypted notifications. + إشعارات مُشفرة بين الطرفين E2E + + + Download errors + أخطاء التنزيل + + + Download files + نزّل الملفات + + + Confirm password + تأكيد كلمة المرور + + + Enable self-destruct + تفعيل التدمير الذاتي + + + Enable (keep overrides) + تفعيل (الاحتفاظ بالتجاوزات) + + + Enable Flux + فعّل flux + + + Enable in direct chats (BETA)! + فعّل في الدردشات المباشرة (تجريبي)! + + + Enable for all + تفعيل للجميع + + + Enable lock + تفعيل القفل + + + Enable camera access + فعّل الوصول إلى الكاميرا + + + Enable self-destruct passcode + تفعيل رمز التدمير الذاتي + @@ -4798,6 +5443,74 @@ SimpleX servers cannot see your profile. You can allow sharing in Privacy & Security / SimpleX Lock settings. يمكنك السماح بالمشاركة في إعدادات الخصوصية والأمان / اعدادات "SimpleX Lock" + + Keychain error + خطأ في Keychain + + + Invalid migration confirmation + تأكيد الترحيل غير صالح + + + %@ + %@ + + + Share + مشاركة + + + Incompatible database version + إصدار قاعدة بيانات غير متوافق + + + File error + خطأ في الملف + + + Database downgrade required + مطلوب الرجوع إلى إصدار سابق من قاعدة البيانات‎ + + + Database encrypted! + قاعدة البيانات مُعمّاة! + + + Wrong database passphrase + عبارة مرور قاعدة بيانات خاطئة + + + Selected chat preferences prohibit this message. + تفضيلات الدردشة المحدّدة تحظر هذه الرسالة. + + + Database error + خطأ في قاعدة البيانات + + + Database passphrase is required to open chat. + عبارة مرور قاعدة البيانات مطلوبة لفتح الدردشة. + + + Error: %@ + خطأ: %@ + + + Cancel + إلغاء + + + Large file! + الملف كبير! + + + + + + + From: %@ + من: %@ + 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 6674d1587b..1c016f4614 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -8261,6 +8261,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Updated conditions + Aktualisierte Nutzungsbedingungen No comment provided by engineer. @@ -9842,10 +9843,12 @@ Verbindungsanfrage wiederholen? pending + ausstehend No comment provided by engineer. pending approval + ausstehende Genehmigung No comment provided by engineer. @@ -9865,6 +9868,7 @@ Verbindungsanfrage wiederholen? rejected + abgelehnt No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index e9b4de3017..07a1447735 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -379,7 +379,7 @@ **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Atención**: NO podrás recuperar o cambiar la contraseña si la pierdes. + **Atención**: Si la pierdes NO podrás recuperar o cambiar la contraseña. No comment provided by engineer. @@ -1245,7 +1245,7 @@ Better groups performance - Mejor rendimiento de los grupos + Rendimiento de grupos mejorado No comment provided by engineer. @@ -1270,7 +1270,7 @@ Better privacy and security - Mejor privacidad y seguridad + Privacidad y seguridad mejoradas No comment provided by engineer. @@ -3946,7 +3946,7 @@ Error: %2$@ Get notified when mentioned. - Las menciones ahora notifican. + Las menciones ahora se notifican. No comment provided by engineer. @@ -4066,7 +4066,7 @@ Error: %2$@ Help admins moderating their groups. - Ayuda a los admins moderar sus grupos. + Ayuda a los admins a moderar sus grupos. No comment provided by engineer. @@ -4853,7 +4853,7 @@ This is your link for group %@! Mention members 👋 - Mencionar miembros 👋 + Menciona a miembros 👋 No comment provided by engineer. @@ -5686,7 +5686,7 @@ Requiere activación de la VPN. Organize chats into lists - Organiza los chats en listas + Organiza tus chats en listas No comment provided by engineer. @@ -5957,7 +5957,7 @@ Error: %@ Private media file names. - Nombres privados de archivos de media. + Nombres privados en archivos de media. No comment provided by engineer. @@ -7131,7 +7131,7 @@ Actívalo en ajustes de *Servidores y Redes*. Set message expiration in chats. - Establece vencimiento de los mensajes en chats. + Establece el vencimiento para los mensajes en los chats. No comment provided by engineer. @@ -8261,6 +8261,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Updated conditions + Condiciones actualizadas No comment provided by engineer. @@ -9842,10 +9843,12 @@ Repeat connection request? pending + pendiente No comment provided by engineer. pending approval + pendiente de aprobación No comment provided by engineer. @@ -9865,6 +9868,7 @@ Repeat connection request? rejected + rechazado No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index cd2c8022c1..1ff79e2697 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -592,7 +592,7 @@ Abort changing address? - Cím módosításának megszakítása? + Megszakítja a cím módosítását? No comment provided by engineer. @@ -624,12 +624,12 @@ Accept connection request? - Kapcsolatkérés elfogadása? + Elfogadja a kapcsolódási kérést? No comment provided by engineer. Accept contact request from %@? - Elfogadja %@ kapcsolatkérését? + Elfogadja %@ kapcsolódási kérését? notification body @@ -835,7 +835,7 @@ All new messages from %@ will be hidden! - Az összes %@ által írt új üzenet el lesz rejtve! + %@ összes új üzenete el lesz rejtve! No comment provided by engineer. @@ -875,7 +875,7 @@ Allow calls? - Hívások engedélyezése? + Engedélyezi a hívásokat? No comment provided by engineer. @@ -950,7 +950,7 @@ Allow voice messages? - Hangüzenetek engedélyezése? + Engedélyezi a hangüzeneteket? No comment provided by engineer. @@ -1095,12 +1095,12 @@ Archive %lld reports? - %lld jelentés archiválása? + Archivál %lld jelentést? No comment provided by engineer. Archive all reports? - Az összes jelentés archiválása? + Archiválja az összes jelentést? No comment provided by engineer. @@ -1120,7 +1120,7 @@ Archive report? - Jelentés archiválása? + Archiválja a jelentést? No comment provided by engineer. @@ -1310,12 +1310,12 @@ Block member for all? - Az összes tag számára letiltja ezt a tagot? + Az összes tag számára letiltja a tagot? No comment provided by engineer. Block member? - Tag letiltása? + Letiltja a tagot? No comment provided by engineer. @@ -1471,7 +1471,7 @@ Change automatic message deletion? - Automatikus üzenettörlés módosítása? + Módosítja az automatikus üzenettörlést? alert title @@ -1481,7 +1481,7 @@ Change database passphrase? - Az adatbázis jelmondatának módosítása? + Módosítja az adatbázis jelmondatát? No comment provided by engineer. @@ -1491,7 +1491,7 @@ Change member role? - Tag szerepkörének módosítása? + Módosítja a tag szerepkörét? No comment provided by engineer. @@ -1692,12 +1692,12 @@ Clear conversation? - Üzenetek kiürítése? + Kiüríti az üzeneteket? No comment provided by engineer. Clear group? - Csoport kiürítése? + Kiüríti a csoportot? No comment provided by engineer. @@ -1707,7 +1707,7 @@ Clear private notes? - Privát jegyzetek kiürítése? + Kiüríti a privát jegyzeteket? No comment provided by engineer. @@ -1747,7 +1747,7 @@ Conditions accepted on: %@. - Feltételek elfogadva: %@. + Feltételek elfogadásának ideje: %@. No comment provided by engineer. @@ -1782,12 +1782,12 @@ Conditions will be accepted on: %@. - A feltételek ekkor lesznek elfogadva: %@. + A feltételek el lesznek fogadva a következő időpontban: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. - A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %@. + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára a következő időpontban: %@. No comment provided by engineer. @@ -1877,20 +1877,20 @@ Connect to yourself? - Kapcsolódás saját magához? + Kapcsolódik saját magához? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! - Kapcsolódás saját magához? + Kapcsolódik saját magához? Ez az Ön SimpleX-címe! No comment provided by engineer. Connect to yourself? This is your own one-time link! - Kapcsolódás saját magához? + Kapcsolódik saját magához? Ez az Ön egyszer használható meghívási hivatkozása! No comment provided by engineer. @@ -2128,7 +2128,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Correct name to %@? - Név helyesbítése a következőre: %@? + Helyesbíti a nevet a következőre: %@? No comment provided by engineer. @@ -2402,7 +2402,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete %lld messages? - %lld üzenet törlése? + Töröl %lld üzenetet? No comment provided by engineer. @@ -2412,7 +2412,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete address? - Cím törlése? + Törli a címet? No comment provided by engineer. @@ -2447,12 +2447,12 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete chat profile? - Csevegési profil törlése? + Törli a csevegési profilt? No comment provided by engineer. Delete chat? - Csevegés törlése? + Törli a csevegést? No comment provided by engineer. @@ -2467,7 +2467,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete contact? - Partner törlése? + Törli a partnert? No comment provided by engineer. @@ -2487,7 +2487,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete files and media? - A fájlok- és a médiatartalmak törlése? + Törli a fájl- és a médiatartalmakat? No comment provided by engineer. @@ -2512,7 +2512,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete group? - Csoport törlése? + Törli a csoportot? No comment provided by engineer. @@ -2527,22 +2527,22 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete link? - Hivatkozás törlése? + Törli a hivatkozást? No comment provided by engineer. Delete list? - Lista törlése? + Törli a listát? alert title Delete member message? - Csoporttag üzenetének törlése? + Törli a tag üzenetét? No comment provided by engineer. Delete message? - Üzenet törlése? + Törli az üzenetet? No comment provided by engineer. @@ -2562,7 +2562,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete old database? - Régi adatbázis törlése? + Törli a régi adatbázist? No comment provided by engineer. @@ -2572,7 +2572,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete pending connection? - Függőben lévő partnerkérelem törlése? + Törli a függőben lévő meghívót? No comment provided by engineer. @@ -2597,7 +2597,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete user profile? - Felhasználói profil törlése? + Törli a felhasználói profilt? No comment provided by engineer. @@ -2752,7 +2752,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Disable automatic message deletion? - Automatikus üzenettörlés letiltása? + Letiltja az automatikus üzenettörlést? alert title @@ -2807,7 +2807,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Disconnect desktop? - Számítógép leválasztása? + Leválasztja a számítógépet? No comment provided by engineer. @@ -2983,7 +2983,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Enable automatic message deletion? - Automatikus üzenet törlés engedélyezése? + Engedélyezi az automatikus üzenettörlést? alert title @@ -3003,7 +3003,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Enable instant notifications? - Azonnali értesítések engedélyezése? + Engedélyezi az azonnali értesítéseket? No comment provided by engineer. @@ -3018,7 +3018,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Enable periodic notifications? - Időszakos értesítések engedélyezése? + Engedélyezi az időszakos értesítéseket? No comment provided by engineer. @@ -3048,7 +3048,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Encrypt database? - Adatbázis titkosítása? + Titkosítja az adatbázist? No comment provided by engineer. @@ -3837,7 +3837,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Forward %d message(s)? - %d üzenet továbbítása? + Továbbít %d üzenetet? alert title @@ -3852,7 +3852,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Forward messages without files? - Üzenetek továbbítása fájlok nélkül? + Továbbítja az üzeneteket fájlok nélkül? alert message @@ -4216,7 +4216,7 @@ Hiba: %2$@ Import chat database? - Csevegési adatbázis importálása? + Importálja a csevegési adatbázist? No comment provided by engineer. @@ -4588,7 +4588,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Keep unused invitation? - Fel nem használt meghívó megtartása? + Megtartja a fel nem használt meghívót? alert title @@ -4633,7 +4633,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Leave chat? - Csevegés elhagyása? + Elhagyja a csevegést? No comment provided by engineer. @@ -4643,7 +4643,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Leave group? - Csoport elhagyása? + Elhagyja a csoportot? No comment provided by engineer. @@ -4808,7 +4808,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Member will be removed from group - this cannot be undone! - A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! + A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -4963,7 +4963,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages from %@ will be shown! - Az összes %@ által írt üzenet meg fog jelenni! + %@ összes üzenete meg fog jelenni! No comment provided by engineer. @@ -5128,7 +5128,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Network connection - Internetkapcsolat + Hálózati kapcsolat No comment provided by engineer. @@ -5148,7 +5148,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Network operator - Hálózati üzemeltető + Hálózatüzemeltető No comment provided by engineer. @@ -5636,7 +5636,7 @@ VPN engedélyezése szükséges. Open web link? - Webhivatkozás megnyitása? + Megnyitja a webhivatkozást? No comment provided by engineer. @@ -5820,7 +5820,7 @@ Minden további problémát osszon meg a fejlesztőkkel. Please check your network connection with %@ and try again. - Ellenőrizze a hálózati kapcsolatát a következővel: %@, és próbálja újra. + Ellenőrizze a hálózati kapcsolatát a vele: %@, és próbálja újra. No comment provided by engineer. @@ -6094,7 +6094,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Protocol timeout per KB - Protokoll időtúllépése KB-onként + Protokoll időtúllépése kB-onként No comment provided by engineer. @@ -6264,7 +6264,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Reconnect all servers? - Újrakapcsolódás az összes kiszolgálóhoz? + Újrakapcsolódik az összes kiszolgálóhoz? No comment provided by engineer. @@ -6274,12 +6274,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Reconnect server? - Újrakapcsolódás a kiszolgálóhoz? + Újrakapcsolódik a kiszolgálóhoz? No comment provided by engineer. Reconnect servers? - Újrakapcsolódás a kiszolgálókhoz? + Újrakapcsolódik a kiszolgálókhoz? No comment provided by engineer. @@ -6304,7 +6304,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Register notification token? - Értesítési token regisztrálása? + Regisztrálja az értesítési tokent? token info @@ -6345,7 +6345,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Remove archive? - Archívum eltávolítása? + Eltávolítja az archívumot? No comment provided by engineer. @@ -6360,12 +6360,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Remove member? - Biztosan eltávolítja? + Eltávolítja a tagot? No comment provided by engineer. Remove passphrase from keychain? - Jelmondat eltávolítása a kulcstartóból? + Eltávolítja a jelmondatot a kulcstartóból? No comment provided by engineer. @@ -6380,12 +6380,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Renegotiate encryption? - Titkosítás újraegyeztetése? + Újraegyezteti a titkosítást? No comment provided by engineer. Repeat connection request? - Kapcsolatkérés megismétlése? + Megismétli a kapcsolódási kérést? No comment provided by engineer. @@ -6400,7 +6400,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Repeat join request? - Csatlakozáskérés megismétlése? + Megismétli a csatlakozási kérést? No comment provided by engineer. @@ -6485,7 +6485,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Reset all statistics? - Az összes statisztika visszaállítása? + Visszaállítja az összes statisztikát? No comment provided by engineer. @@ -6530,7 +6530,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Restore database backup? - Adatbázismentés visszaállítása? + Visszaállítja az adatbázismentést? No comment provided by engineer. @@ -6570,7 +6570,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Revoke file? - Fájl visszavonása? + Visszavonja a fájlt? No comment provided by engineer. @@ -6656,7 +6656,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Save preferences? - Beállítások mentése? + Menti a beállításokat? alert title @@ -6671,17 +6671,17 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Save servers? - Kiszolgálók mentése? + Menti a kiszolgálókat? alert title Save welcome message? - Üdvözlőüzenet mentése? + Menti az üdvözlőüzenetet? No comment provided by engineer. Save your profile? - Profil mentése? + Menti a profilt? alert title @@ -7460,7 +7460,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Start chat? - Csevegés indítása? + Elindítja a csevegést? No comment provided by engineer. @@ -7500,7 +7500,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Stop chat? - Csevegési szolgáltatás megállítása? + Megállítja a csevegést? No comment provided by engineer. @@ -7510,12 +7510,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Stop receiving file? - Fájlfogadás megállítása? + Megállítja a fájlfogadást? No comment provided by engineer. Stop sending file? - Fájlküldés megállítása? + Megállítja a fájlküldést? No comment provided by engineer. @@ -7525,7 +7525,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Stop sharing address? - Címmegosztás megállítása? + Megállítja a címmegosztást? alert title @@ -7722,7 +7722,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The app can notify you when you receive messages or contact requests - please open settings to enable. - Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – beállítások megnyitása az engedélyezéshez. + Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – ezt a beállítások menüben engedélyezheti. No comment provided by engineer. @@ -8134,7 +8134,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Unblock member? - Tag feloldása? + Feloldja a tag letiltását? No comment provided by engineer. @@ -8211,7 +8211,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Unlink desktop? - Számítógép leválasztása? + Leválasztja a számítógépet? No comment provided by engineer. @@ -8251,16 +8251,17 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Update network settings? - Hálózati beállítások módosítása? + Módosítja a hálózati beállításokat? No comment provided by engineer. Update settings? - Beállítások frissítése? + Frissíti a beállításokat? No comment provided by engineer. Updated conditions + Frissített feltételek No comment provided by engineer. @@ -8757,7 +8758,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso You are already joining the group! Repeat join request? A csatlakozás már folyamatban van a csoporthoz! -Csatlakozáskérés megismétlése? +Megismétli a csatlakozási kérést? No comment provided by engineer. @@ -8903,8 +8904,8 @@ Csatlakozáskérés megismétlése? You have already requested connection! Repeat connection request? - Már küldött egy kapcsolódási kérelmet! -Kapcsolatkérés megismétlése? + Ön már küldött egy kapcsolódási kérést! +Megismétli a kapcsolódási kérést? No comment provided by engineer. @@ -9149,7 +9150,7 @@ Kapcsolatkérés megismétlése? Your servers - Az Ön kiszolgálói + Saját kiszolgálók No comment provided by engineer. @@ -9374,7 +9375,7 @@ Kapcsolatkérés megismétlése? connecting (introduced) - kapcsolódás (bejelentve) + kapcsolódás (bemutatkozva) No comment provided by engineer. @@ -9680,7 +9681,7 @@ Kapcsolatkérés megismétlése? invited to connect - meghívta, hogy csatlakozzon + meghívta egy partnerét chat list item title @@ -9842,10 +9843,12 @@ Kapcsolatkérés megismétlése? pending + függőben No comment provided by engineer. pending approval + jóváhagyásra vár No comment provided by engineer. @@ -9865,6 +9868,7 @@ Kapcsolatkérés megismétlése? rejected + elutasítva No comment provided by engineer. @@ -10438,7 +10442,7 @@ utoljára fogadott üzenet: %2$@ Slow network? - Lassú internetkapcsolat? + Lassú a hálózata? No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 7bc503c097..2855e1bd77 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -8261,6 +8261,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Updated conditions + Condizioni aggiornate No comment provided by engineer. @@ -9842,10 +9843,12 @@ Ripetere la richiesta di connessione? pending + in attesa No comment provided by engineer. pending approval + in attesa di approvazione No comment provided by engineer. @@ -9865,6 +9868,7 @@ Ripetere la richiesta di connessione? rejected + rifiutato No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 567e36c53a..edb7ca86e2 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -14,6 +14,7 @@ + No comment provided by engineer. @@ -544,10 +545,12 @@ @%@ + @%@ No comment provided by engineer. @'%@' + @'%@' No comment provided by engineer. @@ -652,6 +655,7 @@ Active + Активный token status text @@ -796,6 +800,7 @@ All chats will be removed from the list (text), and the list deleted. + Все чаты будут удалены из списка, а сам список удален. alert message @@ -840,6 +845,7 @@ All reports will be archived for you. + Все отчеты будут заархивированы для вас. No comment provided by engineer. @@ -919,6 +925,7 @@ Allow to report messsages to moderators. + Разрешить отправлять жалобы на сообщения модераторам. No comment provided by engineer. @@ -1033,6 +1040,7 @@ App group: + Группа приложения: No comment provided by engineer. @@ -1087,10 +1095,12 @@ Archive %lld reports? + Архивировать %lld отчёт(ов)? No comment provided by engineer. Archive all reports? + Архивировать все отчеты? No comment provided by engineer. @@ -1115,6 +1125,7 @@ Archive reports + Архивировать отчёты swipe action @@ -1129,6 +1140,7 @@ Ask + спросить No comment provided by engineer. @@ -1233,6 +1245,7 @@ Better groups performance + Улучшенная производительность групп No comment provided by engineer. @@ -1257,6 +1270,7 @@ Better privacy and security + Улучшенная приватность и безопасность No comment provided by engineer. @@ -1361,6 +1375,7 @@ Businesses + Предприятия No comment provided by engineer. @@ -1456,6 +1471,7 @@ Change automatic message deletion? + Измененить автоматическое удаление сообщений? alert title @@ -1681,10 +1697,12 @@ Clear group? + Очистить группу? No comment provided by engineer. Clear or delete group? + Очистить или удалить группу? No comment provided by engineer. @@ -1709,6 +1727,7 @@ Community guidelines violation + Нарушение принципов Сообщества report reason @@ -1828,6 +1847,7 @@ Confirmed + Подтвержденный token status text @@ -1951,6 +1971,7 @@ This is your own one-time link! Connection blocked + Соединение заблокировано No comment provided by engineer. @@ -1966,10 +1987,13 @@ This is your own one-time link! Connection is blocked by server operator: %@ + Соединение заблокировано сервером оператора: +%@ No comment provided by engineer. Connection not ready. + Соединение не готово. No comment provided by engineer. @@ -1984,6 +2008,7 @@ This is your own one-time link! Connection requires encryption renegotiation. + Соединение требует повторного согласования шифрования. No comment provided by engineer. @@ -2068,6 +2093,7 @@ This is your own one-time link! Content violates conditions of use + Содержание нарушает условия использования blocking reason @@ -2147,6 +2173,7 @@ This is your own one-time link! Create list + Создать список No comment provided by engineer. @@ -2410,6 +2437,7 @@ This is your own one-time link! Delete chat messages from your device. + Удалить сообщения с вашего устройства. No comment provided by engineer. @@ -2504,6 +2532,7 @@ This is your own one-time link! Delete list? + Удалить список? alert title @@ -2558,6 +2587,7 @@ This is your own one-time link! Delete report + удалить отчёт No comment provided by engineer. @@ -2722,10 +2752,12 @@ This is your own one-time link! Disable automatic message deletion? + Отключить автоматическое удаление сообщений? alert title Disable delete messages + Отключить удаление сообщений alert button @@ -2820,6 +2852,7 @@ This is your own one-time link! Documents: + Документы: No comment provided by engineer. @@ -2834,6 +2867,7 @@ This is your own one-time link! Don't miss important messages. + Не пропустите важные сообщения. No comment provided by engineer. @@ -2843,6 +2877,7 @@ This is your own one-time link! Done + Готово No comment provided by engineer. @@ -3078,6 +3113,7 @@ This is your own one-time link! Encryption renegotiation in progress. + Выполняется повторное согласование шифрования. No comment provided by engineer. @@ -3192,6 +3228,7 @@ This is your own one-time link! Error checking token status + Ошибка проверки статуса токена No comment provided by engineer. @@ -3216,6 +3253,7 @@ This is your own one-time link! Error creating list + Ошибка создания списка alert title @@ -3235,6 +3273,7 @@ This is your own one-time link! Error creating report + Ошибка создания отчета No comment provided by engineer. @@ -3349,6 +3388,7 @@ This is your own one-time link! Error registering for notifications + Ошибка регистрации для уведомлений alert title @@ -3358,6 +3398,7 @@ This is your own one-time link! Error reordering lists + Ошибка переупорядочивания списков alert title @@ -3372,6 +3413,7 @@ This is your own one-time link! Error saving chat list + Ошибка сохранения списка чатов alert title @@ -3456,6 +3498,7 @@ This is your own one-time link! Error testing server connection + Ошибка проверки соединения с сервером No comment provided by engineer. @@ -3540,6 +3583,7 @@ This is your own one-time link! Expired + Истекший token status text @@ -3584,6 +3628,7 @@ This is your own one-time link! Faster deletion of groups. + Ускорено удаление групп. No comment provided by engineer. @@ -3593,6 +3638,7 @@ This is your own one-time link! Faster sending messages. + Ускорена отправка сообщений. No comment provided by engineer. @@ -3602,6 +3648,7 @@ This is your own one-time link! Favorites + Избранное No comment provided by engineer. @@ -3619,6 +3666,7 @@ This is your own one-time link! File is blocked by server operator: (info.reason.text). + Файл заблокирован оператором сервера file error text @@ -3748,6 +3796,7 @@ This is your own one-time link! For all moderators + Для всех модераторов No comment provided by engineer. @@ -3767,6 +3816,7 @@ This is your own one-time link! For me + Для меня No comment provided by engineer. @@ -3895,6 +3945,7 @@ Error: %2$@ Get notified when mentioned. + Получайте уведомления от упоминаний. No comment provided by engineer. @@ -4004,6 +4055,7 @@ Error: %2$@ Groups + Группы No comment provided by engineer. @@ -4013,6 +4065,7 @@ Error: %2$@ Help admins moderating their groups. + Помогайте администраторам модерировать их группы. No comment provided by engineer. @@ -4077,6 +4130,7 @@ Error: %2$@ How it works + Как это работает alert button @@ -4222,10 +4276,12 @@ More improvements are coming soon! Inappropriate content + Нежелательный контент report reason Inappropriate profile + нежелательный профиль report reason @@ -4322,22 +4378,27 @@ More improvements are coming soon! Invalid + Недействительный token status text Invalid (bad token) + Недействительный (плохой токен) token status text Invalid (expired) + Недействительный (истекший) token status text Invalid (unregistered) + Недействительный (незарегистрированный) token status text Invalid (wrong topic) + Недействительный (плохой заголовок) token status text @@ -4615,14 +4676,17 @@ This is your link for group %@! List + Список swipe action List name and emoji should be different for all lists. + Название списка и эмодзи должны быть разными для всех списков. No comment provided by engineer. List name... + Имя списка... No comment provided by engineer. @@ -4717,6 +4781,7 @@ This is your link for group %@! Member reports + Жалобы на участника chat feature @@ -4756,6 +4821,7 @@ This is your link for group %@! Members can report messsages to moderators. + Участники могут пожаловаться на сообщения администраторам. No comment provided by engineer. @@ -4785,6 +4851,7 @@ This is your link for group %@! Mention members 👋 + Упоминайте участников 👋 No comment provided by engineer. @@ -4899,6 +4966,7 @@ This is your link for group %@! Messages in this chat will never be deleted. + Сообщения в этом чате никогда не будут удалены. alert message @@ -5003,6 +5071,7 @@ This is your link for group %@! More + Больше swipe action @@ -5037,6 +5106,7 @@ This is your link for group %@! Mute all + Выключить уведомления для всех notification label action @@ -5091,6 +5161,7 @@ This is your link for group %@! New + Новый token status text @@ -5185,14 +5256,17 @@ This is your link for group %@! No chats + Никаких чатов No comment provided by engineer. No chats found + Чаты не найдены No comment provided by engineer. No chats in list %@ + Никаких чатов в списке %@ No comment provided by engineer. @@ -5247,6 +5321,7 @@ This is your link for group %@! No message + Нет сообщения No comment provided by engineer. @@ -5306,10 +5381,12 @@ This is your link for group %@! No token! + Нет токена! alert title No unread chats + Без непрочитанных чатов No comment provided by engineer. @@ -5324,6 +5401,7 @@ This is your link for group %@! Notes + Примечания No comment provided by engineer. @@ -5348,6 +5426,7 @@ This is your link for group %@! Notifications error + Ошибка уведомлений alert title @@ -5357,6 +5436,7 @@ This is your link for group %@! Notifications status + Статус уведомлений alert title @@ -5444,10 +5524,12 @@ Requires compatible VPN. Only sender and moderators see it + Только отправитель и модераторы видят это No comment provided by engineer. Only you and moderators see it + Только вы и модераторы видят это No comment provided by engineer. @@ -5537,10 +5619,12 @@ Requires compatible VPN. Open link + Открыть ссылку No comment provided by engineer. Open links from chat list + Открыть ссылку из списка чатов No comment provided by engineer. @@ -5550,6 +5634,7 @@ Requires compatible VPN. Open web link? + Открыть веб-ссылку? No comment provided by engineer. @@ -5599,6 +5684,7 @@ Requires compatible VPN. Organize chats into lists + Организуйте чаты в списки No comment provided by engineer. @@ -5794,14 +5880,17 @@ Error: %@ Please try to disable and re-enable notfications. + Попробуйте отключить и снова включить уведомления. token info Please wait for token activation to complete. + Пожалуйста, дождитесь завершения активации токена. token info Please wait for token to be registered. + Пожалуйста, дождитесь регистрации токена. token info @@ -5866,6 +5955,7 @@ Error: %@ Private media file names. + Приватные названия медиафайлов. No comment provided by engineer. @@ -5945,6 +6035,7 @@ Error: %@ Prohibit reporting messages to moderators. + Запретить отправлять жалобы на сообщения модераторам. No comment provided by engineer. @@ -6206,14 +6297,17 @@ Enable in *Network & servers* settings. Register + Зарегистрировать No comment provided by engineer. Register notification token? + Зарегистрировать токен уведомлений? token info Registered + Зарегистрирован token status text @@ -6319,42 +6413,52 @@ Enable in *Network & servers* settings. Report + Пожаловаться chat item action Report content: only group moderators will see it. + Содержание жалобы: его увидят только групповые модераторы. report reason Report member profile: only group moderators will see it. + Жалоба на профиль участника: его увидят только групповые модераторы. report reason Report other: only group moderators will see it. + Другая жалоба: её увидят только модераторы. report reason Report reason? + Причина жалобы? No comment provided by engineer. Report spam: only group moderators will see it. + Пожаловаться на спам: это увидит только модератор группы. report reason Report violation: only group moderators will see it. + Пожаловаться на нарушение: это увидит только модератор группы. report reason Report: (text.isEmpty ? reason.text : text) + Жалоба report in notification Reporting messages to moderators is prohibited. + Жалобы на сообщения запрещены в этой группе. No comment provided by engineer. Reports + Жалобы No comment provided by engineer. @@ -6535,6 +6639,7 @@ Enable in *Network & servers* settings. Save list + Сохранить список No comment provided by engineer. @@ -6779,6 +6884,7 @@ Enable in *Network & servers* settings. Send private reports + Отправляйте приватные жалобы No comment provided by engineer. @@ -6998,6 +7104,7 @@ Enable in *Network & servers* settings. Set chat name… + Установить имя чата… No comment provided by engineer. @@ -7022,6 +7129,7 @@ Enable in *Network & servers* settings. Set message expiration in chats. + Установите время исчезания сообщений в чатах. No comment provided by engineer. @@ -7334,6 +7442,7 @@ Enable in *Network & servers* settings. Spam + Спам blocking reason report reason @@ -7424,6 +7533,7 @@ Enable in *Network & servers* settings. Storage + Хранилище No comment provided by engineer. @@ -7488,6 +7598,7 @@ Enable in *Network & servers* settings. TCP port for messaging + TCP-порт для обмена сообщениями No comment provided by engineer. @@ -7567,6 +7678,7 @@ Enable in *Network & servers* settings. Test notifications + Протестировать уведомления No comment provided by engineer. @@ -7773,6 +7885,7 @@ It can happen because of some bug or when the connection is compromised. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены. alert message @@ -7827,6 +7940,7 @@ It can happen because of some bug or when the connection is compromised. This message was deleted or not received yet. + Это сообщение было удалено или еще не получено. No comment provided by engineer. @@ -7943,6 +8057,7 @@ You will be prompted to complete authentication before this feature is enabled.< Token status: %@. + Статус токена: %@. token status @@ -8144,6 +8259,7 @@ To connect, please ask your contact to create another connection link and check Updated conditions + Обновленные условия No comment provided by engineer. @@ -8208,6 +8324,7 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. + Использовать TCP-порт %@, когда не указано ни одного порта. No comment provided by engineer. @@ -8287,6 +8404,7 @@ To connect, please ask your contact to create another connection link and check Use web port + Использовать веб-порт No comment provided by engineer. @@ -8571,6 +8689,7 @@ To connect, please ask your contact to create another connection link and check Yes + Да No comment provided by engineer. @@ -8844,6 +8963,7 @@ Repeat connection request? You should receive notifications. + Вы должны получать уведомления. token info @@ -9113,6 +9233,7 @@ Repeat connection request? archived report + архивная жалоба No comment provided by engineer. @@ -9633,6 +9754,7 @@ Repeat connection request? moderator + модератор member role @@ -9719,10 +9841,12 @@ Repeat connection request? pending + на рассмотрении No comment provided by engineer. pending approval + ожидает утверждения No comment provided by engineer. @@ -9742,6 +9866,7 @@ Repeat connection request? rejected + отклонён No comment provided by engineer. diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index acdc621e0c..2ba56bb2af 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -92,7 +92,7 @@ "Share" = "Megosztás"; /* No comment provided by engineer. */ -"Slow network?" = "Lassú internetkapcsolat?"; +"Slow network?" = "Lassú a hálózata?"; /* No comment provided by engineer. */ "Unknown database error: %@" = "Ismeretlen adatbázishiba: %@"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index eb3238b43b..da88d2cb77 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -3830,9 +3830,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "Peer-to-Peer"; +/* No comment provided by engineer. */ +"pending" = "ausstehend"; + /* No comment provided by engineer. */ "Pending" = "Ausstehend"; +/* No comment provided by engineer. */ +"pending approval" = "ausstehende Genehmigung"; + /* No comment provided by engineer. */ "Periodic" = "Periodisch"; @@ -4176,6 +4182,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Kontaktanfrage ablehnen"; +/* No comment provided by engineer. */ +"rejected" = "abgelehnt"; + /* call status */ "rejected call" = "Abgelehnter Anruf"; @@ -5412,6 +5421,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Einstellungen aktualisieren?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Aktualisierte Nutzungsbedingungen"; + /* rcv group event chat item */ "updated group profile" = "Aktualisiertes Gruppenprofil"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index b70bf16ea8..f69e9a1356 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -86,7 +86,7 @@ "**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Recuarda**: usar la misma base de datos en dos dispositivos hará que falle el descifrado de mensajes como protección de seguridad."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Atención**: NO podrás recuperar o cambiar la contraseña si la pierdes."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Atención**: Si la pierdes NO podrás recuperar o cambiar la contraseña."; /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Recomendado**: el token del dispositivo y las notificaciones se envían al servidor de notificaciones de SimpleX Chat, pero no el contenido del mensaje, su tamaño o su procedencia."; @@ -797,7 +797,7 @@ "Better groups" = "Grupos mejorados"; /* No comment provided by engineer. */ -"Better groups performance" = "Mejor rendimiento de los grupos"; +"Better groups performance" = "Rendimiento de grupos mejorado"; /* No comment provided by engineer. */ "Better message dates." = "Sistema de fechas mejorado."; @@ -812,7 +812,7 @@ "Better notifications" = "Notificaciones mejoradas"; /* No comment provided by engineer. */ -"Better privacy and security" = "Mejor privacidad y seguridad"; +"Better privacy and security" = "Privacidad y seguridad mejoradas"; /* No comment provided by engineer. */ "Better security ✅" = "Seguridad mejorada ✅"; @@ -2602,7 +2602,7 @@ "Further reduced battery usage" = "Reducción consumo de batería"; /* No comment provided by engineer. */ -"Get notified when mentioned." = "Las menciones ahora notifican."; +"Get notified when mentioned." = "Las menciones ahora se notifican."; /* No comment provided by engineer. */ "GIFs and stickers" = "GIFs y stickers"; @@ -2683,7 +2683,7 @@ "Help" = "Ayuda"; /* No comment provided by engineer. */ -"Help admins moderating their groups." = "Ayuda a los admins moderar sus grupos."; +"Help admins moderating their groups." = "Ayuda a los admins a moderar sus grupos."; /* No comment provided by engineer. */ "Hidden" = "Oculto"; @@ -3220,7 +3220,7 @@ "Members can send voice messages." = "Los miembros del grupo pueden enviar mensajes de voz."; /* No comment provided by engineer. */ -"Mention members 👋" = "Mencionar miembros 👋"; +"Mention members 👋" = "Menciona a miembros 👋"; /* No comment provided by engineer. */ "Menus" = "Menus"; @@ -3771,7 +3771,7 @@ "Or to share privately" = "O para compartir en privado"; /* No comment provided by engineer. */ -"Organize chats into lists" = "Organiza los chats en listas"; +"Organize chats into lists" = "Organiza tus chats en listas"; /* No comment provided by engineer. */ "other" = "otros"; @@ -3830,9 +3830,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "p2p"; +/* No comment provided by engineer. */ +"pending" = "pendiente"; + /* No comment provided by engineer. */ "Pending" = "Pendientes"; +/* No comment provided by engineer. */ +"pending approval" = "pendiente de aprobación"; + /* No comment provided by engineer. */ "Periodic" = "Periódicamente"; @@ -3945,7 +3951,7 @@ "Private filenames" = "Nombres de archivos privados"; /* No comment provided by engineer. */ -"Private media file names." = "Nombres privados de archivos de media."; +"Private media file names." = "Nombres privados en archivos de media."; /* No comment provided by engineer. */ "Private message routing" = "Enrutamiento privado de mensajes"; @@ -4176,6 +4182,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Rechazar solicitud de contacto"; +/* No comment provided by engineer. */ +"rejected" = "rechazado"; + /* call status */ "rejected call" = "llamada rechazada"; @@ -4700,7 +4709,7 @@ "Set it instead of system authentication." = "Úsalo en lugar de la autenticación del sistema."; /* No comment provided by engineer. */ -"Set message expiration in chats." = "Establece vencimiento de los mensajes en chats."; +"Set message expiration in chats." = "Establece el vencimiento para los mensajes en los chats."; /* profile update event chat item */ "set new contact address" = "nueva dirección de contacto"; @@ -5412,6 +5421,9 @@ /* No comment provided by engineer. */ "Update settings?" = "¿Actualizar configuración?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Condiciones actualizadas"; + /* rcv group event chat item */ "updated group profile" = "ha actualizado el perfil del grupo"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 5a01b41d2e..0fb32210f5 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -356,7 +356,7 @@ "Abort changing address" = "Cím módosításának megszakítása"; /* No comment provided by engineer. */ -"Abort changing address?" = "Cím módosításának megszakítása?"; +"Abort changing address?" = "Megszakítja a cím módosítását?"; /* No comment provided by engineer. */ "About operators" = "Az üzemeltetőkről"; @@ -379,10 +379,10 @@ "Accept conditions" = "Feltételek elfogadása"; /* No comment provided by engineer. */ -"Accept connection request?" = "Kapcsolatkérés elfogadása?"; +"Accept connection request?" = "Elfogadja a kapcsolódási kérést?"; /* notification body */ -"Accept contact request from %@?" = "Elfogadja %@ kapcsolatkérését?"; +"Accept contact request from %@?" = "Elfogadja %@ kapcsolódási kérését?"; /* accept contact request via notification swipe action */ @@ -527,7 +527,7 @@ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek."; /* No comment provided by engineer. */ -"All new messages from %@ will be hidden!" = "Az összes %@ által írt új üzenet el lesz rejtve!"; +"All new messages from %@ will be hidden!" = "%@ összes új üzenete el lesz rejtve!"; /* profile dropdown */ "All profiles" = "Összes profil"; @@ -551,7 +551,7 @@ "Allow calls only if your contact allows them." = "A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ -"Allow calls?" = "Hívások engedélyezése?"; +"Allow calls?" = "Engedélyezi a hívásokat?"; /* No comment provided by engineer. */ "Allow disappearing messages only if your contact allows it to you." = "Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi az Ön számára."; @@ -596,7 +596,7 @@ "Allow voice messages only if your contact allows them." = "A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi."; /* No comment provided by engineer. */ -"Allow voice messages?" = "Hangüzenetek engedélyezése?"; +"Allow voice messages?" = "Engedélyezi a hangüzeneteket?"; /* No comment provided by engineer. */ "Allow your contacts adding message reactions." = "A reakciók hozzáadása az üzenetekhez engedélyezve van a partnerei számára."; @@ -689,10 +689,10 @@ "Archive" = "Archívum"; /* No comment provided by engineer. */ -"Archive %lld reports?" = "%lld jelentés archiválása?"; +"Archive %lld reports?" = "Archivál %lld jelentést?"; /* No comment provided by engineer. */ -"Archive all reports?" = "Az összes jelentés archiválása?"; +"Archive all reports?" = "Archiválja az összes jelentést?"; /* No comment provided by engineer. */ "Archive and upload" = "Archiválás és feltöltés"; @@ -704,7 +704,7 @@ "Archive report" = "Jelentés archiválása"; /* No comment provided by engineer. */ -"Archive report?" = "Jelentés archiválása?"; +"Archive report?" = "Archiválja a jelentést?"; /* swipe action */ "Archive reports" = "Jelentések archiválása"; @@ -836,10 +836,10 @@ "Block member" = "Letiltás"; /* No comment provided by engineer. */ -"Block member for all?" = "Az összes tag számára letiltja ezt a tagot?"; +"Block member for all?" = "Az összes tag számára letiltja a tagot?"; /* No comment provided by engineer. */ -"Block member?" = "Tag letiltása?"; +"Block member?" = "Letiltja a tagot?"; /* marked deleted chat item preview text */ "blocked" = "letiltva"; @@ -960,19 +960,19 @@ "Change" = "Módosítás"; /* alert title */ -"Change automatic message deletion?" = "Automatikus üzenettörlés módosítása?"; +"Change automatic message deletion?" = "Módosítja az automatikus üzenettörlést?"; /* authentication reason */ "Change chat profiles" = "Csevegési profilok módosítása"; /* No comment provided by engineer. */ -"Change database passphrase?" = "Az adatbázis jelmondatának módosítása?"; +"Change database passphrase?" = "Módosítja az adatbázis jelmondatát?"; /* authentication reason */ "Change lock mode" = "Zárolási mód módosítása"; /* No comment provided by engineer. */ -"Change member role?" = "Tag szerepkörének módosítása?"; +"Change member role?" = "Módosítja a tag szerepkörét?"; /* authentication reason */ "Change passcode" = "Jelkód módosítása"; @@ -1108,16 +1108,16 @@ "Clear conversation" = "Üzenetek kiürítése"; /* No comment provided by engineer. */ -"Clear conversation?" = "Üzenetek kiürítése?"; +"Clear conversation?" = "Kiüríti az üzeneteket?"; /* No comment provided by engineer. */ -"Clear group?" = "Csoport kiürítése?"; +"Clear group?" = "Kiüríti a csoportot?"; /* No comment provided by engineer. */ "Clear or delete group?" = "Csoport kiürítése vagy törlése?"; /* No comment provided by engineer. */ -"Clear private notes?" = "Privát jegyzetek kiürítése?"; +"Clear private notes?" = "Kiüríti a privát jegyzeteket?"; /* No comment provided by engineer. */ "Clear verification" = "Hitelesítés törlése"; @@ -1147,7 +1147,7 @@ "Completed" = "Elkészült"; /* No comment provided by engineer. */ -"Conditions accepted on: %@." = "Feltételek elfogadva: %@."; +"Conditions accepted on: %@." = "Feltételek elfogadásának ideje: %@."; /* No comment provided by engineer. */ "Conditions are accepted for the operator(s): **%@**." = "A következő üzemeltető(k) számára elfogadott feltételek: **%@**."; @@ -1168,10 +1168,10 @@ "Conditions will be accepted for the operator(s): **%@**." = "A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**."; /* No comment provided by engineer. */ -"Conditions will be accepted on: %@." = "A feltételek ekkor lesznek elfogadva: %@."; +"Conditions will be accepted on: %@." = "A feltételek el lesznek fogadva a következő időpontban: %@."; /* No comment provided by engineer. */ -"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %@."; +"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára a következő időpontban: %@."; /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-kiszolgálók beállítása"; @@ -1228,13 +1228,13 @@ "Connect to your friends faster." = "Kapcsolódjon gyorsabban a partnereihez."; /* No comment provided by engineer. */ -"Connect to yourself?" = "Kapcsolódás saját magához?"; +"Connect to yourself?" = "Kapcsolódik saját magához?"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódás saját magához?\nEz az Ön egyszer használható meghívási hivatkozása!"; +"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódik saját magához?\nEz az Ön egyszer használható meghívási hivatkozása!"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódás saját magához?\nEz az Ön SimpleX-címe!"; +"Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódik saját magához?\nEz az Ön SimpleX-címe!"; /* No comment provided by engineer. */ "Connect via contact address" = "Kapcsolódás a kapcsolattartási címen keresztül"; @@ -1279,7 +1279,7 @@ "connecting (announced)" = "kapcsolódás (bejelentve)"; /* No comment provided by engineer. */ -"connecting (introduced)" = "kapcsolódás (bejelentve)"; +"connecting (introduced)" = "kapcsolódás (bemutatkozva)"; /* No comment provided by engineer. */ "connecting (introduction invitation)" = "kapcsolódás (bemutatkozó meghívó)"; @@ -1417,7 +1417,7 @@ "Corner" = "Sarok"; /* No comment provided by engineer. */ -"Correct name to %@?" = "Név helyesbítése a következőre: %@?"; +"Correct name to %@?" = "Helyesbíti a nevet a következőre: %@?"; /* No comment provided by engineer. */ "Create" = "Létrehozás"; @@ -1602,13 +1602,13 @@ "Delete %lld messages of members?" = "Törli a tagok %lld üzenetét?"; /* No comment provided by engineer. */ -"Delete %lld messages?" = "%lld üzenet törlése?"; +"Delete %lld messages?" = "Töröl %lld üzenetet?"; /* No comment provided by engineer. */ "Delete address" = "Cím törlése"; /* No comment provided by engineer. */ -"Delete address?" = "Cím törlése?"; +"Delete address?" = "Törli a címet?"; /* No comment provided by engineer. */ "Delete after" = "Törlés ennyi idő után"; @@ -1629,10 +1629,10 @@ "Delete chat profile" = "Csevegési profil törlése"; /* No comment provided by engineer. */ -"Delete chat profile?" = "Csevegési profil törlése?"; +"Delete chat profile?" = "Törli a csevegési profilt?"; /* No comment provided by engineer. */ -"Delete chat?" = "Csevegés törlése?"; +"Delete chat?" = "Törli a csevegést?"; /* No comment provided by engineer. */ "Delete connection" = "Kapcsolat törlése"; @@ -1641,7 +1641,7 @@ "Delete contact" = "Partner törlése"; /* No comment provided by engineer. */ -"Delete contact?" = "Partner törlése?"; +"Delete contact?" = "Törli a partnert?"; /* No comment provided by engineer. */ "Delete database" = "Adatbázis törlése"; @@ -1653,7 +1653,7 @@ "Delete file" = "Fájl törlése"; /* No comment provided by engineer. */ -"Delete files and media?" = "A fájlok- és a médiatartalmak törlése?"; +"Delete files and media?" = "Törli a fájl- és a médiatartalmakat?"; /* No comment provided by engineer. */ "Delete files for all chat profiles" = "Fájlok törlése az összes csevegési profilból"; @@ -1668,7 +1668,7 @@ "Delete group" = "Csoport törlése"; /* No comment provided by engineer. */ -"Delete group?" = "Csoport törlése?"; +"Delete group?" = "Törli a csoportot?"; /* No comment provided by engineer. */ "Delete invitation" = "Meghívó törlése"; @@ -1677,16 +1677,16 @@ "Delete link" = "Törlés"; /* No comment provided by engineer. */ -"Delete link?" = "Hivatkozás törlése?"; +"Delete link?" = "Törli a hivatkozást?"; /* alert title */ -"Delete list?" = "Lista törlése?"; +"Delete list?" = "Törli a listát?"; /* No comment provided by engineer. */ -"Delete member message?" = "Csoporttag üzenetének törlése?"; +"Delete member message?" = "Törli a tag üzenetét?"; /* No comment provided by engineer. */ -"Delete message?" = "Üzenet törlése?"; +"Delete message?" = "Törli az üzenetet?"; /* alert button */ "Delete messages" = "Üzenetek törlése"; @@ -1698,13 +1698,13 @@ "Delete old database" = "Régi adatbázis törlése"; /* No comment provided by engineer. */ -"Delete old database?" = "Régi adatbázis törlése?"; +"Delete old database?" = "Törli a régi adatbázist?"; /* No comment provided by engineer. */ "Delete or moderate up to 200 messages." = "Legfeljebb 200 üzenet egyszerre való törlése, vagy moderálása."; /* No comment provided by engineer. */ -"Delete pending connection?" = "Függőben lévő partnerkérelem törlése?"; +"Delete pending connection?" = "Törli a függőben lévő meghívót?"; /* No comment provided by engineer. */ "Delete profile" = "Profil törlése"; @@ -1719,7 +1719,7 @@ "Delete up to 20 messages at once." = "Legfeljebb 20 üzenet egyszerre való törlése."; /* No comment provided by engineer. */ -"Delete user profile?" = "Felhasználói profil törlése?"; +"Delete user profile?" = "Törli a felhasználói profilt?"; /* No comment provided by engineer. */ "Delete without notification" = "Törlés értesítés nélkül"; @@ -1824,7 +1824,7 @@ "Disable (keep overrides)" = "Letiltás (felülírások megtartásával)"; /* alert title */ -"Disable automatic message deletion?" = "Automatikus üzenettörlés letiltása?"; +"Disable automatic message deletion?" = "Letiltja az automatikus üzenettörlést?"; /* alert button */ "Disable delete messages" = "Üzenetek törlésének letiltása"; @@ -1863,7 +1863,7 @@ "Disconnect" = "Kapcsolat bontása"; /* No comment provided by engineer. */ -"Disconnect desktop?" = "Számítógép leválasztása?"; +"Disconnect desktop?" = "Leválasztja a számítógépet?"; /* No comment provided by engineer. */ "Discover and join groups" = "Csoportok felfedezése és csatlakozás"; @@ -1969,7 +1969,7 @@ "Enable (keep overrides)" = "Engedélyezés (felülírások megtartásával)"; /* alert title */ -"Enable automatic message deletion?" = "Automatikus üzenet törlés engedélyezése?"; +"Enable automatic message deletion?" = "Engedélyezi az automatikus üzenettörlést?"; /* No comment provided by engineer. */ "Enable camera access" = "Kamera hozzáférés engedélyezése"; @@ -1984,7 +1984,7 @@ "Enable in direct chats (BETA)!" = "Engedélyezés a közvetlen csevegésekben (BÉTA)!"; /* No comment provided by engineer. */ -"Enable instant notifications?" = "Azonnali értesítések engedélyezése?"; +"Enable instant notifications?" = "Engedélyezi az azonnali értesítéseket?"; /* No comment provided by engineer. */ "Enable lock" = "Zárolás engedélyezése"; @@ -1993,7 +1993,7 @@ "Enable notifications" = "Értesítések engedélyezése"; /* No comment provided by engineer. */ -"Enable periodic notifications?" = "Időszakos értesítések engedélyezése?"; +"Enable periodic notifications?" = "Engedélyezi az időszakos értesítéseket?"; /* No comment provided by engineer. */ "Enable self-destruct" = "Önmegsemmisítés engedélyezése"; @@ -2026,7 +2026,7 @@ "Encrypt" = "Titkosít"; /* No comment provided by engineer. */ -"Encrypt database?" = "Adatbázis titkosítása?"; +"Encrypt database?" = "Titkosítja az adatbázist?"; /* No comment provided by engineer. */ "Encrypt local files" = "Helyi fájlok titkosítása"; @@ -2539,7 +2539,7 @@ "Forward" = "Továbbítás"; /* alert title */ -"Forward %d message(s)?" = "%d üzenet továbbítása?"; +"Forward %d message(s)?" = "Továbbít %d üzenetet?"; /* No comment provided by engineer. */ "Forward and save messages" = "Üzenetek továbbítása és mentése"; @@ -2548,7 +2548,7 @@ "Forward messages" = "Üzenetek továbbítása"; /* alert message */ -"Forward messages without files?" = "Üzenetek továbbítása fájlok nélkül?"; +"Forward messages without files?" = "Továbbítja az üzeneteket fájlok nélkül?"; /* No comment provided by engineer. */ "Forward up to 20 messages at once." = "Legfeljebb 20 üzenet egyszerre való továbbítása."; @@ -2773,7 +2773,7 @@ "Import" = "Importálás"; /* No comment provided by engineer. */ -"Import chat database?" = "Csevegési adatbázis importálása?"; +"Import chat database?" = "Importálja a csevegési adatbázist?"; /* No comment provided by engineer. */ "Import database" = "Adatbázis importálása"; @@ -2962,7 +2962,7 @@ "invited %@" = "meghívta őt: %@"; /* chat list item title */ -"invited to connect" = "meghívta, hogy csatlakozzon"; +"invited to connect" = "meghívta egy partnerét"; /* rcv group event chat item */ "invited via your group link" = "meghíva az Ön csoporthivatkozásán keresztül"; @@ -3046,7 +3046,7 @@ "Keep the app open to use it from desktop" = "A számítógépről való használathoz tartsd nyitva az alkalmazást"; /* alert title */ -"Keep unused invitation?" = "Fel nem használt meghívó megtartása?"; +"Keep unused invitation?" = "Megtartja a fel nem használt meghívót?"; /* No comment provided by engineer. */ "Keep your connections" = "Kapcsolatok megtartása"; @@ -3070,13 +3070,13 @@ "Leave chat" = "Csevegés elhagyása"; /* No comment provided by engineer. */ -"Leave chat?" = "Csevegés elhagyása?"; +"Leave chat?" = "Elhagyja a csevegést?"; /* No comment provided by engineer. */ "Leave group" = "Csoport elhagyása"; /* No comment provided by engineer. */ -"Leave group?" = "Csoport elhagyása?"; +"Leave group?" = "Elhagyja a csoportot?"; /* rcv group event chat item */ "left" = "elhagyta a csoportot"; @@ -3193,7 +3193,7 @@ "Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Member will be removed from group - this cannot be undone!" = "A tag eltávolítása a csoportból – ez a művelet nem vonható vissza!"; +"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!"; /* No comment provided by engineer. */ "Members can add message reactions." = "A tagok reakciókat adhatnak hozzá az üzenetekhez."; @@ -3292,7 +3292,7 @@ "Messages & files" = "Üzenetek és fájlok"; /* No comment provided by engineer. */ -"Messages from %@ will be shown!" = "Az összes %@ által írt üzenet meg fog jelenni!"; +"Messages from %@ will be shown!" = "%@ összes üzenete meg fog jelenni!"; /* alert message */ "Messages in this chat will never be deleted." = "Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve."; @@ -3409,7 +3409,7 @@ "Network & servers" = "Hálózat és kiszolgálók"; /* No comment provided by engineer. */ -"Network connection" = "Internetkapcsolat"; +"Network connection" = "Hálózati kapcsolat"; /* No comment provided by engineer. */ "Network decentralization" = "Hálózati decentralizáció"; @@ -3421,7 +3421,7 @@ "Network management" = "Hálózatkezelés"; /* No comment provided by engineer. */ -"Network operator" = "Hálózati üzemeltető"; +"Network operator" = "Hálózatüzemeltető"; /* No comment provided by engineer. */ "Network settings" = "Hálózati beállítások"; @@ -3741,7 +3741,7 @@ "Open Settings" = "Beállítások megnyitása"; /* No comment provided by engineer. */ -"Open web link?" = "Webhivatkozás megnyitása?"; +"Open web link?" = "Megnyitja a webhivatkozást?"; /* No comment provided by engineer. */ "Opening app…" = "Az alkalmazás megnyitása…"; @@ -3830,9 +3830,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "egyenrangú"; +/* No comment provided by engineer. */ +"pending" = "függőben"; + /* No comment provided by engineer. */ "Pending" = "Függőben"; +/* No comment provided by engineer. */ +"pending approval" = "jóváhagyásra vár"; + /* No comment provided by engineer. */ "Periodic" = "Időszakos"; @@ -3864,7 +3870,7 @@ "Please check that you used the correct link or ask your contact to send you another one." = "Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat."; /* No comment provided by engineer. */ -"Please check your network connection with %@ and try again." = "Ellenőrizze a hálózati kapcsolatát a következővel: %@, és próbálja újra."; +"Please check your network connection with %@ and try again." = "Ellenőrizze a hálózati kapcsolatát a vele: %@, és próbálja újra."; /* No comment provided by engineer. */ "Please check yours and your contact preferences." = "Ellenőrizze a saját- és a partnere beállításait."; @@ -4026,7 +4032,7 @@ "Protocol timeout" = "Protokoll időtúllépése"; /* No comment provided by engineer. */ -"Protocol timeout per KB" = "Protokoll időtúllépése KB-onként"; +"Protocol timeout per KB" = "Protokoll időtúllépése kB-onként"; /* No comment provided by engineer. */ "Proxied" = "Proxyzott"; @@ -4137,16 +4143,16 @@ "Reconnect all servers" = "Újrakapcsolódás az összes kiszolgálóhoz"; /* No comment provided by engineer. */ -"Reconnect all servers?" = "Újrakapcsolódás az összes kiszolgálóhoz?"; +"Reconnect all servers?" = "Újrakapcsolódik az összes kiszolgálóhoz?"; /* No comment provided by engineer. */ "Reconnect server to force message delivery. It uses additional traffic." = "A kiszolgálóhoz való újrakapcsolódás az üzenetkézbesítési jelentések kikényszerítéséhez. Ez további adatforgalmat használ."; /* No comment provided by engineer. */ -"Reconnect server?" = "Újrakapcsolódás a kiszolgálóhoz?"; +"Reconnect server?" = "Újrakapcsolódik a kiszolgálóhoz?"; /* No comment provided by engineer. */ -"Reconnect servers?" = "Újrakapcsolódás a kiszolgálókhoz?"; +"Reconnect servers?" = "Újrakapcsolódik a kiszolgálókhoz?"; /* No comment provided by engineer. */ "Record updated at" = "Bejegyzés frissítve"; @@ -4161,7 +4167,7 @@ "Register" = "Regisztrálás"; /* token info */ -"Register notification token?" = "Értesítési token regisztrálása?"; +"Register notification token?" = "Regisztrálja az értesítési tokent?"; /* token status text */ "Registered" = "Regisztrálva"; @@ -4176,6 +4182,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Kapcsolatkérés elutasítása"; +/* No comment provided by engineer. */ +"rejected" = "elutasítva"; + /* call status */ "rejected call" = "elutasított hívás"; @@ -4189,7 +4198,7 @@ "Remove" = "Eltávolítás"; /* No comment provided by engineer. */ -"Remove archive?" = "Archívum eltávolítása?"; +"Remove archive?" = "Eltávolítja az archívumot?"; /* No comment provided by engineer. */ "Remove image" = "Kép eltávolítása"; @@ -4198,10 +4207,10 @@ "Remove member" = "Eltávolítás"; /* No comment provided by engineer. */ -"Remove member?" = "Biztosan eltávolítja?"; +"Remove member?" = "Eltávolítja a tagot?"; /* No comment provided by engineer. */ -"Remove passphrase from keychain?" = "Jelmondat eltávolítása a kulcstartóból?"; +"Remove passphrase from keychain?" = "Eltávolítja a jelmondatot a kulcstartóból?"; /* No comment provided by engineer. */ "removed" = "eltávolítva"; @@ -4225,10 +4234,10 @@ "Renegotiate encryption" = "Titkosítás újraegyeztetése"; /* No comment provided by engineer. */ -"Renegotiate encryption?" = "Titkosítás újraegyeztetése?"; +"Renegotiate encryption?" = "Újraegyezteti a titkosítást?"; /* No comment provided by engineer. */ -"Repeat connection request?" = "Kapcsolatkérés megismétlése?"; +"Repeat connection request?" = "Megismétli a kapcsolódási kérést?"; /* No comment provided by engineer. */ "Repeat download" = "Letöltés ismét"; @@ -4237,7 +4246,7 @@ "Repeat import" = "Importálás ismét"; /* No comment provided by engineer. */ -"Repeat join request?" = "Csatlakozáskérés megismétlése?"; +"Repeat join request?" = "Megismétli a csatlakozási kérést?"; /* No comment provided by engineer. */ "Repeat upload" = "Feltöltés ismét"; @@ -4291,7 +4300,7 @@ "Reset all statistics" = "Az összes statisztika visszaállítása"; /* No comment provided by engineer. */ -"Reset all statistics?" = "Az összes statisztika visszaállítása?"; +"Reset all statistics?" = "Visszaállítja az összes statisztikát?"; /* No comment provided by engineer. */ "Reset colors" = "Színek visszaállítása"; @@ -4318,7 +4327,7 @@ "Restore database backup" = "Adatbázismentés visszaállítása"; /* No comment provided by engineer. */ -"Restore database backup?" = "Adatbázismentés visszaállítása?"; +"Restore database backup?" = "Visszaállítja az adatbázismentést?"; /* No comment provided by engineer. */ "Restore database error" = "Hiba az adatbázis visszaállításakor"; @@ -4342,7 +4351,7 @@ "Revoke file" = "Fájl visszavonása"; /* No comment provided by engineer. */ -"Revoke file?" = "Fájl visszavonása?"; +"Revoke file?" = "Visszavonja a fájlt?"; /* No comment provided by engineer. */ "Role" = "Szerepkör"; @@ -4388,7 +4397,7 @@ "Save passphrase in Keychain" = "Jelmondat mentése a kulcstartóba"; /* alert title */ -"Save preferences?" = "Beállítások mentése?"; +"Save preferences?" = "Menti a beállításokat?"; /* No comment provided by engineer. */ "Save profile password" = "Profiljelszó mentése"; @@ -4397,13 +4406,13 @@ "Save servers" = "Kiszolgálók mentése"; /* alert title */ -"Save servers?" = "Kiszolgálók mentése?"; +"Save servers?" = "Menti a kiszolgálókat?"; /* No comment provided by engineer. */ -"Save welcome message?" = "Üdvözlőüzenet mentése?"; +"Save welcome message?" = "Menti az üdvözlőüzenetet?"; /* alert title */ -"Save your profile?" = "Profil mentése?"; +"Save your profile?" = "Menti a profilt?"; /* No comment provided by engineer. */ "saved" = "mentett"; @@ -4912,7 +4921,7 @@ "Start chat" = "Csevegés indítása"; /* No comment provided by engineer. */ -"Start chat?" = "Csevegés indítása?"; +"Start chat?" = "Elindítja a csevegést?"; /* No comment provided by engineer. */ "Start migration" = "Átköltöztetés indítása"; @@ -4936,22 +4945,22 @@ "Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "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."; /* No comment provided by engineer. */ -"Stop chat?" = "Csevegési szolgáltatás megállítása?"; +"Stop chat?" = "Megállítja a csevegést?"; /* cancel file action */ "Stop file" = "Fájl megállítása"; /* No comment provided by engineer. */ -"Stop receiving file?" = "Fájlfogadás megállítása?"; +"Stop receiving file?" = "Megállítja a fájlfogadást?"; /* No comment provided by engineer. */ -"Stop sending file?" = "Fájlküldés megállítása?"; +"Stop sending file?" = "Megállítja a fájlküldést?"; /* alert action */ "Stop sharing" = "Megosztás megállítása"; /* alert title */ -"Stop sharing address?" = "Címmegosztás megállítása?"; +"Stop sharing address?" = "Megállítja a címmegosztást?"; /* authentication reason */ "Stop SimpleX" = "SimpleX megállítása"; @@ -5071,7 +5080,7 @@ "Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak – hozzájárulás a Weblate-en!"; /* No comment provided by engineer. */ -"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – beállítások megnyitása az engedélyezéshez."; +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – ezt a beállítások menüben engedélyezheti."; /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ."; @@ -5323,7 +5332,7 @@ "Unblock member for all?" = "Az összes tag számára feloldja a tag letiltását?"; /* No comment provided by engineer. */ -"Unblock member?" = "Tag feloldása?"; +"Unblock member?" = "Feloldja a tag letiltását?"; /* rcv group event chat item */ "unblocked %@" = "feloldotta %@ letiltását"; @@ -5380,7 +5389,7 @@ "Unlink" = "Szétkapcsolás"; /* No comment provided by engineer. */ -"Unlink desktop?" = "Számítógép leválasztása?"; +"Unlink desktop?" = "Leválasztja a számítógépet?"; /* No comment provided by engineer. */ "Unlock" = "Feloldás"; @@ -5407,10 +5416,13 @@ "Update database passphrase" = "Az adatbázis jelmondatának módosítása"; /* No comment provided by engineer. */ -"Update network settings?" = "Hálózati beállítások módosítása?"; +"Update network settings?" = "Módosítja a hálózati beállításokat?"; /* No comment provided by engineer. */ -"Update settings?" = "Beállítások frissítése?"; +"Update settings?" = "Frissíti a beállításokat?"; + +/* No comment provided by engineer. */ +"Updated conditions" = "Frissített feltételek"; /* rcv group event chat item */ "updated group profile" = "frissítette a csoport profilját"; @@ -5758,7 +5770,7 @@ "You are already joining the group via this link." = "A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül."; /* No comment provided by engineer. */ -"You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nCsatlakozáskérés megismétlése?"; +"You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a csatlakozási kérést?"; /* No comment provided by engineer. */ "You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; @@ -5866,7 +5878,7 @@ "You have already requested connection via this address!" = "Már küldött egy kapcsolatkérést ezen a címen keresztül!"; /* No comment provided by engineer. */ -"You have already requested connection!\nRepeat connection request?" = "Már küldött egy kapcsolódási kérelmet!\nKapcsolatkérés megismétlése?"; +"You have already requested connection!\nRepeat connection request?" = "Ön már küldött egy kapcsolódási kérést!\nMegismétli a kapcsolódási kérést?"; /* No comment provided by engineer. */ "You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva."; @@ -6025,7 +6037,7 @@ "Your server address" = "Saját SMP-kiszolgálójának címe"; /* No comment provided by engineer. */ -"Your servers" = "Az Ön kiszolgálói"; +"Your servers" = "Saját kiszolgálók"; /* No comment provided by engineer. */ "Your settings" = "Beállítások"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 586ddd1ffa..6474459604 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -3830,9 +3830,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "in attesa"; + /* No comment provided by engineer. */ "Pending" = "In attesa"; +/* No comment provided by engineer. */ +"pending approval" = "in attesa di approvazione"; + /* No comment provided by engineer. */ "Periodic" = "Periodicamente"; @@ -4176,6 +4182,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Rifiuta la richiesta di contatto"; +/* No comment provided by engineer. */ +"rejected" = "rifiutato"; + /* call status */ "rejected call" = "chiamata rifiutata"; @@ -5412,6 +5421,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Aggiornare le impostazioni?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Condizioni aggiornate"; + /* rcv group event chat item */ "updated group profile" = "ha aggiornato il profilo del gruppo"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 5d78aaa35b..b63d59c644 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* No comment provided by engineer. */ "\n" = "\n"; +/* No comment provided by engineer. */ +" " = " "; + /* No comment provided by engineer. */ " (" = " ("; @@ -55,6 +58,12 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Поставить звездочку в GitHub](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"@'%@'" = "@'%@'"; + +/* No comment provided by engineer. */ +"@%@" = "@%@"; + /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Добавить контакт**: создать и поделиться новой ссылкой-приглашением."; @@ -394,6 +403,9 @@ /* No comment provided by engineer. */ "Acknowledgement errors" = "Ошибки подтверждения"; +/* token status text */ +"Active" = "Активный"; + /* No comment provided by engineer. */ "Active connections" = "Активные соединения"; @@ -490,6 +502,9 @@ /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Все чаты и сообщения будут удалены - это нельзя отменить!"; +/* alert message */ +"All chats will be removed from the list (text), and the list deleted." = "Все чаты будут удалены из списка, а сам список удален."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Все данные удаляются при его вводе."; @@ -517,6 +532,9 @@ /* profile dropdown */ "All profiles" = "Все профили"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Все отчеты будут заархивированы для вас."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Все контакты, которые соединились через этот адрес, сохранятся."; @@ -562,6 +580,9 @@ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Разрешить необратимо удалять отправленные сообщения. (24 часа)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Разрешить отправлять жалобы на сообщения модераторам."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Разрешить посылать файлы и медиа."; @@ -634,6 +655,9 @@ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Приложение шифрует новые локальные файлы (кроме видео)."; +/* No comment provided by engineer. */ +"App group:" = "Группа приложения:"; + /* No comment provided by engineer. */ "App icon" = "Иконка"; @@ -664,6 +688,12 @@ /* No comment provided by engineer. */ "Archive" = "Архивировать"; +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Архивировать %lld отчёт(ов)?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Архивировать все отчеты?"; + /* No comment provided by engineer. */ "Archive and upload" = "Архивировать и загрузить"; @@ -676,12 +706,21 @@ /* No comment provided by engineer. */ "Archive report?" = "Архивировать отчет?"; +/* swipe action */ +"Archive reports" = "Архивировать отчёты"; + /* No comment provided by engineer. */ "Archived contacts" = "Архивированные контакты"; +/* No comment provided by engineer. */ +"archived report" = "архивная жалоба"; + /* No comment provided by engineer. */ "Archiving database" = "Подготовка архива"; +/* No comment provided by engineer. */ +"Ask" = "спросить"; + /* No comment provided by engineer. */ "Attach" = "Прикрепить"; @@ -757,6 +796,9 @@ /* No comment provided by engineer. */ "Better groups" = "Улучшенные группы"; +/* No comment provided by engineer. */ +"Better groups performance" = "Улучшенная производительность групп"; + /* No comment provided by engineer. */ "Better message dates." = "Улучшенные даты сообщений."; @@ -769,6 +811,9 @@ /* No comment provided by engineer. */ "Better notifications" = "Улучшенные уведомления"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Улучшенная приватность и безопасность"; + /* No comment provided by engineer. */ "Better security ✅" = "Улучшенная безопасность ✅"; @@ -841,6 +886,9 @@ /* No comment provided by engineer. */ "Business chats" = "Бизнес разговоры"; +/* No comment provided by engineer. */ +"Businesses" = "Предприятия"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; @@ -911,6 +959,9 @@ /* No comment provided by engineer. */ "Change" = "Поменять"; +/* alert title */ +"Change automatic message deletion?" = "Измененить автоматическое удаление сообщений?"; + /* authentication reason */ "Change chat profiles" = "Поменять профили"; @@ -1059,6 +1110,12 @@ /* No comment provided by engineer. */ "Clear conversation?" = "Очистить разговор?"; +/* No comment provided by engineer. */ +"Clear group?" = "Очистить группу?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Очистить или удалить группу?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Очистить личные заметки?"; @@ -1074,6 +1131,9 @@ /* No comment provided by engineer. */ "colored" = "цвет"; +/* report reason */ +"Community guidelines violation" = "Нарушение принципов Сообщества"; + /* server test step */ "Compare file" = "Сравнение файла"; @@ -1146,6 +1206,9 @@ /* No comment provided by engineer. */ "Confirm upload" = "Подтвердить загрузку"; +/* token status text */ +"Confirmed" = "Подтвержденный"; + /* server test step */ "Connect" = "Соединиться"; @@ -1245,6 +1308,9 @@ /* No comment provided by engineer. */ "Connection and servers status." = "Состояние соединения и серверов."; +/* No comment provided by engineer. */ +"Connection blocked" = "Соединение заблокировано"; + /* No comment provided by engineer. */ "Connection error" = "Ошибка соединения"; @@ -1254,12 +1320,21 @@ /* chat list item title (it should not be shown */ "connection established" = "соединение установлено"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Соединение заблокировано сервером оператора:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Соединение не готово."; + /* No comment provided by engineer. */ "Connection notifications" = "Уведомления по соединениям"; /* No comment provided by engineer. */ "Connection request sent!" = "Запрос на соединение отправлен!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Соединение требует повторного согласования шифрования."; + /* No comment provided by engineer. */ "Connection security" = "Безопасность соединения"; @@ -1320,6 +1395,9 @@ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Контакты могут помечать сообщения для удаления; Вы сможете просмотреть их."; +/* blocking reason */ +"Content violates conditions of use" = "Содержание нарушает условия использования"; + /* No comment provided by engineer. */ "Continue" = "Продолжить"; @@ -1362,6 +1440,9 @@ /* No comment provided by engineer. */ "Create link" = "Создать ссылку"; +/* No comment provided by engineer. */ +"Create list" = "Создать список"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Создайте новый профиль в [приложении для компьютера](https://simplex.chat/downloads/). 💻"; @@ -1541,6 +1622,9 @@ /* No comment provided by engineer. */ "Delete chat" = "Удалить разговор"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Удалить сообщения с вашего устройства."; + /* No comment provided by engineer. */ "Delete chat profile" = "Удалить профиль чата"; @@ -1595,6 +1679,9 @@ /* No comment provided by engineer. */ "Delete link?" = "Удалить ссылку?"; +/* alert title */ +"Delete list?" = "Удалить список?"; + /* No comment provided by engineer. */ "Delete member message?" = "Удалить сообщение участника?"; @@ -1625,6 +1712,9 @@ /* server test step */ "Delete queue" = "Удаление очереди"; +/* No comment provided by engineer. */ +"Delete report" = "удалить отчёт"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Удаляйте до 20 сообщений за раз."; @@ -1733,6 +1823,12 @@ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Выключить (кроме исключений)"; +/* alert title */ +"Disable automatic message deletion?" = "Отключить автоматическое удаление сообщений?"; + +/* alert button */ +"Disable delete messages" = "Отключить удаление сообщений"; + /* No comment provided by engineer. */ "Disable for all" = "Выключить для всех"; @@ -1793,15 +1889,24 @@ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "Не используйте SimpleX для экстренных звонков."; +/* No comment provided by engineer. */ +"Documents:" = "Документы:"; + /* No comment provided by engineer. */ "Don't create address" = "Не создавать адрес"; /* No comment provided by engineer. */ "Don't enable" = "Не включать"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Не пропустите важные сообщения."; + /* No comment provided by engineer. */ "Don't show again" = "Не показывать"; +/* No comment provided by engineer. */ +"Done" = "Готово"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Откатить версию и открыть чат"; @@ -1983,6 +2088,9 @@ /* chat item text */ "encryption re-negotiation required for %@" = "требуется новое соглашение о шифровании для %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Выполняется повторное согласование шифрования."; + /* No comment provided by engineer. */ "ended" = "завершён"; @@ -2058,6 +2166,9 @@ /* No comment provided by engineer. */ "Error changing to incognito!" = "Ошибка при смене на Инкогнито!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Ошибка проверки статуса токена"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Ошибка подключения к пересылающему серверу %@. Попробуйте позже."; @@ -2070,6 +2181,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Ошибка при создании ссылки группы"; +/* alert title */ +"Error creating list" = "Ошибка создания списка"; + /* No comment provided by engineer. */ "Error creating member contact" = "Ошибка создания контакта с членом группы"; @@ -2079,6 +2193,9 @@ /* No comment provided by engineer. */ "Error creating profile!" = "Ошибка создания профиля!"; +/* No comment provided by engineer. */ +"Error creating report" = "Ошибка создания отчета"; + /* No comment provided by engineer. */ "Error decrypting file" = "Ошибка расшифровки файла"; @@ -2145,12 +2262,21 @@ /* No comment provided by engineer. */ "Error reconnecting servers" = "Ошибка переподключения к серверам"; +/* alert title */ +"Error registering for notifications" = "Ошибка регистрации для уведомлений"; + /* No comment provided by engineer. */ "Error removing member" = "Ошибка при удалении члена группы"; +/* alert title */ +"Error reordering lists" = "Ошибка переупорядочивания списков"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Ошибка сброса статистики"; +/* alert title */ +"Error saving chat list" = "Ошибка сохранения списка чатов"; + /* No comment provided by engineer. */ "Error saving group profile" = "Ошибка при сохранении профиля группы"; @@ -2202,6 +2328,9 @@ /* No comment provided by engineer. */ "Error synchronizing connection" = "Ошибка синхронизации соединения"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Ошибка проверки соединения с сервером"; + /* No comment provided by engineer. */ "Error updating group link" = "Ошибка обновления ссылки группы"; @@ -2256,6 +2385,9 @@ /* No comment provided by engineer. */ "expired" = "истекло"; +/* token status text */ +"Expired" = "Истекший"; + /* No comment provided by engineer. */ "Export database" = "Экспорт архива чата"; @@ -2280,18 +2412,30 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Быстрые и не нужно ждать, когда отправитель онлайн!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Ускорено удаление групп."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Быстрое вступление и надежная доставка сообщений."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Ускорена отправка сообщений."; + /* swipe action */ "Favorite" = "Избранный"; +/* No comment provided by engineer. */ +"Favorites" = "Избранное"; + /* file error alert title */ "File error" = "Ошибка файла"; /* alert message */ "File errors:\n%@" = "Ошибки файлов:\n%@"; +/* file error text */ +"File is blocked by server operator:\n(info.reason.text)." = "Файл заблокирован оператором сервера"; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Файл не найден - скорее всего, файл был удален или отменен."; @@ -2367,6 +2511,9 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Починка не поддерживается членом группы"; +/* No comment provided by engineer. */ +"For all moderators" = "Для всех модераторов"; + /* No comment provided by engineer. */ "for better metadata privacy." = "для лучшей конфиденциальности метаданных."; @@ -2379,6 +2526,9 @@ /* No comment provided by engineer. */ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux."; +/* No comment provided by engineer. */ +"For me" = "Для меня"; + /* No comment provided by engineer. */ "For private routing" = "Для доставки сообщений"; @@ -2451,6 +2601,9 @@ /* No comment provided by engineer. */ "Further reduced battery usage" = "Уменьшенное потребление батареи"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Получайте уведомления от упоминаний."; + /* No comment provided by engineer. */ "GIFs and stickers" = "ГИФ файлы и стикеры"; @@ -2523,9 +2676,15 @@ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Группа будет удалена для Вас - это действие нельзя отменить!"; +/* No comment provided by engineer. */ +"Groups" = "Группы"; + /* No comment provided by engineer. */ "Help" = "Помощь"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Помогайте администраторам модерировать их группы."; + /* No comment provided by engineer. */ "Hidden" = "Скрытое"; @@ -2562,6 +2721,9 @@ /* No comment provided by engineer. */ "How it helps privacy" = "Как это улучшает конфиденциальность"; +/* alert button */ +"How it works" = "Как это работает"; + /* No comment provided by engineer. */ "How SimpleX works" = "Как SimpleX работает"; @@ -2649,6 +2811,12 @@ /* No comment provided by engineer. */ "inactive" = "неактивен"; +/* report reason */ +"Inappropriate content" = "Нежелательный контент"; + +/* report reason */ +"Inappropriate profile" = "нежелательный профиль"; + /* No comment provided by engineer. */ "Incognito" = "Инкогнито"; @@ -2715,6 +2883,21 @@ /* No comment provided by engineer. */ "Interface colors" = "Цвета интерфейса"; +/* token status text */ +"Invalid" = "Недействительный"; + +/* token status text */ +"Invalid (bad token)" = "Недействительный (плохой токен)"; + +/* token status text */ +"Invalid (expired)" = "Недействительный (истекший)"; + +/* token status text */ +"Invalid (unregistered)" = "Недействительный (незарегистрированный)"; + +/* token status text */ +"Invalid (wrong topic)" = "Недействительный (плохой заголовок)"; + /* invalid chat data */ "invalid chat" = "ошибка чата"; @@ -2916,6 +3099,15 @@ /* No comment provided by engineer. */ "Linked desktops" = "Связанные компьютеры"; +/* swipe action */ +"List" = "Список"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Название списка и эмодзи должны быть разными для всех списков."; + +/* No comment provided by engineer. */ +"List name..." = "Имя списка..."; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2985,6 +3177,9 @@ /* 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." = "Роль участника будет изменена на \"%@\". Все участники разговора получат уведомление."; @@ -3006,6 +3201,9 @@ /* 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." = "Члены группы могут посылать прямые сообщения."; @@ -3021,6 +3219,9 @@ /* No comment provided by engineer. */ "Members can send voice messages." = "Члены группы могут отправлять голосовые сообщения."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Упоминайте участников 👋"; + /* No comment provided by engineer. */ "Menus" = "Меню"; @@ -3093,6 +3294,9 @@ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Сообщения от %@ будут показаны!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Сообщения в этом чате никогда не будут удалены."; + /* No comment provided by engineer. */ "Messages received" = "Получено сообщений"; @@ -3165,9 +3369,15 @@ /* marked deleted chat item preview text */ "moderated by %@" = "удалено %@"; +/* member role */ +"moderator" = "модератор"; + /* time unit */ "months" = "месяцев"; +/* swipe action */ +"More" = "Больше"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Дополнительные улучшения скоро!"; @@ -3186,6 +3396,9 @@ /* notification label action */ "Mute" = "Без звука"; +/* notification label action */ +"Mute all" = "Выключить уведомления для всех"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Без звука, когда не активный!"; @@ -3219,6 +3432,9 @@ /* delete after time */ "never" = "никогда"; +/* token status text */ +"New" = "Новый"; + /* No comment provided by engineer. */ "New chat" = "Новый чат"; @@ -3279,6 +3495,15 @@ /* Authentication unavailable */ "No app password" = "Нет кода доступа"; +/* No comment provided by engineer. */ +"No chats" = "Никаких чатов"; + +/* No comment provided by engineer. */ +"No chats found" = "Чаты не найдены"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Никаких чатов в списке %@"; + /* No comment provided by engineer. */ "No contacts selected" = "Контакты не выбраны"; @@ -3312,6 +3537,9 @@ /* servers error */ "No media & file servers." = "Нет серверов файлов и медиа."; +/* No comment provided by engineer. */ +"No message" = "Нет сообщения"; + /* servers error */ "No message servers." = "Нет серверов сообщений."; @@ -3348,12 +3576,21 @@ /* copied message info in history */ "no text" = "нет текста"; +/* alert title */ +"No token!" = "Нет токена!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Без непрочитанных чатов"; + /* No comment provided by engineer. */ "No user identifiers." = "Без идентификаторов пользователей."; /* No comment provided by engineer. */ "Not compatible!" = "Несовместимая версия!"; +/* No comment provided by engineer. */ +"Notes" = "Примечания"; + /* No comment provided by engineer. */ "Nothing selected" = "Ничего не выбрано"; @@ -3366,9 +3603,15 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Уведомления выключены"; +/* alert title */ +"Notifications error" = "Ошибка уведомлений"; + /* No comment provided by engineer. */ "Notifications privacy" = "Конфиденциальность уведомлений"; +/* alert title */ +"Notifications status" = "Статус уведомлений"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Теперь админы могут:\n- удалять сообщения членов.\n- приостанавливать членов (роль \"наблюдатель\")"; @@ -3431,6 +3674,12 @@ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Только владельцы группы могут разрешить голосовые сообщения."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Только отправитель и модераторы видят это"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Только вы и модераторы видят это"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Только Вы можете добавлять реакции на сообщения."; @@ -3479,12 +3728,21 @@ /* No comment provided by engineer. */ "Open group" = "Открыть группу"; +/* No comment provided by engineer. */ +"Open link" = "Открыть ссылку"; + +/* No comment provided by engineer. */ +"Open links from chat list" = "Открыть ссылку из списка чатов"; + /* authentication reason */ "Open migration to another device" = "Открытие миграции на другое устройство"; /* No comment provided by engineer. */ "Open Settings" = "Открыть Настройки"; +/* No comment provided by engineer. */ +"Open web link?" = "Открыть веб-ссылку?"; + /* No comment provided by engineer. */ "Opening app…" = "Приложение отрывается…"; @@ -3512,6 +3770,9 @@ /* No comment provided by engineer. */ "Or to share privately" = "Или поделиться конфиденциально"; +/* No comment provided by engineer. */ +"Organize chats into lists" = "Организуйте чаты в списки"; + /* No comment provided by engineer. */ "other" = "другое"; @@ -3569,9 +3830,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "на рассмотрении"; + /* No comment provided by engineer. */ "Pending" = "В ожидании"; +/* No comment provided by engineer. */ +"pending approval" = "ожидает утверждения"; + /* No comment provided by engineer. */ "Periodic" = "Периодически"; @@ -3638,6 +3905,15 @@ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете."; +/* token info */ +"Please try to disable and re-enable notfications." = "Попробуйте отключить и снова включить уведомления."; + +/* token info */ +"Please wait for token activation to complete." = "Пожалуйста, дождитесь завершения активации токена."; + +/* token info */ +"Please wait for token to be registered." = "Пожалуйста, дождитесь регистрации токена."; + /* No comment provided by engineer. */ "Polish interface" = "Польский интерфейс"; @@ -3674,6 +3950,9 @@ /* No comment provided by engineer. */ "Private filenames" = "Защищенные имена файлов"; +/* No comment provided by engineer. */ +"Private media file names." = "Приватные названия медиафайлов."; + /* No comment provided by engineer. */ "Private message routing" = "Конфиденциальная доставка сообщений"; @@ -3719,6 +3998,9 @@ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Запретить реакции на сообщения."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Запретить отправлять жалобы на сообщения модераторам."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Запретить посылать прямые сообщения членам группы."; @@ -3881,6 +4163,15 @@ /* No comment provided by engineer. */ "Reduced battery usage" = "Уменьшенное потребление батареи"; +/* No comment provided by engineer. */ +"Register" = "Зарегистрировать"; + +/* token info */ +"Register notification token?" = "Зарегистрировать токен уведомлений?"; + +/* token status text */ +"Registered" = "Зарегистрирован"; + /* reject incoming call via notification swipe action */ "Reject" = "Отклонить"; @@ -3891,6 +4182,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Отклонить запрос"; +/* No comment provided by engineer. */ +"rejected" = "отклонён"; + /* call status */ "rejected call" = "отклонённый звонок"; @@ -3960,6 +4254,36 @@ /* chat item action */ "Reply" = "Ответить"; +/* chat item action */ +"Report" = "Пожаловаться"; + +/* report reason */ +"Report content: only group moderators will see it." = "Содержание жалобы: его увидят только групповые модераторы."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Жалоба на профиль участника: его увидят только групповые модераторы."; + +/* report reason */ +"Report other: only group moderators will see it." = "Другая жалоба: её увидят только модераторы."; + +/* No comment provided by engineer. */ +"Report reason?" = "Причина жалобы?"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Пожаловаться на спам: это увидит только модератор группы."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Пожаловаться на нарушение: это увидит только модератор группы."; + +/* report in notification */ +"Report: (text.isEmpty ? reason.text : text)" = "Жалоба"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Жалобы на сообщения запрещены в этой группе."; + +/* No comment provided by engineer. */ +"Reports" = "Жалобы"; + /* chat list item title */ "requested to connect" = "запрошено соединение"; @@ -4063,6 +4387,9 @@ /* No comment provided by engineer. */ "Save group profile" = "Сохранить профиль группы"; +/* No comment provided by engineer. */ +"Save list" = "Сохранить список"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Сохранить пароль и открыть чат"; @@ -4231,6 +4558,9 @@ /* No comment provided by engineer. */ "Send notifications" = "Отправлять уведомления"; +/* No comment provided by engineer. */ +"Send private reports" = "Отправляйте приватные жалобы"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Отправьте вопросы и идеи"; @@ -4363,6 +4693,9 @@ /* No comment provided by engineer. */ "Set 1 day" = "Установить 1 день"; +/* No comment provided by engineer. */ +"Set chat name…" = "Установить имя чата…"; + /* No comment provided by engineer. */ "Set contact name…" = "Имя контакта…"; @@ -4375,6 +4708,9 @@ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Установите код вместо системной аутентификации."; +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Установите время исчезания сообщений в чатах."; + /* profile update event chat item */ "set new contact address" = "установлен новый адрес контакта"; @@ -4571,6 +4907,10 @@ /* notification title */ "Somebody" = "Контакт"; +/* blocking reason + report reason */ +"Spam" = "Спам"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Квадрат, круг и все, что между ними."; @@ -4628,6 +4968,9 @@ /* No comment provided by engineer. */ "Stopping chat" = "Остановка чата"; +/* No comment provided by engineer. */ +"Storage" = "Хранилище"; + /* No comment provided by engineer. */ "strike" = "зачеркнуть"; @@ -4697,6 +5040,9 @@ /* No comment provided by engineer. */ "TCP connection timeout" = "Таймаут TCP соединения"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "TCP-порт для обмена сообщениями"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4712,6 +5058,9 @@ /* server test failure */ "Test failed at step %@." = "Ошибка теста на шаге %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Протестировать уведомления"; + /* No comment provided by engineer. */ "Test server" = "Тестировать сервер"; @@ -4832,6 +5181,9 @@ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны."; @@ -4865,6 +5217,9 @@ /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Эта ссылка была использована на другом мобильном, пожалуйста, создайте новую ссылку на компьютере."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Это сообщение было удалено или еще не получено."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**."; @@ -4931,6 +5286,9 @@ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Установите режим Инкогнито при соединении."; +/* token status */ +"Token status: %@." = "Статус токена: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Прозрачность тулбара"; @@ -5063,6 +5421,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Обновить настройки?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Обновленные условия"; + /* rcv group event chat item */ "updated group profile" = "обновил(а) профиль группы"; @@ -5144,12 +5505,18 @@ /* No comment provided by engineer. */ "Use SOCKS proxy" = "Использовать SOCKS прокси"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Использовать TCP-порт %@, когда не указано ни одного порта."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Используйте приложение во время звонка."; /* No comment provided by engineer. */ "Use the app with one hand." = "Используйте приложение одной рукой."; +/* No comment provided by engineer. */ +"Use web port" = "Использовать веб-порт"; + /* No comment provided by engineer. */ "User selection" = "Выбор пользователя"; @@ -5360,6 +5727,9 @@ /* pref value */ "yes" = "да"; +/* No comment provided by engineer. */ +"Yes" = "Да"; + /* No comment provided by engineer. */ "you" = "Вы"; @@ -5555,6 +5925,9 @@ /* chat list item description */ "you shared one-time link incognito" = "Вы создали ссылку инкогнито"; +/* token info */ +"You should receive notifications." = "Вы должны получать уведомления."; + /* snd group event chat item */ "you unblocked %@" = "Вы разблокировали %@"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 3a9d72b40e..a9d5a4b60b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1214,7 +1214,7 @@ يريد الاتصال بك! خوادم ICE الخاصة بك خصوصيتك - حدثت ملف تعريف المجموعة + حُدثت ملف تعريف المجموعة أنت: %1$s تحديث تحديث إعدادات الشبكة؟ @@ -2348,4 +2348,18 @@ إرسال أسرع للرسائل. رُفض رُفض + خطأ في قراءة عبارة مرور قاعدة البيانات + ينتظر + حُدثت الشروط + إزالة الأعضاء؟ + سيتم إخفاء جميع الرسائل الجديدة من هؤلاء الأعضاء! + سيتم إزالة الأعضاء من الدردشة - لا يمكن التراجع عن هذا! + إلغاء حظر الأعضاء للجميع؟ + حظر الأعضاء للجميع؟ + سيتم عرض رسائل من هؤلاء الأعضاء! + لا يمكن قراءة عبارة المرور في Keystore، يُرجى إدخالها يدويًا. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطورين. + سيتم إزالة الأعضاء من المجموعة - لا يمكن التراجع عن هذا! + المشرفين + لا يمكن قراءة عبارة المرور في Keystore. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطورين. + موافقة الانتظار diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 19e93e6973..19cf9c440d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -766,7 +766,7 @@ Obnovit zálohu databáze\? Obnovit Chyba při obnovování databáze - Přístupová fráze nebyla v klíčence nalezena, zadejte jej prosím ručně. K této situaci mohlo dojít, pokud jste obnovili data aplikace pomocí zálohovacího nástroje. Pokud tomu tak není, obraťte se na vývojáře. + Přístupová fráze nebyla v klíčence nalezena, zadejte ji prosím ručně. K této situaci mohlo dojít, pokud jste obnovili data aplikace pomocí zálohovacího nástroje. Pokud tomu tak není, kontaktujte prosím vývojáře. Chat můžete spustit v Nastavení / Databáze nebo restartováním aplikace. pozvánka do skupiny %1$s Jste zváni do skupiny. Připojte se k členům skupiny. @@ -2354,4 +2354,10 @@ Můžete přenést exportovanou databázi. zamítnuto Jemný + Chyba čtení přístupové fráze databáze + Přístupová fráze v úlozišti klíčů nelze načíst, prosím zadejte ji ručně. To se může stát po aktualizaci systému nekompatibilní s aplikací. Pokud to tak není, kontaktujte prosím vývojáře. + Přístupovou frázi v ůložišti klíčů nelze načíst. Mohlo se to stát po aktualizaci systému nekompatibilní s aplikací. Pokud to není váš případ, kontaktujte prosím vývojáře. + Aktualizované podmínky + čekám na schválení + čekám 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 444c7fedaa..a28477d524 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -661,7 +661,7 @@ Bitte geben Sie das vorherige Passwort ein, nachdem Sie die Datenbanksicherung wiederhergestellt haben. Diese Aktion kann nicht rückgängig gemacht werden! Wiederherstellen Fehler bei der Wiederherstellung der Datenbank - Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Das kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Bitte nehmen Sie Kontakt mit den Entwicklern auf, wenn das nicht der Fall ist. + Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Dies kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Wenden Sie sich bitte an die Entwickler, wenn dies nicht der Fall ist. Der Chat wurde beendet Sie können den Chat über die App-Einstellungen/Datenbank oder durch Neustart der App starten. @@ -1483,7 +1483,7 @@ %1$s.]]> Das ist Ihre eigene SimpleX-Adresse! Richtiger Name für %s? - %d Nachrichten löschen? + Sollen %d Nachrichten gelöscht werden? Mit %1$s verbinden? Mitglied entfernen Blockieren @@ -1652,8 +1652,8 @@ Private Notizen Es werden alle Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Private Notizen entfernen? - %s wurde blockiert - %s wurde freigegeben + es wurde %s blockiert + Es wurden %s freigegeben Sie haben %s blockiert Sie haben %s freigegeben Mitglied für Alle blockieren? @@ -2089,10 +2089,10 @@ Einstellungen Die Nachrichten werden für alle Gruppenmitglieder gelöscht. Die Nachrichten werden für alle Mitglieder als moderiert markiert. - %d Nachrichten der Mitglieder löschen? + Sollen %d Nachrichten von Mitgliedern gelöscht werden? Nachricht Nachrichten werden zur Löschung markiert. Der/Die Empfänger hat/haben die Möglichkeit, diese Nachrichten aufzudecken. - %d ausgewählt + Es wurden %d ausgewählt Es wurde Nichts ausgewählt Auswählen Einladen @@ -2154,7 +2154,7 @@ Die Nachrichten wurden gelöscht, nachdem Sie sie ausgewählt hatten. Es gibt nichts zum Weiterleiten! %1$d andere(r) Datei-Fehler. - %1$s Nachricht(en) weiterleiten? + Soll(en) %1$s Nachricht(en) weitergeleitet werden? %1$d Datei(en) wurde(n) gelöscht. %1$d Datei(en) wurde(n) nicht heruntergeladen. Nachrichten ohne Dateien weiterleiten? @@ -2434,4 +2434,18 @@ Verpassen Sie keine wichtigen Nachrichten. Chats in Listen verwalten abgelehnt + Das Passwort kann nicht aus dem Schlüsselbund gelesen werden. Dies kann nach einer Systemaktualisierung passiert sein, die nicht mit der App kompatibel war. Wenden Sie sich bitte an die Entwickler, wenn dies nicht der Fall ist. + ausstehend + Fehler beim Lesen des Datenbank-Passworts + Aktualisierte Nutzungsbedingungen + ausstehende Genehmigung + Das Passwort kann nicht aus dem Schlüsselbund gelesen werden. Bitte geben Sie es manuell ein. Dies kann nach einer Systemaktualisierung passiert sein, die nicht mit der App kompatibel war. Wenden Sie sich bitte an die Entwickler, wenn dies nicht der Fall ist. + Mitglieder entfernen? + Mitglieder werden aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden! + Mitglieder werden aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! + Mitglieder für Alle freigeben? + Nachrichten dieser Mitglieder werden angezeigt! + Moderatoren + Mitglieder für Alle blockieren? + Alle neuen Nachrichten dieser Mitglieder werden nicht angezeigt! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 56ae24a08b..3c9ed3e5bf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -72,7 +72,7 @@ Añadir a otro dispositivo Versión de la aplicación: v%s Solicita recibir la imagen - Recuerda: NO podrás recuperar o cambiar la frase de contraseña si la pierdes.]]> + Recuerda: Si la pierdes NO podrás recuperar o cambiar la frase de contraseña.]]> Tanto tú como tu contacto podéis enviar mensajes de voz. ¡Consume más energía! La aplicación está siempre en segundo plano y las notificaciones se muestran de inmediato.]]> Tanto tú como tu contacto podéis eliminar los mensajes enviados de forma irreversible. (24 horas) @@ -129,7 +129,7 @@ %d semana %d semanas Mensajes temporales - Canfirma tus credenciales + Confirma tus credenciales conectando (presentado) conectando (invitación de presentación ) conectando (aceptado) @@ -680,7 +680,7 @@ Abrir chat Restaurar copia de seguridad de la base de datos Guardar contraseña y abrir el chat - La contraseña no se ha encontrado en Keystore, introdúcela manualmente. Esto puede haber ocurrido si has restaurado los datos de la aplicación con una herramienta de copia de seguridad. Si no es así, por favor ponte en contacto con los desarrolladores. + La frase de contraseña no se ha encontrado en Keystore. Por favor, introdúcela manualmente. Puede deberse a que hayas restaurado los datos de la aplicación mediante alguna herramienta para copias de seguridad. Si no es así, por favor, ponte en contacto con los desarrolladores. Expulsar Expulsar miembro Enviar mensaje directo @@ -2347,18 +2347,32 @@ No se permite informar de mensajes en este grupo. Todos los informes serán archivados para ti. ¿Archivar todos los informes? - Las menciones ahora notifican. - Mencionar miembros 👋 + Las menciones ahora se notifican. + Menciona a miembros 👋 No pierdas los mensajes importantes. - Organiza los chats en listas - Nombres privados de archivos de media. + Organiza tus chats en listas + Nombres privados en archivos de media. Eliminación más rápida de grupos. Envío más rápido de mensajes. - Ayuda a los admins moderar sus grupos. - Mejor rendimiento de los grupos - Mejor privacidad y seguridad + Ayuda a los admins a moderar sus grupos. + Rendimiento de grupos mejorado + Privacidad y seguridad mejoradas Envía informes privados - Establece vencimiento de los mensajes en chats. + Establece el vencimiento para los mensajes en los chats. rechazado rechazado + ¿Expulsar miembros? + ¡Los mensajes de estos miembros serán mostrados! + ¿Desbloquear miembros para todos? + ¡Todos los mensajes nuevos de estos miembros estarán ocultos! + ¿Bloquear miembros para todos? + Los miembros serán expulsados del chat. ¡No podrá deshacerse! + Condiciones actualizadas + moderadores + Los miembros serán expulsados del grupo. ¡No podrá deshacerse! + pendiente de aprobación + pendiente + Error al leer la frase de contraseña de la base de datos + La frase de contraseña no se ha podido leer en Keystore. Puede deberse a alguna actualización del sistema incompatible con la aplicación. Si no es así, por favor, ponte en contacto con los desarrolladores. + La frase de contraseña no se ha podido leer en Keystore. Por favor, introdúcela manualmente. Puede deberse a alguna actualización del sistema incompatible con la aplicación. Si no es así, por favor, ponte en contacto con los desarrolladores. 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 8aa0dd28cc..ddcc232717 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -10,7 +10,7 @@ 5 perc 1 perc A SimpleX-címről - Cím módosításának megszakítása? + Megszakítja a cím módosítását? Megszakítás 30 másodperc Egyszer használható meghívási hivatkozás @@ -26,7 +26,7 @@ Elfogadás gombra fent, majd: Elfogadás inkognitóban - Kapcsolatkérés elfogadása? + Elfogadja a kapcsolódási kérést? Elfogadás Elfogadás Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára. @@ -113,7 +113,7 @@ félkövér Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. - Hangüzenetek engedélyezése? + Engedélyezi a hangüzeneteket? Mindig használjon továbbítókiszolgálót mindig A hívás már befejeződött! @@ -129,7 +129,7 @@ Legjobb akkumulátoridő. Csak akkor kap értesítéseket, amikor az alkalmazás meg van nyitva. (NINCS háttérszolgáltatás.)]]> Megjelenés Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. A beállításokban újraengedélyezheti. - Tag letiltása? + Letiltja a tagot? %1$s hívása befejeződött Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> szerző @@ -160,14 +160,14 @@ Mindkét fél hozzáadhat az üzenetekhez reakciókat. Mindkét fél tud hívásokat kezdeményezni. Sikertelen hitelesítés - Az összes %s által írt új üzenet el lesz rejtve! + %s összes új üzenete el lesz rejtve! Az alkalmazás verziója: v%s A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva hanghívás (nem e2e titkosított) letiltva - Az adatbázis jelmondatának módosítása? + Módosítja az adatbázis jelmondatát? kapcsolódva Jelkód módosítása a következőre módosította %s szerepkörét: „%s” @@ -175,7 +175,7 @@ Módosítás Jelkód megerősítése Jelszó megerősítése - Tag szerepkörének módosítása? + Módosítja a tag szerepkörét? Zárolási mód módosítása Kapcsolódott kapcsolódott @@ -191,7 +191,7 @@ Önmegsemmisítő-mód módosítása a következőre módosította az Ön szerepkörét: „%s” Kapcsolódás - Közvetlen kapcsolódás? + Közvetlenül kapcsolódik? Kapcsolódás közvetlenül kapcsolódott kapcsolat %1$d @@ -199,7 +199,7 @@ Csoport létrehozása véletlenszerű profillal. A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. - Kapcsolódás egyszer használható meghívási hivatkozással? + Kapcsolódik az egyszer használható meghívási hivatkozással? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) Csak név @@ -207,18 +207,18 @@ Cím létrehozása Másolás Folytatás - Kapcsolódás egy hivatkozáson keresztül? + Kapcsolódik egy hivatkozáson keresztül? A partner már létezik Fő verzió: v%s Partner ellenőrizve - Kapcsolódás saját magához? + Kapcsolódik saját magához? Kimásolva a vágólapra Kapcsolatkérés elküldve! Kapcsolódás a számítógéphez Kapcsolat - Név helyesbítése a következőre: %s? + Helyesbíti a nevet a következőre: %s? Időtúllépés kapcsolódáskor - Kapcsolódás a következővel: %1$s? + Kapcsolódik vele: %1$s? Létrehozás Partnerbeállítások Kapcsolat @@ -238,7 +238,7 @@ kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása törölt partner - Csoporttag üzenetének törlése? + Törli a tag üzenetét? A csevegés fut Egyszer használható meghívási hivatkozás létrehozása Törlés @@ -279,7 +279,7 @@ A csevegés megállt (jelenlegi) Témák személyre szabása és megosztása. - Csevegési profil törlése? + Törli a csevegési profilt? Titkos csoport létrehozása Kapcsolódva a számítógéphez ICE-kiszolgálók beállítása @@ -288,17 +288,17 @@ készítő Megerősítés Csak számomra - %d üzenet törlése? + Töröl %d üzenetet? Egyéni témák kapcsolódás (elfogadva) Kiszolgáló címének ellenőrzése és újrapróbálkozás. - Csoport törlése? + Törli a csoportot? Adatbázis fejlesztésének megerősítése Saját profil létrehozása cím módosítása… kapcsolódás… Hívás kapcsolása - A fájlok- és a médiatartalmak törlése? + Törli a fájl- és a médiatartalmakat? befejezett CSEVEGÉSI ADATBÁZIS Önmegsemmisítő-jelkód módosítása @@ -307,9 +307,9 @@ kapcsolódás… Sötét téma törölve - Csevegési profil törlése? + Törli a csevegési profilt? Csevegés a fejlesztőkkel - Hivatkozás törlése? + Törli a hivatkozást? kapcsolódás Egyéni időköz Kapcsolódás inkognitóban @@ -318,7 +318,7 @@ kapcsolódás (bejelentve) kapcsolódás… Csevegési adatbázis törölve - kapcsolódás (bejelentve) + kapcsolódás (bemutatkozva) Csoporthivatkozás létrehozása Csevegési konzol Fájlok törlése az összes csevegési profilból @@ -332,16 +332,16 @@ Fájl létrehozása Tikos csoport létrehozása Elvetés - Partner törlése? + Törli a partnert? Kiürítés Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás Csevegések - Üzenet törlése? - Függőben lévő partnerkérelem törlése? + Törli az üzenetet? + Törli a függőben lévő meghívót? Adatbázis titkosítva! - Üzenetek kiürítése? + Kiüríti a csevegést? Adatbázis visszafejlesztése Üzenetek kiürítése Az adatbázis titkosítási jelmondata frissítve lesz. @@ -372,8 +372,8 @@ Törlés %d óra %d hónap - Cím törlése? - Kézbesítési jelentések letiltása? + Törli a címet? + Letiltja a kézbesítési jelentéseket? Az adatbázis-jelmondat eltér a Keystore-ban lévőtől. Közvetlen üzenetek E-mail @@ -404,7 +404,7 @@ A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. %d perc Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt módosítsa. - Kézbesítés jelentések letiltása a csoportok számára? + Letiltja a kézbesítési jelentéseket a csoportok számára? nap %d nap Duplikált megjelenített név! @@ -423,7 +423,7 @@ Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás Az adatbázis titkosítva lesz, a jelmondat pedig a Keystore-ban lesz tárolva. - Automatikus üzenet törlés engedélyezése? + Engedélyezi az automatikus üzenettörlést? Törlés az adatbázis verziója újabb, mint az alkalmazásé, de a visszafelé történő átköltöztetés viszont nem lehetséges a következőhöz: %s Leírás @@ -447,7 +447,7 @@ Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben. alapértelmezett (%s) duplikált üzenet - Számítógép leválasztása? + Leválasztja a számítógépet? A számítógép-alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. Kézbesítés %d fájl, %s összméretben @@ -512,10 +512,10 @@ Engedélyezés (felülírások megtartásával) Adja meg a helyes jelmondatot. A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza! - Adatbázis titkosítása? + Titkosítja az adatbázist? A zárolási képernyőn megjelenő hívások engedélyezése a Beállításokban. titkosítás elfogadva - Kézbesítési jelentések engedélyezése? + Engedélyezi a kézbesítési jelentéseket? Hiba a csoportprofil mentésekor hiba A fájl törölve lesz a kiszolgálókról. @@ -560,7 +560,7 @@ Hiba a csoporthivatkozás frissítésekor a csoport törölve csoportprofil frissítve - Hiba a függőben lévő partner kapcsolatának törlésekor + Hiba a függőben lévő meghívó törlésekor Hiba a csevegési adatbázis importálásakor Hiba a kézbesítési jelentések engedélyezésekor! Hiba az XFTP-kiszolgálók mentésekor @@ -584,7 +584,7 @@ Hiba a fájl fogadásakor titkosítás rendben Hiba a kapcsolatkérés törlésekor - Kézbesítési jelentések engedélyezése a csoportok számára? + Engedélyezi a kézbesítési jelentéseket a csoportok számára? Partner általi javítás nem támogatott Fájl nem található Kapcsolat bontása @@ -652,7 +652,7 @@ Importálás Azonnali értesítések Inkognitómód - Csevegési adatbázis importálása? + Importálja a csevegési adatbázist? Az azonnali értesítések le vannak tiltva! Azonnali értesítések! Kép @@ -693,7 +693,7 @@ Speciális beállítások Nincs kézbesítési információ moderált - A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! + A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Nincs partner kijelölve Nincsenek fogadott vagy küldött fájlok @@ -778,7 +778,7 @@ A fájl betöltése… Nincs hozzáadandó partner Üzenetvázlat - meghívta, hogy csatlakozzon + meghívta egy partnerét Egyszer használható meghívási hivatkozás Értesítések Egyszerre csak 10 kép küldhető el @@ -809,7 +809,7 @@ (ez az eszköz: v%s)]]> %s ajánlotta Csoport elhagyása - Az összes %s által írt üzenet meg fog jelenni! + %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. Az üzenet visszafejtése sikertelen volt, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült. megfigyelő inkognitó a csoporthivatkozáson keresztül @@ -830,7 +830,7 @@ Hitelesítés Üzenetkézbesítési jelentések! hivatkozás előnézeti képe - Csoport elhagyása? + Elhagyja a csoportot? nem Hamarosan további fejlesztések érkeznek! kikapcsolva @@ -871,7 +871,7 @@ elutasított hívás Időszakos fogadott, tiltott - Kapcsolatkérés megismétlése? + Megismétli a kapcsolódási kérést? Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) Szerepkör SimpleX kapcsolattartási cím @@ -950,7 +950,7 @@ Csillagozás a GitHubon Eltávolítás Keresés - Titkosítás újraegyeztetése? + Újraegyezteti a titkosítást? Az önmegsemmisítő-jelkód engedélyezve! Biztonsági kiértékelés Cím @@ -966,8 +966,8 @@ Partner nevének és az üzenet tartalmának megjelenítése BEÁLLÍTÁSOK Profiljelszó mentése - Fájlküldés megállítása? - Számítógép leválasztása? + Megállítja a fájlküldést? + Leválasztja a számítógépet? A hangüzenetek le vannak tiltva! Közvetlen üzenet küldése a kapcsolódáshoz PING-ek száma @@ -998,7 +998,7 @@ Megosztja a címet a partnereivel? Profiljelszó Téma - Jelmondat eltávolítása a beállításokból? + Eltávolítja a jelmondatot a beállításokból? SimpleX-csoporthivatkozás Várakozás a képre Önmegsemmisítés @@ -1021,7 +1021,7 @@ egyenrangú CSEVEGÉSI SZOLGÁLTATÁS INDÍTÁSA Kapott hivatkozás beillesztése - Kiszolgálók mentése? + Menti a kiszolgálókat? A SimpleX Chat biztonsága a Trail of Bits által lett auditálva. frissítette a csoport profilját SIMPLEX CHAT TÁMOGATÁSA @@ -1040,7 +1040,7 @@ mp A profilfrissítés el lesz küldve az Ön partnerei számára. Egyszerűsített inkognitómód - Üdvözlőüzenet mentése? + Menti az üdvözlőüzenetet? Új csevegési fiók létrehozásához indítsa újra az alkalmazást. Engedély megtagadva! Függőben lévő hívás @@ -1087,7 +1087,7 @@ Saját XFTP-kiszolgálók Videó kikapcsolva Privát fájlnevek - Beállítások mentése? + Menti a beállításokat? Jelkód Ismeretlen hiba Saját SMP-kiszolgálójának címe @@ -1095,7 +1095,7 @@ Eltávolítás Adatbázis-jelmondat beállítása Biztonsági kód megtekintése - Tag feloldása? + Feloldja a tag letiltását? A küldő törölhette a kapcsolatkérést. Érvénytelen adatbázis-jelmondat Saját SMP-kiszolgálók @@ -1113,18 +1113,18 @@ Cím megosztása A kiszolgáló QR-kódjának beolvasása Megállítás - Címmegosztás megállítása? + Megállítja a címmegosztást? Csevegési profilok módosítása - Csatlakozáskérés megismétlése? + Megismétli a csatlakozási kérést? Várakozás a képre Hangüzenetek - Biztosan eltávolítja? + Eltávolítja a tagot? Biztonsági kód hitelesítése eltávolította Önt SimpleX-cím Megjelenítés: válasz fogadása… - Adatbázismentés visszaállítása? + Visszaállítja az adatbázismentést? Üzenetek fogadása… %s és %s kapcsolódott Ön megfigyelő @@ -1150,7 +1150,7 @@ olvasatlan Függőben Üdvözöljük %1$s! - Jelmondat eltávolítása a Keystrore-ból? + Eltávolítja a jelmondatot a Keystrore-ból? Feloldás Az eltűnő üzenetek küldése le van tiltva. Videó @@ -1186,7 +1186,7 @@ Koppintson a Mentés és a partner értesítése Elutasított hívás - SOCKS-proxy beállítások + SOCKS-proxybeállítások QR-kód Titkosítás újraegyeztetése Eltávolítás @@ -1202,7 +1202,7 @@ Megosztás megállítása Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása - Beállítások mentése? + Menti a beállításokat? Nincsenek felhasználó-azonosítók. A közvetlen üzenetek küldése a tagok között le van tiltva. SOCKS-proxy használata? @@ -1210,7 +1210,7 @@ hét Megjelenítés WebRTC ICE-kiszolgálók - Fájl visszavonása? + Visszavonja a fájlt? Közvetlen üzenet küldése Elutasítás Küldés @@ -1224,7 +1224,7 @@ Feloldás Némítás megszüntetése SimpleX Chat megnyitása a hívás fogadásához - Fájlfogadás megállítása? + Megállítja a fájlfogadást? - partnerek értesítése a törlésről (nem kötelező)\n- profilnevek szóközökkel\n- és még sok más! Lengyel kezelőfelület Kiszolgáló használata @@ -1251,7 +1251,7 @@ Kapcsolat hitelesítése Tudjon meg többet A fájl küldője visszavonta az átvitelt. - Csevegési szolgáltatás megállítása? + Megállítja a csevegést? Fogadva Beállítva 1 nap Felfedés @@ -1271,7 +1271,7 @@ Koppintson ide az új csevegés indításához Várakozás a számítógépre… Az üzenetváltás jövője - Hálózati beállítások módosítása? + Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat hitelesítése fájlok küldése egyelőre még nem támogatott @@ -1287,7 +1287,7 @@ a kapcsolattartási címhivatkozáson keresztül a SimpleX a háttérben fut a push értesítések használata helyett.]]> A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a partnerkérelmet és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). - A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. + A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. A partnerei továbbra is kapcsolódva maradnak. A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát Az adatbázis nem működik megfelelően. Koppintson ide a további információkért @@ -1310,7 +1310,7 @@ Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen A biztonsága érdekében kapcsolja be a SimpleX-zár funkciót.\nA funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beállítására az eszközén. A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! - Ellenőrizze a hálózati kapcsolatát a következővel: %1$s, és próbálja újra. + Ellenőrizze a hálózati kapcsolatát vele: %1$s, és próbálja újra. A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be. Az alkalmazás összeomlott Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat. @@ -1364,11 +1364,11 @@ Az Ön 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 - Átvitel-izoláció módjának frissítése? + Frissíti az átvitel-izoláció módját? Átvitel-izoláció 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 internet kapcsolat használata? + Közvetlen internetkapcsolat használata? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. A fő csevegési profilja el lesz küldve a csoporttagok számára Később engedélyezheti őket az „Adatvédelem és biztonság” menüben. @@ -1412,7 +1412,7 @@ Megjelenítendő üzenet beállítása az új tagok számára! Köszönet a felhasználóknak – hozzájárulás a Weblate-en! A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. - Protokoll időtúllépése KB-onként + Protokoll időtúllépése kB-onként Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. A profilja csak a partnereivel van megosztva. @@ -1486,7 +1486,7 @@ A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás. A beillesztett szöveg nem egy SimpleX-hivatkozás. A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél. - Csevegés indítása? + Elindítja a csevegést? Látható előzmények Alkalmazás jelkód Partner hozzáadása @@ -1502,7 +1502,7 @@ Az előzmények ne legyenek elküldve az új tagok számára. Vagy mutassa meg ezt a kódot Kamera hozzáférés engedélyezése - Fel nem használt meghívó megtartása? + Megtartja a fel nem használt meghívót? Ennek az egyszer használható meghívási hivatkozásnak a megosztása Új csevegés A csevegések betöltése… @@ -1555,7 +1555,7 @@ Privát jegyzetek Hiba a privát jegyzetek törlésekor Hiba az üzenet létrehozásakor - Privát jegyzetek kiürítése? + Kiüríti a privát jegyzeteket? Létrehozva Mentett üzenet Létrehozva: %s @@ -1576,7 +1576,7 @@ Letiltva az adminisztrátor által letiltotta őt: %s Letiltás - Az összes tag számára letiltja ezt a tagot? + Az összes tag számára letiltja a tagot? %d üzenetet letiltott az adminisztrátor Feloldás Az összes tag számára feloldja a tag letiltását? @@ -1677,7 +1677,7 @@ Engedélyek megadása Vezetékes Ethernet Mobilhálózat - Internetkapcsolat + Hálózati kapcsolat Nincs hálózati kapcsolat További Wi-Fi @@ -1837,14 +1837,14 @@ Statisztikagyűjtés kezdete: %s.\nAz összes adat privát módon van tárolva az Ön eszközén. Elküldött üzenetek Proxyzott kiszolgálók - Újrakapcsolódás a kiszolgálókhoz? - Újrakapcsolódás a kiszolgálóhoz? + Újrakapcsolódik a kiszolgálókhoz? + Újrakapcsolódik a kiszolgálóhoz? Hiba a kiszolgálóhoz való újrakapcsolódáskor Újrakapcsolódás az összes kiszolgálóhoz Hiba a statisztikák visszaállításakor Visszaállítás Az összes statisztika visszaállítása - Az összes statisztika visszaállítása? + Visszaállítja az összes statisztikát? A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! Részletes statisztikák Letöltve @@ -1958,7 +1958,7 @@ Kapcsolódás a partnerhez, várjon vagy ellenőrizze később! Törölt partner. Nem lehet felhívni a csoporttagot - Hívások engedélyezése? + Engedélyezi a hívásokat? Meghívás üzenet Beszélgetés megtartása @@ -2019,7 +2019,7 @@ Hiba a profilváltáskor A kapcsolata át lett helyezve ide: %s, de egy váratlan hiba történt a profilra való átirányításkor. Az üzenetek törölve lesznek – ez a művelet nem vonható vissza! - Archívum eltávolítása? + Eltávolítja az archívumot? A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. CSEVEGÉSI ADATBÁZIS Profil megosztása @@ -2046,8 +2046,8 @@ Üzenetek továbbítása… %1$d fájlhiba:\n%2$s %1$s üzenet nem lett továbbítva - %1$s üzenet továbbítása? - Üzenetek továbbítása fájlok nélkül? + Továbbít %1$s üzenetet? + Továbbítja az üzeneteket fájlok nélkül? Az üzeneteket törölték miután kijelölte őket. %1$s üzenet mentése Hiba az üzenetek továbbításakor @@ -2107,10 +2107,10 @@ SimpleX-cím vagy egyszer használható meghívási hivatkozás? Egyszer használható meghívási hivatkozás létrehozása Kiszolgáló-üzemeltetők - Hálózati üzemeltetők + Hálózatüzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. - Jelölje ki a használni kívánt hálózati üzemeltetőket. + Jelölje ki a használni kívánt hálózatüzemeltetőket. Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. @@ -2119,15 +2119,15 @@ Folytatás Feltételek felülvizsgálata Elfogadott feltételek - A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %s. - Az Ön kiszolgálói + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára a következő időpontban: %s. + Saját kiszolgálók %s.]]> %s.]]> %s kiszolgáló - Hálózati üzemeltető + Hálózatüzemeltető Weboldal - Feltételek elfogadva: %s. - A feltételek ekkor lesznek elfogadva: %s. + Feltételek elfogadásának ideje: %s. + A feltételek el lesznek elfogadva a következő időpontban: %s. Kiszolgálók használata %s használata A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: @@ -2177,7 +2177,7 @@ Nincs háttérszolgáltatás Értesítések és akkumulátor Az alkalmazás mindig fut a háttérben - Csevegés elhagyása? + Elhagyja a csevegést? Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. Csevegés törlése Meghívás a csevegésbe @@ -2185,7 +2185,7 @@ Munkatársak hozzáadása A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza! A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! - Csevegés törlése? + Törli a csevegést? Csevegés elhagyása Csak a csevegés tulajdonosai módosíthatják a csevegési beállításokat. Könnyen elérhető csevegési eszköztár @@ -2238,7 +2238,7 @@ Lista mentése Az összes csevegés el lesz távolítva a következő listáról, és a lista is törlődik: %s Törlés - Lista törlése? + Törli a listát? Szerkesztés Listanév… Az összes lista nevének és emodzsijának különbözőnek kell lennie. @@ -2251,7 +2251,7 @@ A jelentés archiválva lesz az Ön számára. Tartalom jelentése: csak a csoport moderátorai látják. Archívum - Jelentés archiválása? + Archiválja a jelentést? archivált jelentés moderátor Közösségi irányelvek megsértése @@ -2285,15 +2285,15 @@ Hivatkozás megnyitása Hivatkozás megnyitása a csevegési listából Nem - Webhivatkozás megnyitása? + Megnyitja a webhivatkozást? Csevegés nevének beállítása… - Automatikus üzenettörlés letiltása? + Letiltja az automatikus üzenettörlést? Üzenetek törlésének letiltása Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. 1 év alapértelmezett (%s) Csevegési üzenetek törlése az Ön eszközéről. - Automatikus üzenettörlés módosítása? + Módosítja az automatikus üzenettörlést? Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. A következő TCP-port használata, amikor nincs port megadva: %1$s. TCP-port az üzenetváltáshoz @@ -2303,8 +2303,8 @@ Legfeljebb %1$s tagot említhet meg egy üzenetben! Az üzenetek jelentése a moderátorok felé engedélyezve van. Az üzenetek a moderátorok felé történő jelentésének megtiltása. - Az összes jelentés archiválása? - %d jelentés archiválása? + Archiválja az összes jelentést? + Archivál %d jelentést? Csak magamnak Jelentések archiválása Az összes moderátor számára @@ -2326,4 +2326,18 @@ Csevegések listákba szervezése elutasítva elutasítva + A jelmondat nem olvasható a Keystore-ban, ezért kézzel szükséges megadni. Ez az alkalmazással nem kompatibilis rendszerfrissítés után történhetett meg. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. + függőben + jóváhagyásra vár + Hiba az adatbázis-jelmondat olvasásakor + A jelmondat nem olvasható a Keystore-ban. Ez az alkalmazással nem kompatibilis rendszerfrissítés után történhetett meg. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. + Frissített feltételek + A tagok el lesznek távolítva a csoportból – ez a művelet nem vonható vissza! + Eltávolítja a tagokat? + Az összes tag számára letiltja ezeket a tagokat? + A tagok el lesznek távolítva a csevegésből – ez a művelet nem vonható vissza! + Az összes tag számára feloldja a tagok letiltását? + Ezen tagok összes új üzenete el lesz rejtve! + A tagok összes üzenete meg fog jelenni! + moderátorok 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 a1bf6014dc..b474e6c130 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -266,7 +266,7 @@ Nome completo del gruppo: scansionare il codice QR nella videochiamata, oppure il tuo contatto può condividere un link di invito.]]> Backup dei dati dell\'app - Android Keystore è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. + L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. Database della chat eliminato ICONA APP @@ -274,7 +274,7 @@ Consuma più batteria! L\'app funziona sempre in secondo piano: le notifiche vengono mostrate istantaneamente.]]> chiamata… annulla anteprima link - Impossibile accedere al Keystore per salvare la password del database + Impossibile accedere all\'archivio chiavi per salvare la password del database Impossibile invitare i contatti! Cambia ruolo cambio indirizzo… @@ -285,7 +285,7 @@ Elimina link Crea indirizzo Crea link - La password di crittografia del database verrà aggiornata e conservata nel Keystore. + La password di crittografia del database verrà aggiornata e conservata nell\'archivio chiavi. Il database è crittografato con una password casuale, puoi cambiarla. La password del database è necessaria per aprire la chat. Elimina @@ -399,7 +399,7 @@ AIUTO Chat fermata Errore del database - La password del database è diversa da quella salvata nel Keystore. + La password del database è diversa da quella salvata nell\'archivio chiavi. Database crittografato Inserisci la password giusta. Inserisci la password… @@ -497,7 +497,7 @@ Confronta i codici di sicurezza con i tuoi contatti. Messaggi a tempo Nascondi la schermata dell\'app nelle app recenti. - Android Keystore verrà usato per memorizzare in modo sicuro la password dopo il riavvio dell\'app o la modifica della password; consentirà di ricevere le notifiche. + L\'archivio chiavi di Android verrà usato per memorizzare in modo sicuro la password dopo il riavvio dell\'app o la modifica della password; consentirà di ricevere le notifiche. Nota bene: NON potrai recuperare o cambiare la password se la perdi.]]> Cambiare password del database\? Conferma nuova password… @@ -505,7 +505,7 @@ Database crittografato! La password di crittografia del database verrà aggiornata. Il database verrà crittografato. - Il database verrà crittografato e la password conservata nel Keystore. + Il database verrà crittografato e la password conservata nell\'archivio chiavi. Eliminare i file e i multimediali\? Elimina messaggi Elimina messaggi dopo @@ -729,7 +729,7 @@ Esci Uscire dal gruppo\? Apri chat - Password non trovata nel Keystore, inseriscila a mano. Potrebbe essere successo se hai ripristinato i dati dell\'app usando uno strumento di backup. In caso contrario, contatta gli sviluppatori. + Password non trovata nell\'archivio chiavi, inseriscila a mano. Potrebbe essere successo se hai ripristinato i dati dell\'app usando uno strumento di backup. In caso contrario, contatta gli sviluppatori. Inserisci la password precedente dopo aver ripristinato il backup del database. Questa azione non può essere annullata. Conserva la password in modo sicuro, NON potrai accedere alla chat se la perdi. Ripristina @@ -871,8 +871,8 @@ Inserisci la password attuale corretta. Conserva la password in modo sicuro, NON potrai cambiarla se la perdi. Rimuovi - Rimuovere la password dal Keystore\? - Salva la password nel Keystore + Rimuovere la password dall\'archivio chiavi? + Salva la password nell\'archivio chiavi %s secondo/i Questa azione non può essere annullata: tutti i file e i media ricevuti e inviati verranno eliminati. Rimarranno le immagini a bassa risoluzione. Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti. @@ -2363,4 +2363,18 @@ Nomi privati dei file multimediali. rifiutato rifiutato + Errore di lettura della password del database + in attesa di approvazione + in attesa + Condizioni aggiornate + La password nell\'archivio chiavi non può essere letta. Potrebbe essere successo dopo un aggiornamento di sistema incompatibile con l\'app. In caso contrario, contatta gli sviluppatori. + La password nell\'archivio chiavi non può essere letta, inseriscila a mano. Potrebbe essere successo dopo un aggiornamento di sistema incompatibile con l\'app. In caso contrario, contatta gli sviluppatori. + I membri verranno rimossi dalla chat, non è reversibile! + I membri verranno rimossi dal gruppo, non è reversibile! + Rimuovere i membri? + I messaggi di questi membri verranno mostrati! + Sbloccare i membri per tutti? + Bloccare i membri per tutti? + moderatori + Tutti i nuovi messaggi di questi membri verranno nascosti! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index ac3deb84db..63b6b202d9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -323,7 +323,7 @@ A autenticação do dispositivo não está habilitada. Você pode ativar o Bloqueio SimpleX em Configurações, depois de ativar a autenticação do dispositivo. Desativar Desconectado - Mensagens que temporárias são proibidas neste grupo. + Mensagens temporárias são proibidas. Erro ao salvar arquivo O nome de exibição não pode conter espaços em branco. chamada de áudio criptografada ponta-a-ponta @@ -552,7 +552,7 @@ O remetente pode ter excluído a solicitação de conexão. Notificações periódicas estão desativadas! As notificações instantâneas estão desativadas! - serviço em segundo plano SimpleX - ele usa uma porcentagem da bateria por dia.]]> + SimpleX executa em segundo plano em vez de usar notificações push.]]> Executa quando o aplicativo está aberto enviado o envio falhou @@ -587,7 +587,7 @@ Servidores SMP Endereço do servidor pré-definido Rejeitar - %1$d mensagem(s) ignorada(s) + %1$d mensagem(ens) ignorada(s) Proteger a tela do aplicativo Enviar prévias de links Privacidade e segurança @@ -1062,7 +1062,7 @@ Você pode ativar o bloqueio SimpleX via configurações. Hash de mensagem incorreta O hash da mensagem anterior é diferente. - %1$d descriptografia das mensagens falhou + %1$d mensagens falharam em serem descriptografadas. ID de mensagem incorreta A ID da próxima mensagem está incorreta (menor ou igual à anterior). \nIsso pode acontecer por causa de algum bug ou quando a conexão está comprometida. @@ -1149,7 +1149,7 @@ Digite a mensagem de boas-vindas... (opcional) Salvar configurações de aceitação automática Abrindo banco de dados… - Abrir perfis de bate-papo + Alterar perfis de conversa Compartilhar endereço com os contatos\? Seus contatos continuarão conectados. Todos os dados do aplicativo serão excluídos. @@ -1249,7 +1249,7 @@ código de segurança alterado Renegociar criptografia %s em %s - Arquivos e mídia são proibidos neste grupo. + Arquivos e mídias são proibidos. Proibir o envio de arquivos e mídia. criptografia OK para %s Correção não suportada pelo membro do grupo @@ -2065,15 +2065,15 @@ Sua conexão foi movida para %s, mas um erro inesperado ocorreu ao redirecioná-lo para o seu perfil. %1$d erro(s) de arquivo(s): \n%2$s - %1$d outro erro de arquivo. + %1$d outro(s) erro(s) de arquivo(s). Erro ao encaminhar mensagens. Encaminhar %1$s mensagens? Encaminhar mensagens sem arquivos? As mensagens foram excluidas após vocês selecioná-las. Nada para encaminhar! - %1$d arquivo(s) ainda estão sendo baixados. - %1$d arquivos foram excluidos. - %1$d arquivos não foram baixados. + %1$d o(s) arquivo(s) ainda está(ão) sendo baixado(s). + %1$d arquivo(s) foi(ram) excluído(s). + %1$d arquivo(s) não foi(ram) baixado(s). Baixar Emcaminhar mensagens… Encaminhando %1$s mensagens. @@ -2121,9 +2121,104 @@ Melhor experiência do usuário %1$s.]]> Arquivar - Pergunte + Perguntar Desfoque Denúncias arquivadas de membros Endereço comercial denúncia arquivada + Deletar chat + O texto das condições atuais não pôde ser carregado, você pode revisar as condições por meio deste link: + %s.]]> + Formato de mensagem personalizável. + Envio de mensagens mais rápido. + Checar mensagens a cada 10 minutos + Todas novas mensagens destes membros serão ocultadas + Erro ao atualizar servidor + Permitir denunciar mensagens aos moderadores. + Melhorias de privacidade e segurança + Não perca mensagens importantes. + Chat já existente! + Ativar logs + Bloquear membros para todos? + Deletar ou moderar até 200 mensagens. + %s.]]> + %s.]]> + Mensagens diretas entre membros são proibidas neste chat. + Melhor desempenho de grupos + com criptografia de ponta-a-ponta, e com segurança pós-quântica em mensagens diretas.]]> + Chat será deletado para você - essa ação não pode ser desfeita! + Condições aceitas em: %s. + Mensagens diretas entre membros são proibidas. + %s.]]> + Alterar ordem + Erro ao ler a senha do banco de dados + Erro ao aceitar condições + Violação das diretrizes da comunidade + Erro ao salvar servidores + %d denúncias + 1 denúncia + com apenas um contato - compartilhe pessoalmente ou por qualquer aplicativo de mensagens.]]> + Erro ao salvar configurações + Criar link único + Reparar + %s, aceite as condições de uso.]]> + O conteúdo viola as condições de uso + Conexão bloqueada + A conexão está bloqueada pelo operador do servidor:\n%1$s. + O arquivo está bloqueado pelo operador do servidor:\n%1$s. + Deletar denúncia + Empresas + Alterar lista + Continuar + Erro ao salvar banco de dados + %s.]]> + Erro ao inicializar o WebView. Certifique-se de que você tenha o WebView instalado e que sua arquitetura suportada seja arm64.\nErro: %s + Alterar exclusão automática de mensagens? + Desativar exclusão automática de mensagens? + Deletar lista? + 1 ano + padrão (%s) + %s.]]> + %s.]]> + As condições serão aceitas automaticamente para operadores habilitados em: %s. + Segurança de conexão + As condições serão aceitas em: %s. + %s.]]> + Condições de uso + Erro ao adicionar servidor + Chats de empresas + para melhoria da privacidade de metadados. + Exclusão mais rápida de grupos. + Erro ao criar lista de chat + Erro ao carregar lista de chats + Erro ao atualizar a lista de chats + Contatos + Favoritos + Criar lista + Editar + Canto + Ativar flux + Todas denúncias serão arquivadas para você. + Arquivar todas denúncias? + Arquivar %d denúncias? + Arquivar denúncias + Para todos moderadores + Para mim + Deletar mensagens de chat do seu dispositivo. + Excluir chat? + O chat será deletado para todos os membros - essa ação não pode ser desfeita! + Desativar exclusão de mensagens + Renegociação de criptografia em andamento. + Deletar + Clique no botão de informação perto do campo de endereço para permitir usar o microfone. + As condições serão aceitas para operadores habilitados após 30 dias. + Por exemplo, se o seu contato receber mensagens por meio de um servidor SimpleX Chat, seu aplicativo as entregará por meio de um servidor Flux. + A conexão não está pronta. + Erros nas configurações de servidores. + Para o perfil de chat %s: + A conexão requer renegociação de criptografia. + Reparar conexão? + Erro ao criar denúncia + Chat + Seus servidores diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index c9db7de2e6..544ee8af89 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -971,4 +971,4 @@ Todos os perfis Já conectando! Já entrando no grupo! - \ No newline at end of file + 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 1280c67bb0..881b726a51 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -754,7 +754,7 @@ Вы пытаетесь пригласить инкогнито контакт в группу, где Вы используете свой основной профиль Пригласить членов группы - УЧАСТНИКОВ ГРУППЫ: %1$s + %1$s УЧАСНИКОВ Вы: %1$s Удалить группу Удалить группу? @@ -2296,8 +2296,8 @@ Серверы по умолчанию Роль будет изменена на %s. Все участники разговора получат уведомление. Ваш профиль будет отправлен участникам разговора. - %s.]]> - %s.]]> + %s.]]> + %s.]]> Условия использования Дополнительные серверы файлов и медиа Ошибка сохранения сервера @@ -2381,13 +2381,82 @@ Да Упоминайте участников 👋 Улучшенная приватность и безопасность - Ускоренно удаление групп. - Ускоренна отправка сообщений. + Ускорено удаление групп. + Ускорена отправка сообщений. Помогайте администраторам модерировать их группы. Организуйте чаты в списки Отправляйте приватные жалобы Установите время исчезания сообщений в чатах. Вы можете упомянуть до %1$s пользователей в одном сообщении! Причина жалобы? - Эта жалоба будет арчивирована для вас. + Эта жалоба будет архивирована для вас. + Разрешить отправлять жалобы на сообщения модераторам. + Содержание нарушает условия использования + Ошибка чтения пароля базы данных + архивная жалоба %s + Нарушение принципов Сообщества + Нежелательный контент + Еще одна причина + нежелательный профиль + Архивные отчеты участников + %d отчёты + Ошибка создания отчета + Соединение заблокировано + Соединение заблокировано сервером оператора:\n%1$s. + спросить + Отключить автоматическое удаление сообщений? + Удалить сообщения с вашего устройства. + Отключить удаление сообщений + по умолчанию (%s) + Все отчеты будут заархивированы для вас. + Архивировать все отчеты? + Архивировать %d отчёт? + Для меня + Архивировать жалобу + Архивировать отчёты + удалить отчёт + Файл заблокирован оператором сервера:\n%1$s. + Для всех модераторов + 1 жалоба + Измененить автоматическое удаление сообщений? + 1 год + Не пропустите важные сообщения. + Ошибка сохранения настроек + архивная жалоба + архив + Архивировать жалобу? + Парольная фраза в хранилище ключей не может быть прочитана. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. + Парольная фраза в хранилище не читается, пожалуйста, введите ее вручную. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. + модератор + ожидает утверждения + на рассмотрении + Обновленные условия + Запретить отправлять жалобы на сообщения модераторам. + Участники могут пожаловаться на сообщения администраторам. + Сообщения в этом чате никогда не будут удалены. + Открыть ссылку из списка чатов + Открыть веб-ссылку? + Жалоба на профиль участника: его увидят только групповые модераторы. + Жалобы на участника + Другая жалоба: её увидят только модераторы. + Выключить уведомления для всех + Использовать TCP-порт %1$s, когда не указано ни одного порта. + Использовать веб-порт + Нет + Содержание жалобы: его увидят только групповые модераторы. + отклонён + Жалоба: %s + TCP-порт для обмена сообщениями + Открыть ссылку + отклонён + Только отправитель и модераторы видят это + Только вы и модераторы видят это + Разблокировать членов для всех? + Сообщения от этих членов будут показаны! + Все новые сообщения от этих членов группы будут скрыты! + Заблокировать членов для всех? + Члены группы будут удалены - это действие нельзя отменить! + Члены будут удалены из разговора - это действие нельзя отменить! + модераторы + Удалить членов группы? 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 3c6566552d..5fd75918a9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -1226,7 +1226,7 @@ Змінити ключову фразу бази даних? Не вдається отримати доступ до сховища ключів для збереження пароля бази даних Зберегти ключову фразу і відкрити чат - Ключова фраза не знайдена в сховищі ключів, будь ласка, введіть її вручну. Це може трапитися, якщо ви відновили дані додатка за допомогою інструменту резервного копіювання. Якщо це не так, зверніться до розробників. + Пароль не знайдено в сховищі ключів, введіть його вручну. Це могло статися, якщо ви відновили дані додатка за допомогою інструмента резервного копіювання. Якщо це не так, зверніться до розробників. Видалити профіль чату %d сек Пошук @@ -2360,4 +2360,18 @@ Згадуйте учасників 👋 Встановлюйте термін придатності повідомлень у чатах. Швидше видалення груп. + Помилка зчитування пароля бази даних + Пароль у сховищі ключів не можна зчитати. Це могло статися після оновлення системи, несумісного з додатком. Якщо це не так, зверніться до розробників. + Пароль у сховищі ключів не можна зчитати, введіть його вручну. Це могло статися після оновлення системи, несумісного з додатком. Якщо це не так, зверніться до розробників. + очікує + очікує на схвалення + Видалити учасників? + Усі нові повідомлення від цих учасників будуть приховані! + Заблокувати учасників для всіх? + Учасників буде видалено з групи – це неможливо скасувати! + модератори + Повідомлення від цих учасників буде показано! + Учасників буде видалено з чату – це неможливо скасувати! + Розблокувати учасників для всіх? + Оновлені умови 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 77918b1c8f..ba5a4d3555 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 @@ -1961,7 +1961,7 @@ 转发服务器 %1$s 连接目的地服务器 %2$s 失败。请稍后尝试。 转发服务器地址不兼容网络设置:%1$s。 转发服务器版本不兼容网络设置:%1$s。 - %1$s 的目的地服务器地址不兼容转发服务器 %2$s 的设置 + %1$s 的目的地服务器地址不兼容转发服务器 %2$s 的设置。 连接转发服务器 %1$s 出错。请稍后尝试。 模糊媒体文件 中度 @@ -2347,4 +2347,18 @@ 在聊天中设置消息过期时间。 被拒绝 被拒绝 + 数据库密码短语读取出错 + 无法读取 Keystore 中的密码短语,请手动输入它。这可能在不兼容本应用的系统更新后出现。如果不是这种情况,请联系开发者。 + 无法读取 Keystore 中的密码短语,请手动输入它。这可能在不兼容本应用的系统更新后出现。如果不是这种情况,请联系开发者。 + 待批准 + 待批准 + 条款已更新 + 所有来自这些成员的新消息都将被隐藏! + 为所有其他成员封禁这些成员? + 将从群中移除这些成员 — 此操作无法撤销! + 将显示来自这些成员的消息! + 删除成员吗? + 为所有其他成员解封这些成员吗? + moderators + 将从聊天中移除这些成员 — 此操作无法撤销! From ad4adf66ecdae8bafc8b97f26f97ed8add03cf82 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:19:37 +0700 Subject: [PATCH 121/289] ios: fix small scroll on new message (#5721) * ios: fix small scroll on new message * added inset in calculation of offset --- apps/ios/Shared/Views/Chat/ChatView.swift | 18 ++++++++---------- .../Shared/Views/Chat/EndlessScrollView.swift | 4 ++-- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index ef343d1fb8..2b59a2aaa2 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -570,6 +570,14 @@ struct ChatView: View { .onChange(of: im.reversedChatItems) { items in mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) scrollView.updateItems(mergedItems.boxedValue.items) + if im.itemAdded { + im.itemAdded = false + if scrollView.listState.firstVisibleItemIndex < 2 { + scrollView.scrollToBottomAnimated() + } else { + scrollView.scroll(by: 34) + } + } } .onChange(of: revealedItems) { revealed in mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) @@ -589,16 +597,6 @@ struct ChatView: View { searchTextChanged(s) } } - .onChange(of: im.itemAdded) { added in - if added { - im.itemAdded = false - if scrollView.listState.firstVisibleItemIndex < 2 { - scrollView.scrollToBottomAnimated() - } else { - scrollView.scroll(by: 34) - } - } - } } } diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 39cc84a71d..9756489628 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -517,7 +517,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu let y = if top { min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) } else { - max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) + max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) } setContentOffset(CGPointMake(contentOffset.x, y), animated: false) scrollBarView.flashScrollIndicators() @@ -568,7 +568,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu let y = if top { min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) } else { - max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) + max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) } setContentOffset(CGPointMake(contentOffset.x, y), animated: true) scrollBarView.flashScrollIndicators() From cc2a45bdaf80d6eb197d306db2ef7fb45e72b3ae Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 7 Mar 2025 18:16:56 +0700 Subject: [PATCH 122/289] android, desktop: expand moderated messages (#5722) --- .../chat/simplex/common/views/chat/item/ChatItemView.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 e399d9e88d..4eb7f56837 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 @@ -640,6 +640,13 @@ fun ChatItemView( fun DeletedItem() { MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { + if (revealed.value) { + HideItemAction(revealed, showMenu, reveal) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu, reveal) + } else if (range.value != null) { + ExpandItemAction(revealed, showMenu, reveal) + } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { From 47adbe281359e676635226ce3ae9583c95a59230 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:50:44 +0400 Subject: [PATCH 123/289] ui: fix strings, update translations (#5718) * ios: fix strings * update translations * update report ru translations * remove unnecessary localizations * update ru translations * update android translations * import translations --------- Co-authored-by: Evgeny Poberezkin --- .../Shared/Views/Call/ActiveCallView.swift | 4 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 8 +- .../ChatItem/IntegrityErrorItemView.swift | 6 +- .../Views/Chat/ChatItem/MsgContentView.swift | 8 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/ChatList/ServersSummaryView.swift | 4 +- .../Shared/Views/ChatList/TagListView.swift | 2 +- .../Views/Database/DatabaseErrorView.swift | 2 +- .../Shared/Views/Database/DatabaseView.swift | 2 +- .../Views/Migration/MigrateFromDevice.swift | 2 +- .../Views/Migration/MigrateToDevice.swift | 2 +- .../Shared/Views/Onboarding/SimpleXInfo.swift | 2 + .../Views/UserSettings/MarkdownHelp.swift | 4 +- .../AdvancedNetworkSettings.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 2 +- .../Views/UserSettings/StorageView.swift | 2 +- .../bg.xcloc/Localized Contents/bg.xliff | 61 +------ .../cs.xcloc/Localized Contents/cs.xliff | 61 +------ .../de.xcloc/Localized Contents/de.xliff | 70 +------- .../en.xcloc/Localized Contents/en.xliff | 70 +------- .../es.xcloc/Localized Contents/es.xliff | 70 +------- .../fi.xcloc/Localized Contents/fi.xliff | 61 +------ .../fr.xcloc/Localized Contents/fr.xliff | 61 +------ .../hu.xcloc/Localized Contents/hu.xliff | 70 +------- .../it.xcloc/Localized Contents/it.xliff | 70 +------- .../ja.xcloc/Localized Contents/ja.xliff | 61 +------ .../nl.xcloc/Localized Contents/nl.xliff | 70 +------- .../pl.xcloc/Localized Contents/pl.xliff | 65 +------ .../ru.xcloc/Localized Contents/ru.xliff | 161 ++++++------------ .../th.xcloc/Localized Contents/th.xliff | 61 +------ .../tr.xcloc/Localized Contents/tr.xliff | 61 +------ .../uk.xcloc/Localized Contents/uk.xliff | 61 +------ .../Localized Contents/zh-Hans.xliff | 61 +------ apps/ios/SimpleXChat/ChatTypes.swift | 2 +- apps/ios/SimpleXChat/Notifications.swift | 2 +- apps/ios/bg.lproj/Localizable.strings | 21 --- apps/ios/cs.lproj/Localizable.strings | 21 --- apps/ios/de.lproj/Localizable.strings | 36 +--- apps/ios/es.lproj/Localizable.strings | 36 +--- apps/ios/fi.lproj/Localizable.strings | 21 --- apps/ios/fr.lproj/Localizable.strings | 21 --- apps/ios/hu.lproj/Localizable.strings | 36 +--- apps/ios/it.lproj/Localizable.strings | 36 +--- apps/ios/ja.lproj/Localizable.strings | 21 --- apps/ios/nl.lproj/Localizable.strings | 36 +--- apps/ios/pl.lproj/Localizable.strings | 29 +--- apps/ios/ru.lproj/Localizable.strings | 126 ++++++-------- apps/ios/th.lproj/Localizable.strings | 21 --- apps/ios/tr.lproj/Localizable.strings | 21 --- apps/ios/uk.lproj/Localizable.strings | 21 --- apps/ios/zh-Hans.lproj/Localizable.strings | 21 --- .../commonMain/resources/MR/ar/strings.xml | 1 - .../commonMain/resources/MR/base/strings.xml | 1 - .../commonMain/resources/MR/ca/strings.xml | 1 - .../commonMain/resources/MR/cs/strings.xml | 1 - .../commonMain/resources/MR/de/strings.xml | 1 - .../commonMain/resources/MR/es/strings.xml | 1 - .../commonMain/resources/MR/fr/strings.xml | 1 - .../commonMain/resources/MR/hu/strings.xml | 1 - .../commonMain/resources/MR/in/strings.xml | 1 - .../commonMain/resources/MR/it/strings.xml | 1 - .../commonMain/resources/MR/ko/strings.xml | 1 - .../commonMain/resources/MR/nl/strings.xml | 1 - .../commonMain/resources/MR/pl/strings.xml | 1 - .../resources/MR/pt-rBR/strings.xml | 1 - .../commonMain/resources/MR/ru/strings.xml | 85 +++++---- .../commonMain/resources/MR/tr/strings.xml | 1 - .../commonMain/resources/MR/uk/strings.xml | 1 - .../commonMain/resources/MR/vi/strings.xml | 1 - .../resources/MR/zh-rCN/strings.xml | 1 - 70 files changed, 307 insertions(+), 1575 deletions(-) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 2f76f1f046..3e88019e95 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -361,7 +361,7 @@ struct ActiveCallOverlay: View { HStack { Text(call.encryptionStatus) if let connInfo = call.connectionInfo { - Text("(") + Text(connInfo.text) + Text(")") + Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")") } } } @@ -390,7 +390,7 @@ struct ActiveCallOverlay: View { HStack { Text(call.encryptionStatus) if let connInfo = call.connectionInfo { - Text("(") + Text(connInfo.text) + Text(")") + Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")") } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 4603a026cd..6920ab999b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -161,13 +161,13 @@ struct CIRcvDecryptionError: View { let why = Text(decryptErrorReason) switch msgDecryptError { case .ratchetHeader: - message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why case .tooManySkipped: - message = Text("\(msgCount) messages skipped.") + Text("\n") + why + message = Text("\(msgCount) messages skipped.") + textNewLine + why case .ratchetEarlier: - message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why case .other: - message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why case .ratchetSync: message = Text("Encryption re-negotiation failed.") } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index afeb88b05d..c6d1afa04a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -31,8 +31,8 @@ struct IntegrityErrorItemView: View { case .msgBadHash: AlertManager.shared.showAlert(Alert( title: Text("Bad message hash"), - message: Text("The hash of the previous message is different.") + Text("\n") + - Text(decryptErrorReason) + Text("\n") + + message: Text("The hash of the previous message is different.") + textNewLine + + Text(decryptErrorReason) + textNewLine + Text("Please report it to the developers.") )) case .msgBadId: msgBadIdAlert() @@ -47,7 +47,7 @@ struct IntegrityErrorItemView: View { message: Text(""" The ID of the next message is incorrect (less or equal to the previous). It can happen because of some bug or when the connection is compromised. - """) + Text("\n") + + """) + textNewLine + Text("Please report it to the developers.") )) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 0bb2463d23..d82b8314a7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -21,7 +21,7 @@ private let typingIndicators: [Text] = [ ] private func typing(_ w: Font.Weight = .light) -> Text { - Text(".").fontWeight(w) + Text(verbatim: ".").fontWeight(w) } struct MsgContentView: View { @@ -88,7 +88,7 @@ struct MsgContentView: View { } private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? Text("\n") : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } @@ -117,7 +117,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St if let s = sender { let t = Text(s) - return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res + return (preview ? t : t.fontWeight(.medium)) + Text(verbatim: ": ") + res } else { return res } @@ -170,7 +170,7 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool, } private func mentionText(_ name: String) -> Text { - Text(name.contains(" @") ? "@'\(name)'" : "@\(name)").fontWeight(.semibold) + Text(verbatim: name.contains(" @") ? "@'\(name)'" : "@\(name)").fontWeight(.semibold) } private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 2b59a2aaa2..1e4c16b036 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2374,7 +2374,7 @@ struct ReactionContextMenu: View { @ViewBuilder private func groupMemberReactionList() -> some View { if memberReactions.isEmpty { ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in - Text(verbatim: " ") + textSpace } } else { ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index aa802c1af9..4dbdc81620 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -587,7 +587,7 @@ struct SMPStatsView: View { } header: { Text("Statistics") } footer: { - Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.") + Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.") } } } @@ -703,7 +703,7 @@ struct XFTPStatsView: View { } header: { Text("Statistics") } footer: { - Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.") + Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.") } } } diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift index 8811234f52..74ed9534e0 100644 --- a/apps/ios/Shared/Views/ChatList/TagListView.swift +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -61,7 +61,7 @@ struct TagListView: View { Button { showAlert( NSLocalizedString("Delete list?", comment: "alert title"), - message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), + message: String.localizedStringWithFormat(NSLocalizedString("All chats will be removed from the list %@, and the list deleted.", comment: "alert message"), text), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 6222a28fb4..1ded0acc90 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -141,7 +141,7 @@ struct DatabaseErrorView: View { } private func migrationsText(_ ms: [String]) -> some View { - (Text("Migrations:").font(.subheadline) + Text(verbatim: "\n") + Text(ms.joined(separator: "\n")).font(.caption)) + (Text("Migrations:").font(.subheadline) + textNewLine + Text(ms.joined(separator: "\n")).font(.caption)) .multilineTextAlignment(.center) .padding(.horizontal, 25) } diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 4c05434eb6..59eee1338b 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -279,7 +279,7 @@ struct DatabaseView: View { case let .archiveExportedWithErrors(archivePath, errs): return Alert( title: Text("Chat database exported"), - message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), + message: Text("You may save the exported archive.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), dismissButton: .default(Text("Continue")) { showShareSheet(items: [archivePath]) } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index eb8df5fb04..3e8fd131a5 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -177,7 +177,7 @@ struct MigrateFromDevice: View { case let .archiveExportedWithErrors(archivePath, errs): return Alert( title: Text("Chat database exported"), - message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), + message: Text("You may migrate the exported database.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), dismissButton: .default(Text("Continue")) { Task { await uploadArchive(path: archivePath) } } diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 1a2e1a4fb1..1a740874a6 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -623,7 +623,7 @@ struct MigrateToDevice: View { AlertManager.shared.showAlert( Alert( title: Text("Error migrating settings"), - message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error))) + message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error))) ) } hideView() diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 40dd29db53..dbae3e9fb3 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -140,6 +140,8 @@ struct SimpleXInfo: View { let textSpace = Text(verbatim: " ") +let textNewLine = Text(verbatim: "\n") + struct SimpleXInfo_Previews: PreviewProvider { static var previews: some View { SimpleXInfo(onboarding: true) diff --git a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift index cf9cada592..71c284e9ab 100644 --- a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift +++ b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift @@ -19,7 +19,7 @@ struct MarkdownHelp: View { mdFormat("_italic_", Text("italic").italic()) mdFormat("~strike~", Text("strike").strikethrough()) mdFormat("`a + b`", Text("`a + b`").font(.body.monospaced())) - mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")")) + mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(verbatim: " (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(verbatim: ")")) ( mdFormat("#secret#", Text("secret") .foregroundColor(.clear) @@ -39,7 +39,7 @@ private func mdFormat(_ format: LocalizedStringKey, _ example: Text) -> some Vie } private func color(_ s: String, _ c: Color) -> Text { - Text(s).foregroundColor(c) + Text(", ") + Text(s).foregroundColor(c) + Text(verbatim: ", ") } struct MarkdownHelp_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 7570b1c3e0..55f2e837b8 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -368,8 +368,8 @@ struct AdvancedNetworkSettings: View { let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.") return switch mode { case .user: userMode - case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.") - case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.") + case .session: userMode + textNewLine + Text("New SOCKS credentials will be used every time you start the app.") + case .server: userMode + textNewLine + Text("New SOCKS credentials will be used for each server.") case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.") } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 61dbb5d5d7..80e2a537da 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -528,7 +528,7 @@ struct ProfilePreview: View { func profileName(_ profileOf: NamedChat) -> Text { var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2) if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { - t = t + Text(" (" + profileOf.fullName + ")") + t = t + Text(verbatim: " (" + profileOf.fullName + ")") // .font(.callout) } return t diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift index 2cf63692a7..094c1cb3d6 100644 --- a/apps/ios/Shared/Views/UserSettings/StorageView.swift +++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift @@ -33,7 +33,7 @@ struct StorageView: View { private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { Text(name).font(.headline) ForEach(Array(contents), id: \.key) { (key, value) in - Text(key).bold() + Text(" ") + Text("\(ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))") + Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) } } 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 3e6e1ea387..7c118a877f 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (може да се копира) @@ -323,11 +307,6 @@ %u пропуснати съобщения. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (ново) @@ -338,11 +317,6 @@ (това устройство v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Добави контакт**: за създаване на нов линк. @@ -407,11 +381,6 @@ \*удебелен* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -448,11 +417,6 @@ - история на редактиране. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 сек @@ -519,11 +483,6 @@ 30 секунди No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -531,14 +490,6 @@ <p><a href="%@">Свържете се с мен чрез SimpleX Chat</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things Още няколко неща @@ -780,8 +731,8 @@ Всички чатове и съобщения ще бъдат изтрити - това не може да бъде отменено! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3495,9 +3446,9 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -6106,8 +6057,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 54dc3734c3..e8ea11fb5f 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (lze kopírovat) @@ -321,11 +305,6 @@ %u zpráv přeskočeno. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) No comment provided by engineer. @@ -335,11 +314,6 @@ (toto zařízení v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. No comment provided by engineer. @@ -400,11 +374,6 @@ \*tučně* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -438,11 +407,6 @@ - historie úprav. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec time to disappear @@ -507,11 +471,6 @@ 30 vteřin No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -519,14 +478,6 @@ <p><a href="%@"> Připojte se ke mne přes SimpleX Chat</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things Ještě pár věcí @@ -749,8 +700,8 @@ Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3352,9 +3303,9 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -5877,8 +5828,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 1c016f4614..4c391f97d3 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -5,23 +5,6 @@ - - - - - - No comment provided by engineer. - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (kann kopiert werden) @@ -332,11 +315,6 @@ %u übersprungene Nachrichten. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (Neu) @@ -347,11 +325,6 @@ (Dieses Gerät hat v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Kontakt hinzufügen**: Um einen neuen Einladungslink zu erstellen. @@ -417,11 +390,6 @@ \*fett* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -458,11 +426,6 @@ - Nachrichtenverlauf bearbeiten No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sek @@ -531,11 +494,6 @@ 30 Sekunden No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -543,16 +501,6 @@ <p><a href="%@">Verbinden Sie sich per SimpleX Chat mit mir</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things Ein paar weitere Dinge @@ -798,9 +746,9 @@ Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - Alle Chats werden von der Liste (text) entfernt und danach wird die Liste gelöscht. + + All chats will be removed from the list %@, and the list deleted. + Alle Chats werden von der Liste %@ entfernt und danach wird die Liste gelöscht. alert message @@ -3663,11 +3611,11 @@ Das ist Ihr eigener Einmal-Link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. Datei wurde vom Server-Betreiber blockiert: -(info.reason.text). +%@. file error text @@ -6448,9 +6396,9 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Verstoß melden: Nur Gruppenmoderatoren werden es sehen. report reason - - Report: (text.isEmpty ? reason.text : text) - Meldung: (text.isEmpty ? reason.text : text) + + Report: %@ + Meldung: %@ report in notification 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 39d229c5fa..a205080107 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -5,23 +5,6 @@ - - - - - - No comment provided by engineer. - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (can be copied) @@ -332,11 +315,6 @@ %u messages skipped. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (new) @@ -347,11 +325,6 @@ (this device v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Create 1-time link**: to create and share a new invitation link. @@ -417,11 +390,6 @@ \*bold* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -458,11 +426,6 @@ - editing history. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -531,11 +494,6 @@ 30 seconds No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -543,16 +501,6 @@ <p><a href="%@">Connect to me via SimpleX Chat</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things A few more things @@ -798,9 +746,9 @@ All chats and messages will be deleted - this cannot be undone! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. + All chats will be removed from the list %@, and the list deleted. alert message @@ -3663,11 +3611,11 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -6448,9 +6396,9 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) - Report: (text.isEmpty ? reason.text : text) + + Report: %@ + Report: %@ report in notification 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 07a1447735..fd2ae881ce 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -5,23 +5,6 @@ - - - - - - No comment provided by engineer. - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (puede copiarse) @@ -332,11 +315,6 @@ %u mensaje(s) omitido(s). No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nuevo) @@ -347,11 +325,6 @@ (este dispositivo v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Añadir contacto**: crea un enlace de invitación nuevo. @@ -417,11 +390,6 @@ \*bold* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -458,11 +426,6 @@ - historial de edición. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 seg @@ -531,11 +494,6 @@ 30 segundos No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -543,16 +501,6 @@ <p><a href="%@"> Conecta conmigo a través de SimpleX Chat</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things Algunas cosas más @@ -798,9 +746,9 @@ Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - Todos los chats serán quitados de la lista (text) y esta será eliminada. + + All chats will be removed from the list %@, and the list deleted. + Todos los chats serán quitados de la lista %@ y esta será eliminada. alert message @@ -3663,11 +3611,11 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. Archivo bloqueado por el operador del servidor -(info.reason.text). +%@. file error text @@ -6448,9 +6396,9 @@ Actívalo en ajustes de *Servidores y Redes*. Informar de violación: sólo los moderadores del grupo lo verán. report reason - - Report: (text.isEmpty ? reason.text : text) - Informe: (text.isEmpty ? reason.text : text) + + Report: %@ + Informe: %@ report in notification 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 9d7c9c4bbb..baa5fc7c48 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (voidaan kopioida) @@ -312,11 +296,6 @@ %u viestit ohitettu. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) No comment provided by engineer. @@ -325,11 +304,6 @@ (this device v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. No comment provided by engineer. @@ -390,11 +364,6 @@ \*bold* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -425,11 +394,6 @@ - historian muokkaaminen. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec time to disappear @@ -494,11 +458,6 @@ 30 sekuntia No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -506,14 +465,6 @@ <p> <a href="%@"> Ollaan yhteydessä SimpleX Chatin kautta</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things Muutama asia lisää @@ -736,8 +687,8 @@ Kaikki keskustelut ja viestit poistetaan - tätä ei voi kumota! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3334,9 +3285,9 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -5857,8 +5808,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 31eef4b3b1..d36e7c2bd4 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (peut être copié) @@ -330,11 +314,6 @@ %u messages sautés. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nouveau) @@ -345,11 +324,6 @@ (cet appareil v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Ajouter un contact** : pour créer un nouveau lien d'invitation. @@ -415,11 +389,6 @@ \*gras* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -456,11 +425,6 @@ - l'historique de modification. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -528,11 +492,6 @@ 30 secondes No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -540,14 +499,6 @@ <p><a href="%@">Contactez-moi via SimpleX Chat</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things Encore quelques points @@ -789,8 +740,8 @@ Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière ! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3607,9 +3558,9 @@ Il s'agit de votre propre lien unique ! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -6337,8 +6288,8 @@ Activez-le dans les paramètres *Réseau et serveurs*. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 1ff79e2697..7c25a74d3f 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -5,23 +5,6 @@ - - - - - - No comment provided by engineer. - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (másolható) @@ -332,11 +315,6 @@ %u üzenet kihagyva. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (új) @@ -347,11 +325,6 @@ (ez az eszköz: v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. @@ -417,11 +390,6 @@ \*félkövér* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -458,11 +426,6 @@ - előzmények szerkesztése. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 mp @@ -531,11 +494,6 @@ 30 másodperc No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -543,16 +501,6 @@ <p><a href="%@">Csatlakozzon hozzám a SimpleX Chaten keresztül</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things Néhány további dolog @@ -798,9 +746,9 @@ Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - Az összes csevegés el lesz távolítva a(z) (text) nevű listáról, és a lista is törölve lesz. + + All chats will be removed from the list %@, and the list deleted. + Az összes csevegés el lesz távolítva a(z) %@ nevű listáról, és a lista is törölve lesz. alert message @@ -3663,11 +3611,11 @@ Ez az Ön egyszer használható meghívási hivatkozása! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. A kiszolgáló üzemeltetője letiltotta a fájlt: -(info.reason.text). +%@. file error text @@ -6448,9 +6396,9 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Szabálysértés jelentése: csak a csoport moderátorai látják. report reason - - Report: (text.isEmpty ? reason.text : text) - Jelentés: (text.isEmpty ? reason.text : text) + + Report: %@ + Jelentés: %@ report in notification 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 2855e1bd77..18ee99deda 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -5,23 +5,6 @@ - - - - - - No comment provided by engineer. - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (può essere copiato) @@ -332,11 +315,6 @@ %u messaggi saltati. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nuovo) @@ -347,11 +325,6 @@ (questo dispositivo v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Aggiungi contatto**: per creare un nuovo link di invito. @@ -417,11 +390,6 @@ \*grassetto* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -458,11 +426,6 @@ - cronologia delle modifiche. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -531,11 +494,6 @@ 30 secondi No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -543,16 +501,6 @@ <p><a href="%@">Connettiti a me via SimpleX Chat</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things Qualche altra cosa @@ -798,9 +746,9 @@ Tutte le chat e i messaggi verranno eliminati. Non è reversibile! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - Tutte le chat verranno rimosse dall'elenco (testo) e l'elenco eliminato. + + All chats will be removed from the list %@, and the list deleted. + Tutte le chat verranno rimosse dall'elenco %@ e l'elenco eliminato. alert message @@ -3663,11 +3611,11 @@ Questo è il tuo link una tantum! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. Il file è bloccato dall'operatore del server: -(info.reason.text). +%@. file error text @@ -6448,9 +6396,9 @@ Attivalo nelle impostazioni *Rete e server*. Segnala violazione: solo i moderatori del gruppo lo vedranno. report reason - - Report: (text.isEmpty ? reason.text : text) - Segnalazione: (text.isEmpty ? reason.text : text) + + Report: %@ + Segnalazione: %@ report in notification 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 1a79d251eb..fdca2d5aca 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (コピー可能) @@ -330,11 +314,6 @@ %u 件のメッセージがスキップされました。 No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (新規) @@ -345,11 +324,6 @@ (このデバイス v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **コンタクトの追加**: 新しい招待リンクを作成するか、受け取ったリンクから接続します。 @@ -415,11 +389,6 @@ \*太字* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -456,11 +425,6 @@ - 編集履歴。 No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 秒 @@ -528,11 +492,6 @@ 30秒 No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -540,14 +499,6 @@ <p><a href="%@">SimpleX Chatでつながろう</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things その他 @@ -773,8 +724,8 @@ 全チャットとメッセージが削除されます(※元に戻せません※)! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3407,9 +3358,9 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -5934,8 +5885,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 aeb9c96696..d8d0b3c712 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -5,23 +5,6 @@ - - - - - - No comment provided by engineer. - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (kan gekopieerd worden) @@ -332,11 +315,6 @@ %u berichten zijn overgeslagen. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nieuw) @@ -347,11 +325,6 @@ (dit apparaat v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Contact toevoegen**: om een nieuwe uitnodigingslink aan te maken, of verbinding te maken via een link die u heeft ontvangen. @@ -417,11 +390,6 @@ \*vetgedrukt* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -458,11 +426,6 @@ - bewerkingsgeschiedenis. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sec @@ -531,11 +494,6 @@ 30 seconden No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -543,16 +501,6 @@ <p><a href="%@">Maak verbinding met mij via SimpleX Chat</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things Nog een paar dingen @@ -798,9 +746,9 @@ Alle chats en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - Alle chats worden uit de lijst (tekst) verwijderd en de lijst wordt verwijderd. + + All chats will be removed from the list %@, and the list deleted. + Alle chats worden uit de lijst %@ verwijderd en de lijst wordt verwijderd. alert message @@ -3663,11 +3611,11 @@ Dit is uw eigen eenmalige link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. Bestand is geblokkeerd door serveroperator: -(info.reason.text). +%@. file error text @@ -6448,9 +6396,9 @@ Schakel dit in in *Netwerk en servers*-instellingen. Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. report reason - - Report: (text.isEmpty ? reason.text : text) - Rapport: (text.isEmpty ? reason.text : text) + + Report: %@ + Rapport: %@ report in notification 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 aa257a2322..6a2d6e0d83 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (można skopiować) @@ -331,11 +315,6 @@ %u pominiętych wiadomości. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (nowy) @@ -346,11 +325,6 @@ (to urządzenie v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Dodaj kontakt**: aby utworzyć nowy link z zaproszeniem lub połączyć się za pomocą otrzymanego linku. @@ -416,11 +390,6 @@ \*pogrubiony* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -457,11 +426,6 @@ - historia edycji. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 sek @@ -530,11 +494,6 @@ 30 sekund No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -542,16 +501,6 @@ <p><a href="%@">Połącz się ze mną poprzez SimpleX Chat.</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things Jeszcze kilka rzeczy @@ -797,9 +746,9 @@ Wszystkie czaty i wiadomości zostaną usunięte - nie można tego cofnąć! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - Wszystkie rozmowy zostaną usunięte z listy (text), a lista usunięta. + + All chats will be removed from the list %@, and the list deleted. + Wszystkie rozmowy zostaną usunięte z listy %@, a lista usunięta. alert message @@ -3607,9 +3556,9 @@ To jest twój jednorazowy link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -6302,8 +6251,8 @@ Włącz w ustawianiach *Sieć i serwery* . Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 edb7ca86e2..0e14a85c19 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -5,23 +5,6 @@ - - - - - - No comment provided by engineer. - - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (можно скопировать) @@ -332,11 +315,6 @@ %u сообщений пропущено. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (новое) @@ -347,11 +325,6 @@ (это устройство v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Добавить контакт**: создать и поделиться новой ссылкой-приглашением. @@ -417,11 +390,6 @@ \*жирный* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -458,11 +426,6 @@ - история редактирования. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 сек @@ -531,11 +494,6 @@ 30 секунд No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -543,16 +501,6 @@ <p><a href="%@">Соединитесь со мной в SimpleX Chat.</a></p> email text - - @%@ - @%@ - No comment provided by engineer. - - - @'%@' - @'%@' - No comment provided by engineer. - A few more things Еще несколько изменений @@ -798,9 +746,9 @@ Все чаты и сообщения будут удалены - это нельзя отменить! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. - Все чаты будут удалены из списка, а сам список удален. + + All chats will be removed from the list %@, and the list deleted. + Все чаты будут удалены из списка %@, и список удален. alert message @@ -845,7 +793,7 @@ All reports will be archived for you. - Все отчеты будут заархивированы для вас. + Все сообщения о нарушениях будут заархивированы для вас. No comment provided by engineer. @@ -925,7 +873,7 @@ Allow to report messsages to moderators. - Разрешить отправлять жалобы на сообщения модераторам. + Разрешить отправлять сообщения о нарушениях модераторам. No comment provided by engineer. @@ -1095,12 +1043,12 @@ Archive %lld reports? - Архивировать %lld отчёт(ов)? + Архивировать %lld сообщений о нарушениях? No comment provided by engineer. Archive all reports? - Архивировать все отчеты? + Архивировать все сообщения о нарушениях? No comment provided by engineer. @@ -1115,17 +1063,17 @@ Archive report - Архивировать отчет + Архивировать сообщение о нарушении No comment provided by engineer. Archive report? - Архивировать отчет? + Архивировать сообщение о нарушении? No comment provided by engineer. Archive reports - Архивировать отчёты + Архивировать сообщения о нарушениях swipe action @@ -1140,7 +1088,7 @@ Ask - спросить + Спросить No comment provided by engineer. @@ -1270,7 +1218,7 @@ Better privacy and security - Улучшенная приватность и безопасность + Улучшенная конфиденциальность и безопасность No comment provided by engineer. @@ -1375,7 +1323,7 @@ Businesses - Предприятия + Бизнесы No comment provided by engineer. @@ -1727,7 +1675,7 @@ Community guidelines violation - Нарушение принципов Сообщества + Нарушение правил группы report reason @@ -2587,7 +2535,7 @@ This is your own one-time link! Delete report - удалить отчёт + Удалить сообщение о нарушении No comment provided by engineer. @@ -3273,7 +3221,7 @@ This is your own one-time link! Error creating report - Ошибка создания отчета + Ошибка создания сообщения о нарушении No comment provided by engineer. @@ -3398,7 +3346,7 @@ This is your own one-time link! Error reordering lists - Ошибка переупорядочивания списков + Ошибка сортировки списков alert title @@ -3663,10 +3611,11 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). - Файл заблокирован оператором сервера +%@. + Файл заблокирован оператором сервера: +%@. file error text @@ -3945,7 +3894,7 @@ Error: %2$@ Get notified when mentioned. - Получайте уведомления от упоминаний. + Уведомления, когда Вас упомянули. No comment provided by engineer. @@ -4276,12 +4225,12 @@ More improvements are coming soon! Inappropriate content - Нежелательный контент + Неприемлемый контент report reason Inappropriate profile - нежелательный профиль + Неприемлемый профиль report reason @@ -4781,7 +4730,7 @@ This is your link for group %@! Member reports - Жалобы на участника + Сообщения о нарушениях chat feature @@ -4821,7 +4770,7 @@ This is your link for group %@! Members can report messsages to moderators. - Участники могут пожаловаться на сообщения администраторам. + Члены группы могут пожаловаться модераторам. No comment provided by engineer. @@ -4851,7 +4800,7 @@ This is your link for group %@! Mention members 👋 - Упоминайте участников 👋 + Упоминайте членов группы 👋 No comment provided by engineer. @@ -5106,7 +5055,7 @@ This is your link for group %@! Mute all - Выключить уведомления для всех + Все без звука notification label action @@ -5256,7 +5205,7 @@ This is your link for group %@! No chats - Никаких чатов + Нет чатов No comment provided by engineer. @@ -5266,7 +5215,7 @@ This is your link for group %@! No chats in list %@ - Никаких чатов в списке %@ + Нет чатов в списке %@ No comment provided by engineer. @@ -5386,7 +5335,7 @@ This is your link for group %@! No unread chats - Без непрочитанных чатов + Нет непрочитанных чатов No comment provided by engineer. @@ -5401,7 +5350,7 @@ This is your link for group %@! Notes - Примечания + Заметки No comment provided by engineer. @@ -5771,7 +5720,7 @@ Requires compatible VPN. Pending - В ожидании + Ожидает No comment provided by engineer. @@ -5880,7 +5829,7 @@ Error: %@ Please try to disable and re-enable notfications. - Попробуйте отключить и снова включить уведомления. + Попробуйте выключить и снова включить уведомления. token info @@ -5955,7 +5904,7 @@ Error: %@ Private media file names. - Приватные названия медиафайлов. + Конфиденциальные названия медиафайлов. No comment provided by engineer. @@ -6035,7 +5984,7 @@ Error: %@ Prohibit reporting messages to moderators. - Запретить отправлять жалобы на сообщения модераторам. + Запретить жаловаться модераторам группы. No comment provided by engineer. @@ -6418,47 +6367,47 @@ Enable in *Network & servers* settings. Report content: only group moderators will see it. - Содержание жалобы: его увидят только групповые модераторы. + Пожаловаться на сообщение: увидят только модераторы группы. report reason Report member profile: only group moderators will see it. - Жалоба на профиль участника: его увидят только групповые модераторы. + Пожаловаться на профиль: увидят только модераторы группы. report reason Report other: only group moderators will see it. - Другая жалоба: её увидят только модераторы. + Пожаловаться: увидят только модераторы группы. report reason Report reason? - Причина жалобы? + Причина сообщения? No comment provided by engineer. Report spam: only group moderators will see it. - Пожаловаться на спам: это увидит только модератор группы. + Пожаловаться на спам: увидят только модераторы группы. report reason Report violation: only group moderators will see it. - Пожаловаться на нарушение: это увидит только модератор группы. + Пожаловаться на нарушение: увидят только модераторы группы. report reason - - Report: (text.isEmpty ? reason.text : text) - Жалоба + + Report: %@ + Сообщение о нарушении: %@ report in notification Reporting messages to moderators is prohibited. - Жалобы на сообщения запрещены в этой группе. + Сообщения о нарушениях запрещены в этой группе. No comment provided by engineer. Reports - Жалобы + Сообщения о нарушениях No comment provided by engineer. @@ -6884,7 +6833,7 @@ Enable in *Network & servers* settings. Send private reports - Отправляйте приватные жалобы + Вы можете сообщить о нарушениях No comment provided by engineer. @@ -7104,7 +7053,7 @@ Enable in *Network & servers* settings. Set chat name… - Установить имя чата… + Имя чата… No comment provided by engineer. @@ -7129,7 +7078,7 @@ Enable in *Network & servers* settings. Set message expiration in chats. - Установите время исчезания сообщений в чатах. + Установите срок хранения сообщений в чатах. No comment provided by engineer. @@ -7598,7 +7547,7 @@ Enable in *Network & servers* settings. TCP port for messaging - TCP-порт для обмена сообщениями + TCP-порт для отправки сообщений No comment provided by engineer. @@ -7885,7 +7834,7 @@ It can happen because of some bug or when the connection is compromised. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. - Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены. + Это действие нельзя отменить - сообщения в этом чате, отправленные или полученные раньше чем выбрано, будут удалены. alert message @@ -8324,7 +8273,7 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. - Использовать TCP-порт %@, когда не указано ни одного порта. + Использовать TCP-порт %@, когда порт не указан. No comment provided by engineer. @@ -9233,7 +9182,7 @@ Repeat connection request? archived report - архивная жалоба + заархивированное сообщение о нарушении No comment provided by engineer. @@ -9841,7 +9790,7 @@ Repeat connection request? pending - на рассмотрении + ожидает No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 05ebf4337d..74bb020cd6 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (สามารถคัดลอกได้) @@ -306,11 +290,6 @@ %u ข้อความที่ถูกข้าม No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) No comment provided by engineer. @@ -319,11 +298,6 @@ (this device v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. No comment provided by engineer. @@ -384,11 +358,6 @@ \*ตัวหนา* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -419,11 +388,6 @@ - ประวัติการแก้ไข No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec time to disappear @@ -488,11 +452,6 @@ 30 วินาที No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -500,14 +459,6 @@ <p><a href="%@">เชื่อมต่อกับฉันผ่าน SimpleX Chat</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things อีกสองสามอย่าง @@ -728,8 +679,8 @@ แชทและข้อความทั้งหมดจะถูกลบ - การดำเนินการนี้ไม่สามารถยกเลิกได้! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3319,9 +3270,9 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -5834,8 +5785,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 9b832b61fc..d3d81c2674 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (kopyalanabilir) @@ -330,11 +314,6 @@ %u mesajlar atlandı. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (yeni) @@ -345,11 +324,6 @@ (bu cihaz v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Kişi ekle**: yeni bir davet bağlantısı oluşturmak için, ya da aldığın bağlantıyla bağlan. @@ -415,11 +389,6 @@ \*kalın* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -456,11 +425,6 @@ - düzenleme geçmişi. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 saniye @@ -528,11 +492,6 @@ 30 saniye No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -540,14 +499,6 @@ <p><a href="%@">SimpleX Chat ile bana bağlanın</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things Birkaç şey daha @@ -789,8 +740,8 @@ Tüm konuşmalar ve mesajlar silinecektir. Bu, geri alınamaz! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3606,9 +3557,9 @@ Bu senin kendi tek kullanımlık bağlantın! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -6314,8 +6265,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 bb9ae7d3cd..767867685d 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (можна скопіювати) @@ -330,11 +314,6 @@ %u повідомлень пропущено. No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (новий) @@ -345,11 +324,6 @@ (цей пристрій v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **Додати контакт**: створити нове посилання-запрошення. @@ -415,11 +389,6 @@ \*жирний* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -456,11 +425,6 @@ - історія редагування. No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 сек @@ -528,11 +492,6 @@ 30 секунд No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -540,14 +499,6 @@ <p><a href="%@"> Зв'яжіться зі мною через SimpleX Chat</a></p> email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things Ще кілька речей @@ -789,8 +740,8 @@ Всі чати та повідомлення будуть видалені - це неможливо скасувати! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3607,9 +3558,9 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -6337,8 +6288,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification 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 ef62814aff..d464b2f0ed 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 @@ -5,22 +5,6 @@ - - - - - - No comment provided by engineer. - - - - No comment provided by engineer. - - - ( - ( - No comment provided by engineer. - (can be copied) (可复制) @@ -322,11 +306,6 @@ 已跳过 %u 条消息。 No comment provided by engineer. - - ( - ( - No comment provided by engineer. - (new) (新) @@ -337,11 +316,6 @@ (此设备 v%@) No comment provided by engineer. - - ) - ) - No comment provided by engineer. - **Create 1-time link**: to create and share a new invitation link. **添加联系人**: 创建新的邀请链接,或通过您收到的链接进行连接. @@ -406,11 +380,6 @@ \*加粗* No comment provided by engineer. - - , - , - No comment provided by engineer. - - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). @@ -447,11 +416,6 @@ - 编辑消息历史。 No comment provided by engineer. - - . - . - No comment provided by engineer. - 0 sec 0 秒 @@ -517,11 +481,6 @@ 30秒 No comment provided by engineer. - - : - : - No comment provided by engineer. - <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> @@ -529,14 +488,6 @@ <p><a href="%@">通过 SimpleX Chat </a></p>与我联系 email text - - @%@ - No comment provided by engineer. - - - @'%@' - No comment provided by engineer. - A few more things 一些杂项 @@ -768,8 +719,8 @@ 所有聊天记录和消息将被删除——这一行为无法撤销! No comment provided by engineer. - - All chats will be removed from the list (text), and the list deleted. + + All chats will be removed from the list %@, and the list deleted. alert message @@ -3532,9 +3483,9 @@ This is your own one-time link! %@ alert message - + File is blocked by server operator: -(info.reason.text). +%@. file error text @@ -6209,8 +6160,8 @@ Enable in *Network & servers* settings. Report violation: only group moderators will see it. report reason - - Report: (text.isEmpty ? reason.text : text) + + Report: %@ report in notification diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 468bc2ea8f..51feb623e2 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3827,7 +3827,7 @@ public enum FileError: Decodable, Equatable, Hashable { public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown file chunk address - most likely file is deleted.", comment: "file error text") - case let .blocked(_, info): NSLocalizedString("File is blocked by server operator:\n\(info.reason.text).", comment: "file error text") + case let .blocked(_, info): String.localizedStringWithFormat(NSLocalizedString("File is blocked by server operator:\n%@.", comment: "file error text"), info.reason.text) case .noFile: NSLocalizedString("File not found - most likely file was deleted or cancelled.", comment: "file error text") case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("File server error: %@", comment: "file error text"), srvError.errorInfo) case let .other(fileError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "file error text"), fileError) diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 97cc633115..5579449caa 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -205,7 +205,7 @@ func hideSecrets(_ cItem: ChatItem) -> String { } else { let mc = cItem.content.msgContent if case let .report(text, reason) = mc { - return NSLocalizedString("Report: \(text.isEmpty ? reason.text : text)", comment: "report in notification") + return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text) } else { return cItem.text } diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index b697b58222..631e0e7628 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (може да се копира)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- гласови съобщения до 5 минути.\n- персонализирано време за изчезване.\n- история на редактиране."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 цветно!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(ново)"; /* No comment provided by engineer. */ "(this device v%@)" = "(това устройство v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Допринеси](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index eef7930c6f..b6baf5e951 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (lze kopírovat)"; @@ -19,27 +13,12 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 minutové hlasové zprávy.\n- vlastní čas mizení.\n- historie úprav."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 barevný!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(this device v%@)" = "(toto zařízení v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Přispějte](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index da88d2cb77..a85506f2e3 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1,12 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" " = " "; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (kann kopiert werden)"; @@ -25,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- Bis zu 5 Minuten lange Sprachnachrichten\n- Zeitdauer für verschwindende Nachrichten anpassen\n- Nachrichtenverlauf bearbeiten"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 farbig!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(Neu)"; /* No comment provided by engineer. */ "(this device v%@)" = "(Dieses Gerät hat v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -58,12 +34,6 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)"; -/* No comment provided by engineer. */ -"@'%@'" = "@'%@'"; - -/* No comment provided by engineer. */ -"@%@" = "@%@"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Kontakt hinzufügen**: Um einen neuen Einladungslink zu erstellen."; @@ -503,7 +473,7 @@ "All chats and messages will be deleted - this cannot be undone!" = "Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden!"; /* alert message */ -"All chats will be removed from the list (text), and the list deleted." = "Alle Chats werden von der Liste (text) entfernt und danach wird die Liste gelöscht."; +"All chats will be removed from the list %@, and the list deleted." = "Alle Chats werden von der Liste %@ entfernt und danach wird die Liste gelöscht."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Alle Daten werden gelöscht, sobald dieser eingegeben wird."; @@ -2434,7 +2404,7 @@ "File errors:\n%@" = "Datei-Fehler:\n%@"; /* file error text */ -"File is blocked by server operator:\n(info.reason.text)." = "Datei wurde vom Server-Betreiber blockiert:\n(info.reason.text)."; +"File is blocked by server operator:\n%@." = "Datei wurde vom Server-Betreiber blockiert:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen."; @@ -4276,7 +4246,7 @@ "Report violation: only group moderators will see it." = "Verstoß melden: Nur Gruppenmoderatoren werden es sehen."; /* report in notification */ -"Report: (text.isEmpty ? reason.text : text)" = "Meldung: (text.isEmpty ? reason.text : text)"; +"Report: %@" = "Meldung: %@"; /* No comment provided by engineer. */ "Reporting messages to moderators is prohibited." = "Melden von Nachrichten an Moderatoren ist nicht erlaubt."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index f69e9a1356..09e7ea5b8e 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1,12 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" " = " "; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (puede copiarse)"; @@ -25,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- mensajes de voz de hasta 5 minutos.\n- tiempo personalizado para mensajes temporales.\n- historial de edición."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 coloreado!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nuevo)"; /* No comment provided by engineer. */ "(this device v%@)" = "(este dispositivo v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuye](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -58,12 +34,6 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Estrella en GitHub](https://github.com/simplex-chat/simplex-chat)"; -/* No comment provided by engineer. */ -"@'%@'" = "@'%@'"; - -/* No comment provided by engineer. */ -"@%@" = "@%@"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Añadir contacto**: crea un enlace de invitación nuevo."; @@ -503,7 +473,7 @@ "All chats and messages will be deleted - this cannot be undone!" = "Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse!"; /* alert message */ -"All chats will be removed from the list (text), and the list deleted." = "Todos los chats serán quitados de la lista (text) y esta será eliminada."; +"All chats will be removed from the list %@, and the list deleted." = "Todos los chats serán quitados de la lista %@ y esta será eliminada."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Al introducirlo todos los datos son eliminados."; @@ -2434,7 +2404,7 @@ "File errors:\n%@" = "Error(es) de archivo\n%@"; /* file error text */ -"File is blocked by server operator:\n(info.reason.text)." = "Archivo bloqueado por el operador del servidor\n(info.reason.text)."; +"File is blocked by server operator:\n%@." = "Archivo bloqueado por el operador del servidor\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "Archivo no encontrado, probablemente haya sido borrado o cancelado."; @@ -4276,7 +4246,7 @@ "Report violation: only group moderators will see it." = "Informar de violación: sólo los moderadores del grupo lo verán."; /* report in notification */ -"Report: (text.isEmpty ? reason.text : text)" = "Informe: (text.isEmpty ? reason.text : text)"; +"Report: %@" = "Informe: %@"; /* No comment provided by engineer. */ "Reporting messages to moderators is prohibited." = "No se permite informar de mensajes a los moderadores."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index e860e56426..33a2acff78 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (voidaan kopioida)"; @@ -16,24 +10,9 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- ääniviestit enintään 5 minuuttia.\n- mukautettu katoamisaika.\n- historian muokkaaminen."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 värillinen!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Osallistu](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 05dcfa9cd3..ad33963572 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (peut être copié)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messages vocaux pouvant durer jusqu'à 5 minutes.\n- délai personnalisé de disparition.\n- l'historique de modification."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 coloré!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nouveau)"; /* No comment provided by engineer. */ "(this device v%@)" = "(cet appareil v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuer](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 0fb32210f5..64b087aa59 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1,12 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" " = " "; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (másolható)"; @@ -25,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 színezett!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(új)"; /* No comment provided by engineer. */ "(this device v%@)" = "(ez az eszköz: v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Hozzájárulás](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -58,12 +34,6 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Csillagozás a GitHubon](https://github.com/simplex-chat/simplex-chat)"; -/* No comment provided by engineer. */ -"@'%@'" = "@'%@'"; - -/* No comment provided by engineer. */ -"@%@" = "@%@"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; @@ -503,7 +473,7 @@ "All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza!"; /* alert message */ -"All chats will be removed from the list (text), and the list deleted." = "Az összes csevegés el lesz távolítva a(z) (text) nevű listáról, és a lista is törölve lesz."; +"All chats will be removed from the list %@, and the list deleted." = "Az összes csevegés el lesz távolítva a(z) %@ nevű listáról, és a lista is törölve lesz."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "A jelkód megadása után az összes adat törölve lesz."; @@ -2434,7 +2404,7 @@ "File errors:\n%@" = "Fájlhiba:\n%@"; /* file error text */ -"File is blocked by server operator:\n(info.reason.text)." = "A kiszolgáló üzemeltetője letiltotta a fájlt:\n(info.reason.text)."; +"File is blocked by server operator:\n%@." = "A kiszolgáló üzemeltetője letiltotta a fájlt:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták."; @@ -4276,7 +4246,7 @@ "Report violation: only group moderators will see it." = "Szabálysértés jelentése: csak a csoport moderátorai látják."; /* report in notification */ -"Report: (text.isEmpty ? reason.text : text)" = "Jelentés: (text.isEmpty ? reason.text : text)"; +"Report: %@" = "Jelentés: %@"; /* No comment provided by engineer. */ "Reporting messages to moderators is prohibited." = "Az üzenetek jelentése a moderátorok felé le van tiltva."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 6474459604..d2fe0811ca 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1,12 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" " = " "; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (può essere copiato)"; @@ -25,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messaggi vocali fino a 5 minuti.\n- tempo di scomparsa personalizzato.\n- cronologia delle modifiche."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 colorato!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nuovo)"; /* No comment provided by engineer. */ "(this device v%@)" = "(questo dispositivo v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Contribuisci](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -58,12 +34,6 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Dai una stella su GitHub](https://github.com/simplex-chat/simplex-chat)"; -/* No comment provided by engineer. */ -"@'%@'" = "@'%@'"; - -/* No comment provided by engineer. */ -"@%@" = "@%@"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Aggiungi contatto**: per creare un nuovo link di invito."; @@ -503,7 +473,7 @@ "All chats and messages will be deleted - this cannot be undone!" = "Tutte le chat e i messaggi verranno eliminati. Non è reversibile!"; /* alert message */ -"All chats will be removed from the list (text), and the list deleted." = "Tutte le chat verranno rimosse dall'elenco (testo) e l'elenco eliminato."; +"All chats will be removed from the list %@, and the list deleted." = "Tutte le chat verranno rimosse dall'elenco %@ e l'elenco eliminato."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Tutti i dati vengono cancellati quando inserito."; @@ -2434,7 +2404,7 @@ "File errors:\n%@" = "Errori di file:\n%@"; /* file error text */ -"File is blocked by server operator:\n(info.reason.text)." = "Il file è bloccato dall'operatore del server:\n(info.reason.text)."; +"File is blocked by server operator:\n%@." = "Il file è bloccato dall'operatore del server:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "File non trovato - probabilmente è stato eliminato o annullato."; @@ -4276,7 +4246,7 @@ "Report violation: only group moderators will see it." = "Segnala violazione: solo i moderatori del gruppo lo vedranno."; /* report in notification */ -"Report: (text.isEmpty ? reason.text : text)" = "Segnalazione: (text.isEmpty ? reason.text : text)"; +"Report: %@" = "Segnalazione: %@"; /* No comment provided by engineer. */ "Reporting messages to moderators is prohibited." = "È vietato segnalare messaggi ai moderatori."; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index b97da471b2..9ef8c02a0e 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (コピー可能)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 最長 5 分間の音声メッセージ。\n- 消えるまでのカスタム時間。\n- 編集履歴。"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 色付き!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(新規)"; /* No comment provided by engineer. */ "(this device v%@)" = "(このデバイス v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[貢献する](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 1b2ab30025..e2bdd06018 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1,12 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" " = " "; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (kan gekopieerd worden)"; @@ -25,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- spraakberichten tot 5 minuten.\n- aangepaste tijd om te verdwijnen.\n- bewerkingsgeschiedenis."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 gekleurd!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nieuw)"; /* No comment provided by engineer. */ "(this device v%@)" = "(dit apparaat v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Bijdragen](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -58,12 +34,6 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)"; -/* No comment provided by engineer. */ -"@'%@'" = "@'%@'"; - -/* No comment provided by engineer. */ -"@%@" = "@%@"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Contact toevoegen**: om een nieuwe uitnodigingslink aan te maken, of verbinding te maken via een link die u heeft ontvangen."; @@ -503,7 +473,7 @@ "All chats and messages will be deleted - this cannot be undone!" = "Alle chats en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt!"; /* alert message */ -"All chats will be removed from the list (text), and the list deleted." = "Alle chats worden uit de lijst (tekst) verwijderd en de lijst wordt verwijderd."; +"All chats will be removed from the list %@, and the list deleted." = "Alle chats worden uit de lijst %@ verwijderd en de lijst wordt verwijderd."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Alle gegevens worden bij het invoeren gewist."; @@ -2434,7 +2404,7 @@ "File errors:\n%@" = "Bestandsfouten:\n%@"; /* file error text */ -"File is blocked by server operator:\n(info.reason.text)." = "Bestand is geblokkeerd door serveroperator:\n(info.reason.text)."; +"File is blocked by server operator:\n%@." = "Bestand is geblokkeerd door serveroperator:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "Bestand niet gevonden - hoogstwaarschijnlijk is het bestand verwijderd of geannuleerd."; @@ -4267,7 +4237,7 @@ "Report violation: only group moderators will see it." = "Rapporteer overtreding: alleen groepsmoderators kunnen dit zien."; /* report in notification */ -"Report: (text.isEmpty ? reason.text : text)" = "Rapport: (text.isEmpty ? reason.text : text)"; +"Report: %@" = "Rapport: %@"; /* No comment provided by engineer. */ "Reporting messages to moderators is prohibited." = "Het is niet toegestaan om berichten aan moderators te melden."; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 4c88e3dcaf..72a9a538c6 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (można skopiować)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- wiadomości głosowe do 5 minut.\n- niestandardowy czas zniknięcia.\n- historia edycji."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 pokolorowany!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(nowy)"; /* No comment provided by engineer. */ "(this device v%@)" = "(to urządzenie v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Przyczyń się](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -55,12 +34,6 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Daj gwiazdkę na GitHub](https://github.com/simplex-chat/simplex-chat)"; -/* No comment provided by engineer. */ -"@'%@'" = "@'%@'"; - -/* No comment provided by engineer. */ -"@%@" = "@%@"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Dodaj kontakt**: aby utworzyć nowy link z zaproszeniem lub połączyć się za pomocą otrzymanego linku."; @@ -497,7 +470,7 @@ "All chats and messages will be deleted - this cannot be undone!" = "Wszystkie czaty i wiadomości zostaną usunięte - nie można tego cofnąć!"; /* alert message */ -"All chats will be removed from the list (text), and the list deleted." = "Wszystkie rozmowy zostaną usunięte z listy (text), a lista usunięta."; +"All chats will be removed from the list %@, and the list deleted." = "Wszystkie rozmowy zostaną usunięte z listy %@, a lista usunięta."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Wszystkie dane są usuwane po jego wprowadzeniu."; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index b63d59c644..c8b971e21d 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1,12 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" " = " "; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (можно скопировать)"; @@ -25,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- голосовые сообщения до 5 минут.\n- настройка времени исчезающих сообщений.\n- история редактирования."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 цвет!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(новое)"; /* No comment provided by engineer. */ "(this device v%@)" = "(это устройство v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внести свой вклад](https://github.com/simplex-chat/simplex-chat#contribute)"; @@ -58,12 +34,6 @@ /* No comment provided by engineer. */ "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Поставить звездочку в GitHub](https://github.com/simplex-chat/simplex-chat)"; -/* No comment provided by engineer. */ -"@'%@'" = "@'%@'"; - -/* No comment provided by engineer. */ -"@%@" = "@%@"; - /* No comment provided by engineer. */ "**Create 1-time link**: to create and share a new invitation link." = "**Добавить контакт**: создать и поделиться новой ссылкой-приглашением."; @@ -503,7 +473,7 @@ "All chats and messages will be deleted - this cannot be undone!" = "Все чаты и сообщения будут удалены - это нельзя отменить!"; /* alert message */ -"All chats will be removed from the list (text), and the list deleted." = "Все чаты будут удалены из списка, а сам список удален."; +"All chats will be removed from the list %@, and the list deleted." = "Все чаты будут удалены из списка %@, и список удален."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Все данные удаляются при его вводе."; @@ -533,7 +503,7 @@ "All profiles" = "Все профили"; /* No comment provided by engineer. */ -"All reports will be archived for you." = "Все отчеты будут заархивированы для вас."; +"All reports will be archived for you." = "Все сообщения о нарушениях будут заархивированы для вас."; /* No comment provided by engineer. */ "All your contacts will remain connected." = "Все контакты, которые соединились через этот адрес, сохранятся."; @@ -581,7 +551,7 @@ "Allow to irreversibly delete sent messages. (24 hours)" = "Разрешить необратимо удалять отправленные сообщения. (24 часа)"; /* No comment provided by engineer. */ -"Allow to report messsages to moderators." = "Разрешить отправлять жалобы на сообщения модераторам."; +"Allow to report messsages to moderators." = "Разрешить отправлять сообщения о нарушениях модераторам."; /* No comment provided by engineer. */ "Allow to send files and media." = "Разрешить посылать файлы и медиа."; @@ -689,10 +659,10 @@ "Archive" = "Архивировать"; /* No comment provided by engineer. */ -"Archive %lld reports?" = "Архивировать %lld отчёт(ов)?"; +"Archive %lld reports?" = "Архивировать %lld сообщений о нарушениях?"; /* No comment provided by engineer. */ -"Archive all reports?" = "Архивировать все отчеты?"; +"Archive all reports?" = "Архивировать все сообщения о нарушениях?"; /* No comment provided by engineer. */ "Archive and upload" = "Архивировать и загрузить"; @@ -701,25 +671,25 @@ "Archive contacts to chat later." = "Архивируйте контакты чтобы продолжить переписку."; /* No comment provided by engineer. */ -"Archive report" = "Архивировать отчет"; +"Archive report" = "Архивировать сообщение о нарушении"; /* No comment provided by engineer. */ -"Archive report?" = "Архивировать отчет?"; +"Archive report?" = "Архивировать сообщение о нарушении?"; /* swipe action */ -"Archive reports" = "Архивировать отчёты"; +"Archive reports" = "Архивировать сообщения о нарушениях"; /* No comment provided by engineer. */ "Archived contacts" = "Архивированные контакты"; /* No comment provided by engineer. */ -"archived report" = "архивная жалоба"; +"archived report" = "заархивированное сообщение о нарушении"; /* No comment provided by engineer. */ "Archiving database" = "Подготовка архива"; /* No comment provided by engineer. */ -"Ask" = "спросить"; +"Ask" = "Спросить"; /* No comment provided by engineer. */ "Attach" = "Прикрепить"; @@ -812,7 +782,7 @@ "Better notifications" = "Улучшенные уведомления"; /* No comment provided by engineer. */ -"Better privacy and security" = "Улучшенная приватность и безопасность"; +"Better privacy and security" = "Улучшенная конфиденциальность и безопасность"; /* No comment provided by engineer. */ "Better security ✅" = "Улучшенная безопасность ✅"; @@ -887,7 +857,7 @@ "Business chats" = "Бизнес разговоры"; /* No comment provided by engineer. */ -"Businesses" = "Предприятия"; +"Businesses" = "Бизнесы"; /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; @@ -1132,7 +1102,7 @@ "colored" = "цвет"; /* report reason */ -"Community guidelines violation" = "Нарушение принципов Сообщества"; +"Community guidelines violation" = "Нарушение правил группы"; /* server test step */ "Compare file" = "Сравнение файла"; @@ -1713,7 +1683,7 @@ "Delete queue" = "Удаление очереди"; /* No comment provided by engineer. */ -"Delete report" = "удалить отчёт"; +"Delete report" = "Удалить сообщение о нарушении"; /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Удаляйте до 20 сообщений за раз."; @@ -2194,7 +2164,7 @@ "Error creating profile!" = "Ошибка создания профиля!"; /* No comment provided by engineer. */ -"Error creating report" = "Ошибка создания отчета"; +"Error creating report" = "Ошибка создания сообщения о нарушении"; /* No comment provided by engineer. */ "Error decrypting file" = "Ошибка расшифровки файла"; @@ -2269,7 +2239,7 @@ "Error removing member" = "Ошибка при удалении члена группы"; /* alert title */ -"Error reordering lists" = "Ошибка переупорядочивания списков"; +"Error reordering lists" = "Ошибка сортировки списков"; /* No comment provided by engineer. */ "Error resetting statistics" = "Ошибка сброса статистики"; @@ -2434,7 +2404,7 @@ "File errors:\n%@" = "Ошибки файлов:\n%@"; /* file error text */ -"File is blocked by server operator:\n(info.reason.text)." = "Файл заблокирован оператором сервера"; +"File is blocked by server operator:\n%@." = "Файл заблокирован оператором сервера:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "Файл не найден - скорее всего, файл был удален или отменен."; @@ -2602,7 +2572,7 @@ "Further reduced battery usage" = "Уменьшенное потребление батареи"; /* No comment provided by engineer. */ -"Get notified when mentioned." = "Получайте уведомления от упоминаний."; +"Get notified when mentioned." = "Уведомления, когда Вас упомянули."; /* No comment provided by engineer. */ "GIFs and stickers" = "ГИФ файлы и стикеры"; @@ -2812,10 +2782,10 @@ "inactive" = "неактивен"; /* report reason */ -"Inappropriate content" = "Нежелательный контент"; +"Inappropriate content" = "Неприемлемый контент"; /* report reason */ -"Inappropriate profile" = "нежелательный профиль"; +"Inappropriate profile" = "Неприемлемый профиль"; /* No comment provided by engineer. */ "Incognito" = "Инкогнито"; @@ -3178,7 +3148,7 @@ "Member inactive" = "Член неактивен"; /* chat feature */ -"Member reports" = "Жалобы на участника"; +"Member reports" = "Сообщения о нарушениях"; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "Роль участника будет изменена на \"%@\". Все участники разговора получат уведомление."; @@ -3202,7 +3172,7 @@ "Members can irreversibly delete sent messages. (24 hours)" = "Члены группы могут необратимо удалять отправленные сообщения. (24 часа)"; /* No comment provided by engineer. */ -"Members can report messsages to moderators." = "Участники могут пожаловаться на сообщения администраторам."; +"Members can report messsages to moderators." = "Члены группы могут пожаловаться модераторам."; /* No comment provided by engineer. */ "Members can send direct messages." = "Члены группы могут посылать прямые сообщения."; @@ -3220,7 +3190,7 @@ "Members can send voice messages." = "Члены группы могут отправлять голосовые сообщения."; /* No comment provided by engineer. */ -"Mention members 👋" = "Упоминайте участников 👋"; +"Mention members 👋" = "Упоминайте членов группы 👋"; /* No comment provided by engineer. */ "Menus" = "Меню"; @@ -3397,7 +3367,7 @@ "Mute" = "Без звука"; /* notification label action */ -"Mute all" = "Выключить уведомления для всех"; +"Mute all" = "Все без звука"; /* No comment provided by engineer. */ "Muted when inactive!" = "Без звука, когда не активный!"; @@ -3496,13 +3466,13 @@ "No app password" = "Нет кода доступа"; /* No comment provided by engineer. */ -"No chats" = "Никаких чатов"; +"No chats" = "Нет чатов"; /* No comment provided by engineer. */ "No chats found" = "Чаты не найдены"; /* No comment provided by engineer. */ -"No chats in list %@" = "Никаких чатов в списке %@"; +"No chats in list %@" = "Нет чатов в списке %@"; /* No comment provided by engineer. */ "No contacts selected" = "Контакты не выбраны"; @@ -3580,7 +3550,7 @@ "No token!" = "Нет токена!"; /* No comment provided by engineer. */ -"No unread chats" = "Без непрочитанных чатов"; +"No unread chats" = "Нет непрочитанных чатов"; /* No comment provided by engineer. */ "No user identifiers." = "Без идентификаторов пользователей."; @@ -3589,7 +3559,7 @@ "Not compatible!" = "Несовместимая версия!"; /* No comment provided by engineer. */ -"Notes" = "Примечания"; +"Notes" = "Заметки"; /* No comment provided by engineer. */ "Nothing selected" = "Ничего не выбрано"; @@ -3831,10 +3801,10 @@ "peer-to-peer" = "peer-to-peer"; /* No comment provided by engineer. */ -"pending" = "на рассмотрении"; +"pending" = "ожидает"; /* No comment provided by engineer. */ -"Pending" = "В ожидании"; +"Pending" = "Ожидает"; /* No comment provided by engineer. */ "pending approval" = "ожидает утверждения"; @@ -3906,7 +3876,7 @@ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете."; /* token info */ -"Please try to disable and re-enable notfications." = "Попробуйте отключить и снова включить уведомления."; +"Please try to disable and re-enable notfications." = "Попробуйте выключить и снова включить уведомления."; /* token info */ "Please wait for token activation to complete." = "Пожалуйста, дождитесь завершения активации токена."; @@ -3951,7 +3921,7 @@ "Private filenames" = "Защищенные имена файлов"; /* No comment provided by engineer. */ -"Private media file names." = "Приватные названия медиафайлов."; +"Private media file names." = "Конфиденциальные названия медиафайлов."; /* No comment provided by engineer. */ "Private message routing" = "Конфиденциальная доставка сообщений"; @@ -3999,7 +3969,7 @@ "Prohibit messages reactions." = "Запретить реакции на сообщения."; /* No comment provided by engineer. */ -"Prohibit reporting messages to moderators." = "Запретить отправлять жалобы на сообщения модераторам."; +"Prohibit reporting messages to moderators." = "Запретить жаловаться модераторам группы."; /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Запретить посылать прямые сообщения членам группы."; @@ -4258,31 +4228,31 @@ "Report" = "Пожаловаться"; /* report reason */ -"Report content: only group moderators will see it." = "Содержание жалобы: его увидят только групповые модераторы."; +"Report content: only group moderators will see it." = "Пожаловаться на сообщение: увидят только модераторы группы."; /* report reason */ -"Report member profile: only group moderators will see it." = "Жалоба на профиль участника: его увидят только групповые модераторы."; +"Report member profile: only group moderators will see it." = "Пожаловаться на профиль: увидят только модераторы группы."; /* report reason */ -"Report other: only group moderators will see it." = "Другая жалоба: её увидят только модераторы."; +"Report other: only group moderators will see it." = "Пожаловаться: увидят только модераторы группы."; /* No comment provided by engineer. */ -"Report reason?" = "Причина жалобы?"; +"Report reason?" = "Причина сообщения?"; /* report reason */ -"Report spam: only group moderators will see it." = "Пожаловаться на спам: это увидит только модератор группы."; +"Report spam: only group moderators will see it." = "Пожаловаться на спам: увидят только модераторы группы."; /* report reason */ -"Report violation: only group moderators will see it." = "Пожаловаться на нарушение: это увидит только модератор группы."; +"Report violation: only group moderators will see it." = "Пожаловаться на нарушение: увидят только модераторы группы."; /* report in notification */ -"Report: (text.isEmpty ? reason.text : text)" = "Жалоба"; +"Report: %@" = "Сообщение о нарушении: %@"; /* No comment provided by engineer. */ -"Reporting messages to moderators is prohibited." = "Жалобы на сообщения запрещены в этой группе."; +"Reporting messages to moderators is prohibited." = "Сообщения о нарушениях запрещены в этой группе."; /* No comment provided by engineer. */ -"Reports" = "Жалобы"; +"Reports" = "Сообщения о нарушениях"; /* chat list item title */ "requested to connect" = "запрошено соединение"; @@ -4559,7 +4529,7 @@ "Send notifications" = "Отправлять уведомления"; /* No comment provided by engineer. */ -"Send private reports" = "Отправляйте приватные жалобы"; +"Send private reports" = "Вы можете сообщить о нарушениях"; /* No comment provided by engineer. */ "Send questions and ideas" = "Отправьте вопросы и идеи"; @@ -4694,7 +4664,7 @@ "Set 1 day" = "Установить 1 день"; /* No comment provided by engineer. */ -"Set chat name…" = "Установить имя чата…"; +"Set chat name…" = "Имя чата…"; /* No comment provided by engineer. */ "Set contact name…" = "Имя контакта…"; @@ -4709,7 +4679,7 @@ "Set it instead of system authentication." = "Установите код вместо системной аутентификации."; /* No comment provided by engineer. */ -"Set message expiration in chats." = "Установите время исчезания сообщений в чатах."; +"Set message expiration in chats." = "Установите срок хранения сообщений в чатах."; /* profile update event chat item */ "set new contact address" = "установлен новый адрес контакта"; @@ -5041,7 +5011,7 @@ "TCP connection timeout" = "Таймаут TCP соединения"; /* No comment provided by engineer. */ -"TCP port for messaging" = "TCP-порт для обмена сообщениями"; +"TCP port for messaging" = "TCP-порт для отправки сообщений"; /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -5182,7 +5152,7 @@ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут."; /* alert message */ -"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены."; +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Это действие нельзя отменить - сообщения в этом чате, отправленные или полученные раньше чем выбрано, будут удалены."; /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны."; @@ -5506,7 +5476,7 @@ "Use SOCKS proxy" = "Использовать SOCKS прокси"; /* No comment provided by engineer. */ -"Use TCP port %@ when no port is specified." = "Использовать TCP-порт %@, когда не указано ни одного порта."; +"Use TCP port %@ when no port is specified." = "Использовать TCP-порт %@, когда порт не указан."; /* No comment provided by engineer. */ "Use the app while in the call." = "Используйте приложение во время звонка."; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 0a3564fe91..abe1632645 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (สามารถคัดลอกได้)"; @@ -16,24 +10,9 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- ข้อความเสียงนานสุด 5 นาที\n- เวลาที่กำหนดเองที่จะหายไป\n- ประวัติการแก้ไข"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 มีสี!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[มีส่วนร่วม](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index dd0d7c6217..88b2ac401e 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (kopyalanabilir)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 dakikaya kadar süren sesli mesajlar.\n- mesaj kaybolması için özel zaman.\n- düzenleme geçmişi."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 renklendirilmiş!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(yeni)"; /* No comment provided by engineer. */ "(this device v%@)" = "(bu cihaz v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Katkıda bulun](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index f750d1ac8b..ceb8ee0bfa 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (можна скопіювати)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- голосові повідомлення до 5 хвилин.\n- користувальницький час зникнення.\n- історія редагування."; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 кольоровий!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(новий)"; /* No comment provided by engineer. */ "(this device v%@)" = "(цей пристрій v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Внесок](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 68ca7a839f..3ed0d0fa14 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1,9 +1,3 @@ -/* No comment provided by engineer. */ -"\n" = "\n"; - -/* No comment provided by engineer. */ -" (" = " ("; - /* No comment provided by engineer. */ " (can be copied)" = " (可复制)"; @@ -22,30 +16,15 @@ /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 语音消息最长5分钟。\n- 自定义限时消息。\n- 编辑消息历史。"; -/* No comment provided by engineer. */ -", " = ", "; - -/* No comment provided by engineer. */ -": " = ": "; - /* No comment provided by engineer. */ "!1 colored!" = "!1 种彩色!"; -/* No comment provided by engineer. */ -"." = "."; - -/* No comment provided by engineer. */ -"(" = "("; - /* No comment provided by engineer. */ "(new)" = "(新)"; /* No comment provided by engineer. */ "(this device v%@)" = "(此设备 v%@)"; -/* No comment provided by engineer. */ -")" = ")"; - /* No comment provided by engineer. */ "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[贡献](https://github.com/simplex-chat/simplex-chat#contribute)"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index a9d5a4b60b..20b851bad7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2282,7 +2282,6 @@ سبب آخر البلاغات 1 بلاغ - بلاغات الأعضاء المؤرشفة %d بلاغات بلاغات الأعضاء بلّغ عن المحتوى: سيراه مشرفو المجموعة فقط. 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 e5d3384f9b..5e8d22cb99 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -460,7 +460,6 @@ 1 report %d reports Member reports - Archived member reports Share message… diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 0d071e4cce..f2a18050c3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -2269,7 +2269,6 @@ El fitxer està blocat per l\'operador del servidor:\n%1$s. Motiu de l\'informe? 1 informe - Informes de membres arxivats %d informes Informes de membres Informes diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 19cf9c440d..9d13f4f262 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -2175,7 +2175,6 @@ Chyba aktualizace serveru Operátor serveru Decentralizace sítě - Archivována hlášení členů Archivovat hlášení Opustit chat? Nastavení adres 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 a28477d524..551b6caa7f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -2382,7 +2382,6 @@ %d Meldungen Mitglieder-Meldungen Meldungen - Archivierte Mitglieder-Meldungen Inhalt verletzt Nutzungsbedingungen Spam Verbindung blockiert diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 3c9ed3e5bf..ad90b97d38 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -2304,7 +2304,6 @@ Archivar Archivar informe Informes de miembro - Informes de miembros archivados %d informes Informar del perfil de un miembro: sólo los moderadores del grupo lo verán. Otro motivo diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 6e23eca6b6..dd5ce1dc6a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -2124,7 +2124,6 @@ invitation acceptée Archiver le signalement Demander - Rapports du membre archivés Ajouter à la liste Toutes les discussions seront supprimées de la liste %s, et la liste sera supprimée Ajouter des membres à l\'équipe 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 ddcc232717..4449fbed58 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -2274,7 +2274,6 @@ Jelentések %s által archivált jelentés %d jelentés - Tagok archivált jelentései Kéretlen tartalom A tartalom sérti a használati feltételeket A kapcsolat le van tiltva 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 14fb1ad156..835b21db45 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2250,7 +2250,6 @@ Semua 1 laporan Arsip - Laporan anggota arsip Alasan lain laporan arsip oleh %s Arsip laporan 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 b474e6c130..d9b207f27a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2307,7 +2307,6 @@ Archivia la segnalazione Elimina la segnalazione Segnalazioni - Segnalazioni dei membri archiviate 1 segnalazione segnalazione archiviata da %s %d segnalazioni diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index f69ec96d7c..651d32518f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -1496,7 +1496,6 @@ 1 보고서 리스트 추가 전부 - 보관된 멤버 리포트 목록 %s의 모든 차트가 제거되었고, 목록도 삭제되었습니다. 1 년 리스트에 추가하기 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 997979d039..686b1959d5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -2305,7 +2305,6 @@ Rapport verwijderen gearchiveerd rapport door %s 1 rapport - Gearchiveerde ledenrapporten %d rapporten Ledenrapporten Inhoud schendt de gebruiksvoorwaarden diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 8d31873b2d..5102c98563 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -2131,7 +2131,6 @@ Wszystkie Biznesy raport - Zarchiwizowane raporty członków Zapytaj %s.]]> %s.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 63b6b202d9..184548e0da 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -2123,7 +2123,6 @@ Arquivar Perguntar Desfoque - Denúncias arquivadas de membros Endereço comercial denúncia arquivada Deletar chat 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 881b726a51..f8f356614b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -754,7 +754,7 @@ Вы пытаетесь пригласить инкогнито контакт в группу, где Вы используете свой основной профиль Пригласить членов группы - %1$s УЧАСНИКОВ + %1$s ЧЛЕНОВ ГРУППЫ Вы: %1$s Удалить группу Удалить группу? @@ -2296,8 +2296,8 @@ Серверы по умолчанию Роль будет изменена на %s. Все участники разговора получат уведомление. Ваш профиль будет отправлен участникам разговора. - %s.]]> - %s.]]> + %s.]]> + %s.]]> Условия использования Дополнительные серверы файлов и медиа Ошибка сохранения сервера @@ -2367,16 +2367,16 @@ Название списка и эмодзи должны быть разными для всех списков. Пожаловаться Спам - Пожаловаться на спам: это увидит только модератор группы + Пожаловаться на спам: увидят только модераторы группы. Это действие не может быть отмененено - сообщения, отправленные и полученные в этом чате ранее чем выбранное, будут удалены Получайте уведомления от упоминаний. - Жалобы на сообщения запрещены в этой группе. - Пожаловаться на нарушение: это увидит только модератор группы + Сообщения о нарушениях запрещены в этой группе. + Пожаловаться на нарушение: увидят только модераторы группы. Установить имя чата… Улучшенная производительность групп Приватные названия медиафайлов. Спам - Жалобы + Сообщения о нарушениях Непрочитанные упоминания Да Упоминайте участников 👋 @@ -2385,78 +2385,77 @@ Ускорена отправка сообщений. Помогайте администраторам модерировать их группы. Организуйте чаты в списки - Отправляйте приватные жалобы + Вы можете сообщить о нарушениях Установите время исчезания сообщений в чатах. Вы можете упомянуть до %1$s пользователей в одном сообщении! - Причина жалобы? + Причина сообщения? Эта жалоба будет архивирована для вас. - Разрешить отправлять жалобы на сообщения модераторам. + Разрешить отправлять сообщения о нарушениях модераторам. Содержание нарушает условия использования Ошибка чтения пароля базы данных - архивная жалоба %s - Нарушение принципов Сообщества - Нежелательный контент - Еще одна причина - нежелательный профиль - Архивные отчеты участников - %d отчёты - Ошибка создания отчета + сообщение о нарушении заархивировано %s + Нарушение правил группы + Неприемлемое сообщение + Другая причина + Неприемлемый профиль + %d сообщений о нарушениях + Ошибка создания сообщения о нарушении Соединение заблокировано Соединение заблокировано сервером оператора:\n%1$s. - спросить + Спросить Отключить автоматическое удаление сообщений? Удалить сообщения с вашего устройства. Отключить удаление сообщений по умолчанию (%s) - Все отчеты будут заархивированы для вас. - Архивировать все отчеты? - Архивировать %d отчёт? + Все сообщения о нарушениях будут заархивированы для вас. + Архивировать все сообщения о нарушениях? + Архивировать %d сообщений о нарушениях? Для меня - Архивировать жалобу - Архивировать отчёты - удалить отчёт + Архивировать сообщение о нарушении + Архивировать сообщения о нарушениях + Удалить сообщение о нарушении Файл заблокирован оператором сервера:\n%1$s. Для всех модераторов - 1 жалоба + 1 сообщение о нарушении Измененить автоматическое удаление сообщений? 1 год Не пропустите важные сообщения. Ошибка сохранения настроек - архивная жалоба - архив - Архивировать жалобу? - Парольная фраза в хранилище ключей не может быть прочитана. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. - Парольная фраза в хранилище не читается, пожалуйста, введите ее вручную. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. + заархивированное сообщение о нарушении + архивировать + Архивировать сообщение о нарушении? + Пароль не может быть прочитан из Keystore. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. + Пароль не может быть прочитан из Keystore, пожалуйста, введите его. Это могло произойти после обновления системы, несовместимого с приложением. Если это не так, обратитесь к разработчикам. модератор ожидает утверждения - на рассмотрении + ожидает Обновленные условия - Запретить отправлять жалобы на сообщения модераторам. - Участники могут пожаловаться на сообщения администраторам. + Запретить жаловаться модераторам группы. + Члены группы могут пожаловаться модераторам. Сообщения в этом чате никогда не будут удалены. Открыть ссылку из списка чатов Открыть веб-ссылку? - Жалоба на профиль участника: его увидят только групповые модераторы. - Жалобы на участника - Другая жалоба: её увидят только модераторы. + Пожаловаться на профиль: увидят только модераторы группы. + Сообщения о нарушениях + Пожаловаться: увидят только модераторы группы. Выключить уведомления для всех - Использовать TCP-порт %1$s, когда не указано ни одного порта. + Использовать TCP-порт %1$s, когда порт не указан. Использовать веб-порт Нет - Содержание жалобы: его увидят только групповые модераторы. + Пожаловаться на сообщение: увидят только модераторы группы. отклонён - Жалоба: %s - TCP-порт для обмена сообщениями + Сообщение о нарушении: %s + TCP-порт для отправки сообщений Открыть ссылку отклонён Только отправитель и модераторы видят это Только вы и модераторы видят это Разблокировать членов для всех? - Сообщения от этих членов будут показаны! + Сообщения от этих членов группы будут показаны! Все новые сообщения от этих членов группы будут скрыты! Заблокировать членов для всех? Члены группы будут удалены - это действие нельзя отменить! - Члены будут удалены из разговора - это действие нельзя отменить! + Участники будут удалены из разговора - это действие нельзя отменить! модераторы Удалить членов группы? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 62d3764940..c721c95663 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -2143,7 +2143,6 @@ Raporu arşivle %s tarafından arşivlenen rapor 1 rapor - Arşivlenmiş üye raporları %s.]]> Sor 1 yıl 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 5fd75918a9..ce6d5fbfa8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -2308,7 +2308,6 @@ %d повідомлень Повідомлення учасників Повідомлення - Архівовані повідомлення учасників Вміст порушує умови використання Спам Файл заблоковано оператором сервера: \n%1$s. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index cdc9721282..b7b8248b10 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -1869,7 +1869,6 @@ 1 báo cáo %d báo cáo Các báo cáo của thành viên - Các báo cáo thành viên đã được lưu trữ Lỗi đăng ký dài hạn Các báo cáo Bắt đầu từ %s.\nTất cả dữ liệu được lưu trữ một cách riêng tư trên thiết bị của bạn. 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 ba5a4d3555..67faae4943 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 @@ -2294,7 +2294,6 @@ 举报 1 个举报 成员举报 - 已存档的成员举报 %d 个举报 垃圾信息 连接被阻止 From 430e212a9eb5a54991b2595dc614a8604856028d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 7 Mar 2025 18:38:06 +0400 Subject: [PATCH 124/289] core: name limit (#5724) * core: name limit * ios * trim spaces, test --- apps/ios/Shared/Views/Onboarding/CreateProfile.swift | 1 + src/Simplex/Chat/Library/Commands.hs | 4 ++-- tests/ValidNames.hs | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 409cb859ea..53cf73f1c9 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -145,6 +145,7 @@ struct CreateFirstProfile: View { TextField("Enter your name…", text: $displayName) .focused($focusDisplayName) .padding(.horizontal) + .padding(.trailing, 20) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6a5775beb7..54d3cd9143 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -37,7 +37,7 @@ import Data.Either (fromRight, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith4) +import Data.List (dropWhileEnd, find, foldl', isSuffixOf, partition, sortOn, zipWith4) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -4334,7 +4334,7 @@ displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' mkValidName :: String -> String -mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) +mkValidName = dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where fst3 (x, _, _) = x addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index 03089b1721..22ac4a695d 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -38,3 +38,5 @@ testMkValidName = do mkValidName "alice@example.com" `shouldBe` "alice@example.com" mkValidName "alice <> bob" `shouldBe` "alice <> bob" mkValidName "alice -> bob" `shouldBe` "alice -> bob" + mkValidName "01234567890123456789012345678901234567890123456789extra" `shouldBe` "01234567890123456789012345678901234567890123456789" + mkValidName "0123456789012345678901234567890123456789012345678 extra" `shouldBe` "0123456789012345678901234567890123456789012345678" From e2d488266c51c8d68d2ba6bada0a88a872421b07 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 7 Mar 2025 14:59:00 +0000 Subject: [PATCH 125/289] core: 6.3.0.8 (simplexmq 6.3.0.8) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 477ba9c601..2e1b43d41f 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: 9fece9ce3df24d1b006d98e44a6b4e654861428b + tag: a491a1d8780054432542611f540317a6090b9360 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1a9de205f3..53ccda920f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9fece9ce3df24d1b006d98e44a6b4e654861428b" = "0vf4kkj2jc4bg3bc9vsl0gv2z0l7z48626sqlk5wh7il6k14pn17"; + "https://github.com/simplex-chat/simplexmq.git"."a491a1d8780054432542611f540317a6090b9360" = "183wmraa25rxcf3b07apimsdvamccc3qx3p5rr726qzvpkvrxpab"; "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 950b689234..bbdd766a7b 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.0.7 +version: 6.3.0.8 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 3e64b2bcea..a7f44eb465 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, 0, 7] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 8] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 7] +minRemoteHostVersion = AppVersion [6, 3, 0, 8] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From 27f63dafaa8ca1e232d5def60954bbccecf6d5cc Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 7 Mar 2025 22:08:00 +0700 Subject: [PATCH 126/289] ui: option to remove messages of removed members (#5717) * ui: removing messages of removed members * android * fix android * fix ios and refactor * refactor android * update * update2 * remove ts * android new logic * refactor * remove spaghetti * if * android --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 39 ++++++++++++++++ apps/ios/Shared/Model/SimpleXAPI.swift | 16 +++++-- apps/ios/SimpleXChat/APITypes.swift | 16 +++---- .../chat/simplex/common/model/ChatModel.kt | 46 ++++++++++++++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 34 ++++++++++---- 5 files changed, 126 insertions(+), 25 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1c5a55c3b..23b167a9ff 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -624,6 +624,45 @@ final class ChatModel: ObservableObject { VoiceItemState.stopVoiceInChatView(cInfo, cItem) } + func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) { + // this should not happen, only another member can "remove" user, user can only "leave" (another event). + if byMember.groupMemberId == groupInfo.membership.groupMemberId { + logger.debug("exiting removeMemberItems") + return + } + if chatId == groupInfo.id { + for i in 0.. 0, + let updatedItem = removedUpdatedItem(chat.chatItems[0]) { + chat.chatItems = [updatedItem] + } + + func removedUpdatedItem(_ item: ChatItem) -> ChatItem? { + let newContent: CIContent + if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId { + newContent = .sndModerated + } else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId { + newContent = .rcvModerated + } else { + return nil + } + var updatedItem = item + updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember) + if groupInfo.fullGroupPreferences.fullDelete.on { + updatedItem.content = newContent + } + if item.isActiveReport { + decreaseGroupReportsCounter(groupInfo.id) + } + return updatedItem + } + } + func nextChatItemData(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } if previous { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 875a1f27fa..495209499c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1588,9 +1588,9 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } -func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64]) async throws -> [GroupMember] { - let r = await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds), bgTask: false) - if case let .userDeletedMembers(_, _, members) = r { return members } +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 } throw r } @@ -2187,16 +2187,22 @@ func processReceivedMsg(_ res: ChatResponse) async { _ = m.upsertGroupMember(groupInfo, member) } } - case let .deletedMemberUser(user, groupInfo, _): // TODO update user member + case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member if active(user) { await MainActor.run { m.updateGroup(groupInfo) + if withMessages { + m.removeMemberItems(groupInfo.membership, byMember: member, groupInfo) + } } } - case let .deletedMember(user, groupInfo, _, deletedMember): + case let .deletedMember(user, groupInfo, byMember, deletedMember, withMessages): if active(user) { await MainActor.run { _ = m.upsertGroupMember(groupInfo, deletedMember) + if withMessages { + m.removeMemberItems(deletedMember, byMember: byMember, groupInfo) + } } } case let .leftMember(user, groupInfo, member): diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 20c8785b3a..6db0478ab3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -73,7 +73,7 @@ public enum ChatCommand { case apiJoinGroup(groupId: Int64) case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) - case apiRemoveMembers(groupId: Int64, memberIds: [Int64]) + case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool) case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) @@ -252,7 +252,7 @@ public enum ChatCommand { case let .apiJoinGroup(groupId): return "/_join #\(groupId)" 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): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))" case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" @@ -681,7 +681,7 @@ public enum ChatResponse: Decodable, Error { case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) - case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember]) + case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: Group) case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) @@ -691,8 +691,8 @@ public enum ChatResponse: Decodable, Error { case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) - case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) + case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool) + case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) @@ -1048,7 +1048,7 @@ public enum ChatResponse: Decodable, Error { case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") - case let .userDeletedMembers(u, groupInfo, members): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)") + case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") @@ -1058,8 +1058,8 @@ public enum ChatResponse: Decodable, Error { 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)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") - case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)") + case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)") + case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") 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 c69301423e..85db407a20 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 @@ -549,11 +549,11 @@ object ChatModel { } } - suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) { withContext(Dispatchers.Main) { if (chatId.value == cInfo.id) { val items = chatItems.value - val itemIndex = items.indexOfFirst { it.id == cItem.id } + val itemIndex = atIndex ?: items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { items[itemIndex] = cItem } @@ -589,6 +589,48 @@ object ChatModel { } } + suspend fun removeMemberItems(rhId: Long?, removedMember: GroupMember, byMember: GroupMember, groupInfo: GroupInfo) { + fun removedUpdatedItem(item: ChatItem): ChatItem? { + val newContent = when { + item.chatDir is CIDirection.GroupSnd && removedMember.groupMemberId == groupInfo.membership.groupMemberId -> CIContent.SndModerated + item.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember.groupMemberId == removedMember.groupMemberId -> CIContent.RcvModerated + else -> return null + } + val updatedItem = item.copy( + meta = item.meta.copy(itemDeleted = CIDeleted.Moderated(Clock.System.now(), byGroupMember = byMember)), + content = if (groupInfo.fullGroupPreferences.fullDelete.on) newContent else item.content + ) + if (item.isActiveReport) { + decreaseGroupReportsCounter(rhId, groupInfo.id) + } + return updatedItem + } + + // this should not happen, only another member can "remove" user, user can only "leave" (another event). + if (byMember.groupMemberId == groupInfo.membership.groupMemberId) { + Log.d(TAG, "exiting removeMemberItems") + return + } + val cInfo = ChatInfo.Group(groupInfo) + if (chatId.value == groupInfo.id) { + for (i in 0 until chatItems.value.size) { + val updatedItem = removedUpdatedItem(chatItems.value[i]) + if (updatedItem != null) { + updateChatItem(cInfo, updatedItem, atIndex = i) + } + } + } else { + val i = getChatIndex(rhId, groupInfo.id) + val chat = chats[i] + if (chat.chatItems.isNotEmpty()) { + val updatedItem = removedUpdatedItem(chat.chatItems[0]) + if (updatedItem != null) { + chats.value[i] = chat.copy(chatItems = listOf(updatedItem)) + } + } + } + } + fun clearChat(rhId: Long?, cInfo: ChatInfo) { // clear preview val i = getChatIndex(rhId, cInfo.id) 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 c221e3c15e..b04a08e0e1 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 @@ -1995,8 +1995,8 @@ object ChatController { } } - suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List): List? = - when (val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds))) { + 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 else -> { if (!(networkErrorAlert(r))) { @@ -2694,15 +2694,29 @@ object ChatController { if (active(r.user)) { withChats { updateGroup(rhId, r.groupInfo) + if (r.withMessages) { + removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + } + } + withReportsChatsIfOpen { + if (r.withMessages) { + removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + } } } is CR.DeletedMember -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + if (r.withMessages) { + removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) + } } withReportsChatsIfOpen { upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + if (r.withMessages) { + removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) + } } } is CR.LeftMember -> @@ -3412,7 +3426,7 @@ sealed class CC { class ApiJoinGroup(val groupId: 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): CC() + class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean): CC() class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() @@ -3597,7 +3611,7 @@ sealed class CC { is ApiJoinGroup -> "/_join #$groupId" is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" - is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")}" + is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}" is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" @@ -5805,7 +5819,7 @@ sealed class CR { @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("businessLinkConnecting") class BusinessLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val fromContact: Contact): CR() - @Serializable @SerialName("userDeletedMembers") class UserDeletedMembers(val user: UserRef, val groupInfo: GroupInfo, val members: List): CR() + @Serializable @SerialName("userDeletedMembers") class UserDeletedMembers(val user: UserRef, val groupInfo: GroupInfo, val members: List, val withMessages: Boolean): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() @@ -5815,8 +5829,8 @@ sealed class 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() @Serializable @SerialName("membersBlockedForAllUser") class MembersBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val blocked: Boolean): CR() - @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() + @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val withMessages: Boolean): CR() + @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: UserRef, val intoContact: Contact, val mergedContact: Contact): CR() @@ -6168,7 +6182,7 @@ sealed class CR { is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") is BusinessLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nfromContact: $fromContact") - is UserDeletedMembers -> withUser(user, "groupInfo: $groupInfo\nmembers: $members") + is UserDeletedMembers -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\nwithMessages: $withMessages") is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") @@ -6178,8 +6192,8 @@ sealed class CR { is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole") is MemberBlockedForAll -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nblocked: $blocked") is MembersBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\nblocked: $blocked") - is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member") - is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember") + is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nwithMessages: ${withMessages}") + is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember\nwithMessages: ${withMessages}") is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") From 3412ceba01c64aa2c2584254d10ad6ef14b9d83b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 7 Mar 2025 23:49:55 +0700 Subject: [PATCH 127/289] android, desktop: fix group members duplicates (#5727) * android, desktop: fix group members duplicates * optimization * use groupMemberId as key --------- Co-authored-by: Evgeny Poberezkin --- .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 5 +++++ .../chat/simplex/common/views/chat/group/GroupMentions.kt | 2 +- 2 files changed, 6 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 85db407a20..13cdc9f19a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -789,6 +789,11 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { + if (groupMembers.value.isNotEmpty() && groupMembers.value.firstOrNull()?.groupId != groupInfo.groupId) { + // stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn + groupMembers.value = emptyList() + groupMembersIndexes.value = emptyMap() + } val memberIndex = groupMembersIndexes.value[member.groupMemberId] val updated = chatItems.value.map { // Take into account only specific changes, not all. Other member updates are not important and can be skipped 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 89ef19dcda..1a63375432 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 @@ -196,7 +196,7 @@ fun GroupMentions( MaxMentionsReached() } } - itemsIndexed(filteredMembers.value, key = { _, item -> item.memberId }) { i, member -> + itemsIndexed(filteredMembers.value, key = { _, item -> item.groupMemberId }) { i, member -> if (i != 0 || !showMaxReachedBox) { Divider() } From f31372a771e53c8a554e71b04e88f3a249be435e Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 8 Mar 2025 00:10:57 +0700 Subject: [PATCH 128/289] android, desktop: fix link preview (#5725) Co-authored-by: Evgeny Poberezkin --- .../simplex/common/views/chat/item/CIImageView.kt | 12 ++++++------ .../simplex/common/views/helpers/LinkPreviews.kt | 10 ++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 0863c28007..1be2110b1f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -92,12 +92,6 @@ fun CIImageView( } } - @Composable - fun imageViewFullWidth(): Dp { - val approximatePadding = 100.dp - return with(LocalDensity.current) { minOf(DEFAULT_MAX_IMAGE_WIDTH, LocalWindowWidth() - approximatePadding) } - } - @Composable fun imageView(imageBitmap: ImageBitmap, onClick: () -> Unit) { Image( @@ -265,6 +259,12 @@ fun CIImageView( } } +@Composable +fun imageViewFullWidth(): Dp { + val approximatePadding = 100.dp + return with(LocalDensity.current) { minOf(DEFAULT_MAX_IMAGE_WIDTH, LocalWindowWidth() - approximatePadding) } +} + private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index de4e9fe281..9c529e547a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -21,6 +21,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID +import chat.simplex.common.views.chat.item.imageViewFullWidth import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -135,10 +136,15 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel @Composable fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongClick: () -> Unit) { - Column(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { + val image = base64ToBitmap(linkPreview.image) + Column( + Modifier + .layoutId(CHAT_IMAGE_LAYOUT_ID) + .width(if (image.width * 0.97 <= image.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) + ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } Image( - base64ToBitmap(linkPreview.image), + image, stringResource(MR.strings.image_descr_link_preview), modifier = Modifier .fillMaxWidth() From 89dddab06082e5b43fb9f212de1c60a07307779f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 7 Mar 2025 18:18:43 +0000 Subject: [PATCH 129/289] 6.3: ios 268, android 279, desktop 96 --- 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 957f58d5f5..b4c7cbf592 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.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.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 */; }; @@ -531,8 +531,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.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.7-Er4xsZkxTrnBI7tHHcaHrY.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */, ); path = Libraries; sourceTree = ""; @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2012,7 +2012,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2053,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2073,7 +2073,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2098,7 +2098,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2135,7 +2135,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2172,7 +2172,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2223,7 +2223,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2274,7 +2274,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2308,7 +2308,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 268; 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 7bc1da0b97..09095de72e 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-beta.7 -android.version_code=278 +android.version_name=6.3 +android.version_code=279 -desktop.version_name=6.3-beta.7 -desktop.version_code=95 +desktop.version_name=6.3 +desktop.version_code=96 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 3188d9f08724446693a1a1dedc9bec2754e6f07e Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 8 Mar 2025 20:53:27 +0000 Subject: [PATCH 130/289] docs: add reproducibility section (#5732) --- docs/SERVER.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/SERVER.md b/docs/SERVER.md index 88ef74c058..91e5543553 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -1564,6 +1564,78 @@ To update your smp-server to latest version, choose your installation method and docker image prune ``` +## Reproduce builds + +You can locally reproduce server binaries, following these instructions. + +You must have: + +- Linux machine +- `x86-64` architecture +- Installed `docker`, `curl` and `git` + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/master/scripts/reproduce-builds.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x reproduce-builds.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./reproduce-builds.sh 'v6.3.0' + ``` + + This will take a while. + +4. After compilation, you should see the following folders: + + ```sh + ls out* + ``` + + ```sh + out-20.04: + ntf-server smp-server xftp xftp-server + + out-20.04-github: + ntf-server smp-server xftp xftp-server + + out-22.04: + ntf-server smp-server xftp xftp-server + + out-22.04-github: + ntf-server smp-server xftp xftp-server + + out-24.04: + ntf-server smp-server xftp xftp-server + + out-24.04-github: + ntf-server smp-server xftp xftp-server + ``` + +5. Compare the hashes from github release with locally build binaries: + + ```sh + sha256sum out*-github/* + ``` + + ```sh + sha256sum out*[0-9]/* + ``` + + You can safely delete cloned repository: + + ```sh + cd ../ && rm -rf simplexmq + ``` + ## Configuring the app to use the server To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. From 9dfa68bf57f31e6c3663df2c76ea69df608bfbae Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 8 Mar 2025 23:28:33 +0000 Subject: [PATCH 131/289] blog: update v6.3 release post (#5733) * blog: update v6.3 release post * update post, server page * update * headers --- README.md | 3 +- ...user-experience-safety-in-public-groups.md | 148 ++++++++++++++++-- docs/SERVER.md | 24 +++ 3 files changed, 163 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 936667da8c..ac0868d214 100644 --- a/README.md +++ b/README.md @@ -305,12 +305,13 @@ What is already implemented: 15. Manual messaging queue rotations to move conversation to another SMP relay. 16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html). 17. Local files encryption. +18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds). We plan to add: 1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days). 2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. -3. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code. +3. Reproducible clients builds – this is a complex problem, but we are aiming to have it in 2025 at least partially. 4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. ## For developers diff --git a/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md index 1e201825ce..755849afde 100644 --- a/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md +++ b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md @@ -10,23 +10,149 @@ permalink: "/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-publi # SimpleX Chat v6.3: new user experience and safety in public groups -**Will be published:** Mar 8, 2025 +**Published:** Mar 8, 2025 -This is a permalink for release announcement. +**Please note**: v6.3 release for iOS is delayed, we are currently fixing the crashes on iOS 18 reported by the users - thank you! -What's new in v6.3: +If you installed TestFlight release and it crashes for you, please install the current latest beta build 6.3 (265) - it is more stable than the removed builds 266-268. -- improved groups: - - [mention other members](#mention-members). - - [private reports](#private-reports) to group admins. - - [preventing spam and abuse](#preventing-spam-and-abuse). - - [better performance](#better-privacy-and-security). -- [chat lists](#chat-lists) to keep track of what's important. -- [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages) -- [privacy and security improvements](#privacy-and-security-improvements). +This post will be updated once iOS v6.3 is released. + +**What's new in v6.3**: +- [preventing spam and abuse in public groups](#preventing-spam-and-abuse-in-public-groups). +- [group improvements](#group-improvements): [mention other members](#mention-other-members-and-get-notified-when-mentioned), [improved performance](#better-group-performance). +- [better-chat-navigation](#better-chat-navigation): [organize chats into lists](#organize-chats-into-lists) and [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages). +- [privacy and security improvements](#pri): + +The last but not the least - from v6.3 [server builds are now reproducible](#reproducible-server-builds). ## What's new in v6.3 +## Preventing spam and abuse in public groups + +[We wrote before](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md): as the network grows, it becomes more attractive to attackers. This release adds several features that reduce the possibility of attacks and abuse. + +### Spam in groups that are listed in our group directory + +There is no built-in group discovery in SimpleX Chat apps. Instead, we offer an experimental chat bot that allows to submit and to discover public groups. Not so long ago, spammers started sending messages via bots attempting to disrupt these groups. + +We released several changes to the groups directory to protect from spam attacks: + +**Optional captcha verification** + +Group owners can enable the requirement to pass captcha challenge before joining the group. Captcha is generated in the directory bot itself, without any 3rd party servers, and is sent to the joining member. The new member must reply with the text in the image to be accepted to the group. While not a perfect protection, this basic measure complicates programming automatic bots to join public groups. It also provides a foundation to implement "knocking" - a conversation with dedicated group admins prior to joining the group. We plan to release support for knocking in March. + +**Profanity filter for member names** + +While group settings support giving all joining member an "observer" role - that is, without the right to send messages - the attackers tried spaming groups by joining and leaving. We added an optional filter for member names that group owners can enable for groups listed in directory - if a member name contains profanity, they will be rejected. Further improvements will be released in March as well. + +The current SimpleX directory chatbot is a hybrid of [future chat relays](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md#can-large-groups-scale) (a.k.a. super-peers) we are now developing to support large groups, and of a directory service that will be embedded in the app UI later this year, allowing to search and to discover public groups. Anybody is able to run their own directory bots now, and there will be possibility to use third party directories via the app UI in the future too. + +Read more about [SimpleX group directory](../docs/DIRECTORY.md), how to submit your groups, and which groups we now accept. Currently we accept groups related to a limited list of topics that will be expanded once we have better moderation functionality for the groups. + +### More power to group owners and moderators + +This release includes two new features to help group moderators. + +**Private reports** + +Members in groups can bring specific messages and members to group moderators privately, even if the group does not allow direct messages. The simply need to choose report button on the message and choose the report reason. This report will be visible to all group owners and moderators, but not to other members. + +Group moderators can see all member reports in a separate view, and quickly find the problematic messages, making moderation much easier in public groups. These reports are private to group, they are not sent to server operators. + +Please note, that for group listed in our group directory, the directory bot acts as admin, so it will receive all reports as well. + +**Acting on multiple members at once** + +When attackers come, they often use multiple profiles. This version allows selecting multiple members at once and perform these actions on all selected members: +- switch members role between "observer" and "member". +- block and unblock members - this is a "shadow" block, so when you block multiple members who you believe are attackers, their messages will be blocked for all other members but not for them. +- remove members from the group. + +The next version will also allow to remove members together with all messages they sent - for example, if a spam bot joined and sent a lot of spam, but nothing of value. + +## Group improvements + +Abuse and attacks were not the only focus of this release – there are several features that increase value of the groups for the "good" users. + +### Mention other members and get notified when mentioned + +This feature allows you to mention other members in the group in the usual way - type @ character, and choose the member you want to mention from the menu. Even that there is no user accounts and persistent identities we made it work by referencing members in additional message data by their random group ID that is also used for replies and all other interactions in the group. + +You can also now switch message notifications in the group to "mentions only" mode. You will be notified only when you are mentioned in a message or when somebody replies to your message (by including your message as a quote). Simply choose "Mute" in the context menu of the group in the list of chats to switch group notifications to "mentions only" mode. After that you can choose "Mute all" to disable all notifications, including mentions. + +### Better group performance + +**Send messages faster** + +While we didn't reduce the required network traffic to send messages to large groups - your client still has to send message to each member individually - we fully redesigned the process of preparing the message to send, by reducing both storage and time required to put the message in the queue. + +Previously, while preparing to send a message the device had to store a fully encrypted message block to a database for each member, using ~17-20kb of storage for each member, which means that to send 1 message to a group with 1000 active members you devise was temporarily reserving 17-20 Mb (!) of storage. With this release the original message is stored only once, consuming exactly as much (or as little) space as its content, and the actual encryption happens right before sending. While preparing to send a message only headers and keys are prepared and stored, consuming ~200 bytes per message, or about 200kb for 1000 active members - reducing temporary storage required to send the message ~100x, and also reducing the time to prepare it. + +**Faster group deletion** + +When you leave the group, the app preserves a copy of all your communications in the group. You can choose to keep it or to delete it completely. This final group deletion was very slow prior to this release - depending on the number of groups on your device it could sometimes take many minutes. + +This release solved this problem reducing the time it takes to delete the group to seconds, and even in cases when the app is terminated half way - it is either rolled back or completes, but it cannot leave the group in a partially deleted state. It improves both user experience and privacy, as gives you better control over your data. + +## Better chat navigation + +### Organize chats into lists + +It is a common feature in many messengers – it helps organizing your conversations. + +The lists also show when any chat in the list has messages. + +There are several preset lists: contacts, groups, private notes, favourite chats and also groups with member reports - the last list is automatically shown if members of any groups where you are the moderator or the owner sent private reports, until these reports are acted on or archived. + +### Jump to found and forwarded messages + +This version allows to quickly navigate from message in the search results to the point in the conversation when it was sent. + +You can also navigate from the forwarded message (or from the message saved to private notes) to the original message in the chat where it was forwarded or saved from. + +## Privacy and security improvements + +### Set message retention period in chats + +Before this version, you could enable message retention period for all chats in your profile. While helpful in some cases, many of us have conversations that we want to keep for a long time, and some other conversations that are pure entertainment and you want to remove them quicker. + +This version allows it - you can set different retention periods in different conversations. It can be 1 day, 1 week, 1 month or 1 year. We can add more or allow custom retention time in the future too, if users need it. + +### Private media file names + +Previously there were scenarios, when original file names were preserved - specifically, when sending a video or when forwarding any media file. The latter was even worse, as media file usually is generated automatically, to include timestamp, and using the same name could have been used to correlate files between conversations, as one of our users pointed out. + +This version fixes this problem - media file name is now changed when forwarding it to reflect the sending time, so no additional metadata is revealed. + +Please also note: +- the apps remove metadata from all static images, +- only iOS app removes metadata from videos, but android and desktop apps do not do it yet, +- animated images are sent as is, please pre-process them if you need to reduce metadata, +- other file types are sent as is, and their names are left unchanged - we believe that for ordinary files their name is part of their content. + +We plan further improvements to reduce metadata in files in the near future – please let us know what you believe is the most important to reduce first. + +## Reproducible server builds + +Starting from v6.3 server releases are reproducible! + +**Why it is important** + +With reproducible builds anybody can build servers from our code following the same process, and the build would produce identical binaries. + +This also allows us to sign releases, as we reproduce GitHub builds ourselves and by signing them we attest that our builds resulted in identical binaries. + +**How to reproduce builds** + +You can reproduce our builds on Linux with x86 CPU in docker container - please follow the instructions [here](../docs/SERVER.md#reproduce-builds). + +We are looking for support from open-source contributors or security researchers who would agree to publish their signature of our releases, having reproduced the builds. + +**How to verify release signature** + +Please see the instructions [here](../docs/SERVER.md#verifying-server-binaries). + ## SimpleX network Some links to answer the most common questions: diff --git a/docs/SERVER.md b/docs/SERVER.md index 91e5543553..3bfd064c4e 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -15,6 +15,7 @@ revision: 12.10.2024 - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) - [docker container](#docker-container) - [Linode marketplace](#linode-marketplace) +- [Verifying server binaries] - [Configuration](#configuration) - [Interactively](#interactively) - [Via command line options](#via-command-line-options) @@ -32,6 +33,7 @@ revision: 12.10.2024 - [Systemd commands](#systemd-commands) - [Control port](#control-port) - [Daily statistics](#daily-statistics) +- [Reproduce builds](#reproduce-builds) - [Updating your SMP server](#updating-your-smp-server) - [Configuring the app to use the server](#configuring-the-app-to-use-the-server) @@ -515,6 +517,28 @@ This configuration allows you to retain the ability to manage 80 and 443 ports y You can deploy smp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) +## Verifying server binaries + +Starting from v6.3 server builds are [reproducible](#reproduce-builds). + +That also allows us to sign server releases, confirming the integrity of GitHub builds. + +To verify server binaries after you downloaded them: + +1. Download `_sha256sums` (hashes of all server binaries) and `_sha256sums.asc` (signature). + +2. Download our key FB44AF81A45BDE327319797C85107E357D4A17FC from [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) + +3. Import the key with `gpg --import FB44AF81A45BDE327319797C85107E357D4A17FC`. Key filename should be the same as its fingerprint, but please change it if necessary. + +4. Run `gpg --verify --trusted-key _sha256sums.asc _sha256sums`. It should print: + +> Good signature from "SimpleX Chat " + +5. Compute the hashes of the binaries you plan to use with `shu256sum ` or with `openssl sha256 ` and compare them with the hashes in the file `_sha256sums` - they must be the same. + +That is it - you now verified authenticity of our GitHub server binaries. + ## Configuration To see which options are available, execute `smp-server` without flags: From 4bd95c8e4e1576dc30c3c0ae83d68c3434400839 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:22:47 +0700 Subject: [PATCH 132/289] ios: fix random crashes in chat on iOS 18 (#5734) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index b4c7cbf592..e60616d533 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1989,6 +1989,7 @@ ); LLVM_LTO = YES_THIN; MARKETING_VERSION = 6.3; + OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2038,6 +2039,7 @@ ); LLVM_LTO = YES; MARKETING_VERSION = 6.3; + OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; From ed625347bdc0f2a07d60961a089a4c7e76a9f291 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 9 Mar 2025 16:08:49 +0000 Subject: [PATCH 133/289] ios: v6.3, build 269 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e60616d533..9396845831 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2013,7 +2013,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2055,7 +2055,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2075,7 +2075,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2100,7 +2100,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2137,7 +2137,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2174,7 +2174,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2225,7 +2225,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2310,7 +2310,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 268; + CURRENT_PROJECT_VERSION = 269; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 16cf91902caf246f4472f362774958909797d9ee Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 9 Mar 2025 20:18:58 +0000 Subject: [PATCH 134/289] blog: update v6.3 blog post (#5735) --- README.md | 4 ++ ...user-experience-safety-in-public-groups.md | 68 +++++++++--------- blog/README.md | 22 +++++- blog/images/20250308-captcha.png | Bin 0 -> 476245 bytes blog/images/20250308-lists.png | Bin 0 -> 252142 bytes blog/images/20250308-mentions.png | Bin 0 -> 511264 bytes blog/images/20250308-reports.png | Bin 0 -> 435663 bytes .../src/_includes/blog_previews/20250308.html | 12 ++++ 8 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 blog/images/20250308-captcha.png create mode 100644 blog/images/20250308-lists.png create mode 100644 blog/images/20250308-mentions.png create mode 100644 blog/images/20250308-reports.png create mode 100644 website/src/_includes/blog_previews/20250308.html diff --git a/README.md b/README.md index ac0868d214..1830228370 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,10 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md) + +[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md) + [Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) [Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) diff --git a/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md index 755849afde..f1aa3a243f 100644 --- a/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md +++ b/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md @@ -2,9 +2,9 @@ layout: layouts/article.html title: "SimpleX Chat v6.3: new user experience and safety in public groups" date: 2025-03-08 -# previewBody: blog_previews/20241210.html -# image: images/20241210-operators-1.png -draft: true +previewBody: blog_previews/20250308.html +image: images/20250308-captcha.png +imageBottom: true permalink: "/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html" --- @@ -12,19 +12,15 @@ permalink: "/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-publi **Published:** Mar 8, 2025 -**Please note**: v6.3 release for iOS is delayed, we are currently fixing the crashes on iOS 18 reported by the users - thank you! - -If you installed TestFlight release and it crashes for you, please install the current latest beta build 6.3 (265) - it is more stable than the removed builds 266-268. - -This post will be updated once iOS v6.3 is released. - **What's new in v6.3**: - [preventing spam and abuse in public groups](#preventing-spam-and-abuse-in-public-groups). - [group improvements](#group-improvements): [mention other members](#mention-other-members-and-get-notified-when-mentioned), [improved performance](#better-group-performance). -- [better-chat-navigation](#better-chat-navigation): [organize chats into lists](#organize-chats-into-lists) and [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages). -- [privacy and security improvements](#pri): +- [better chat navigation](#better-chat-navigation): [organize chats into lists](#organize-chats-into-lists) and [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages). +- [privacy and security improvements](#privacy-and-security-improvements): [chat retention period](#set-message-retention-period-in-chats) and [private media file names](#private-media-file-names). -The last but not the least - from v6.3 [server builds are now reproducible](#reproducible-server-builds). +Also, we added Catalan interface language to Android and desktop apps, thanks to [our users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat). + +The last but not the least - [server builds are now reproducible](#reproducible-server-builds). ## What's new in v6.3 @@ -36,10 +32,12 @@ The last but not the least - from v6.3 [server builds are now reproducible](#rep There is no built-in group discovery in SimpleX Chat apps. Instead, we offer an experimental chat bot that allows to submit and to discover public groups. Not so long ago, spammers started sending messages via bots attempting to disrupt these groups. -We released several changes to the groups directory to protect from spam attacks: +We released several changes to the groups directory to protect from spam attacks. **Optional captcha verification** + + Group owners can enable the requirement to pass captcha challenge before joining the group. Captcha is generated in the directory bot itself, without any 3rd party servers, and is sent to the joining member. The new member must reply with the text in the image to be accepted to the group. While not a perfect protection, this basic measure complicates programming automatic bots to join public groups. It also provides a foundation to implement "knocking" - a conversation with dedicated group admins prior to joining the group. We plan to release support for knocking in March. **Profanity filter for member names** @@ -54,13 +52,15 @@ Read more about [SimpleX group directory](../docs/DIRECTORY.md), how to submit y This release includes two new features to help group moderators. + + **Private reports** -Members in groups can bring specific messages and members to group moderators privately, even if the group does not allow direct messages. The simply need to choose report button on the message and choose the report reason. This report will be visible to all group owners and moderators, but not to other members. +Group members can privately bring to group moderators attention specific messages and members, even if the group does not allow direct messages. The simply need to choose report in the message context menu and choose the report reason. This report will be visible to all group owners and moderators, but not to other members. -Group moderators can see all member reports in a separate view, and quickly find the problematic messages, making moderation much easier in public groups. These reports are private to group, they are not sent to server operators. +Group moderators can see all member reports in a separate view, and quickly find the problematic messages, making moderation much easier in public groups. These reports are private to groups, they are not sent to server operators. -Please note, that for group listed in our group directory, the directory bot acts as admin, so it will receive all reports as well. +Please note: in the groups listed in our directory, the directory bot acts as admin, so it will receive all reports as well. **Acting on multiple members at once** @@ -73,37 +73,37 @@ The next version will also allow to remove members together with all messages th ## Group improvements -Abuse and attacks were not the only focus of this release – there are several features that increase value of the groups for the "good" users. - ### Mention other members and get notified when mentioned -This feature allows you to mention other members in the group in the usual way - type @ character, and choose the member you want to mention from the menu. Even that there is no user accounts and persistent identities we made it work by referencing members in additional message data by their random group ID that is also used for replies and all other interactions in the group. + -You can also now switch message notifications in the group to "mentions only" mode. You will be notified only when you are mentioned in a message or when somebody replies to your message (by including your message as a quote). Simply choose "Mute" in the context menu of the group in the list of chats to switch group notifications to "mentions only" mode. After that you can choose "Mute all" to disable all notifications, including mentions. +This feature allows you to mention other members in the group in the usual way - type `@` character, and choose the member you want to mention from the menu. Even that there is no user accounts and persistent identities we made it work by referencing members by their random group ID that is also used for replies and all other interactions in the group. + +You can also now switch message notifications in the group to "mentions only" mode. You will be notified only when you are mentioned in a message, or when somebody replies to your message. Simply choose "Mute" in the context menu of the group in the list of chats to switch group notifications to "mentions only" mode. After that you can choose "Mute all" to disable all notifications, including mentions. ### Better group performance **Send messages faster** -While we didn't reduce the required network traffic to send messages to large groups - your client still has to send message to each member individually - we fully redesigned the process of preparing the message to send, by reducing both storage and time required to put the message in the queue. - -Previously, while preparing to send a message the device had to store a fully encrypted message block to a database for each member, using ~17-20kb of storage for each member, which means that to send 1 message to a group with 1000 active members you devise was temporarily reserving 17-20 Mb (!) of storage. With this release the original message is stored only once, consuming exactly as much (or as little) space as its content, and the actual encryption happens right before sending. While preparing to send a message only headers and keys are prepared and stored, consuming ~200 bytes per message, or about 200kb for 1000 active members - reducing temporary storage required to send the message ~100x, and also reducing the time to prepare it. +We didn't reduce the required network traffic to send messages to large groups yet - your client still has to send message to each member individually. But we redesigned the process of sending a message, reducing temporary storage required to schedule the message for delivery by about 100x. This creates a significant storage saving - e.g, to send one message to a group of 1,000 members previously required ~20Mb, and now it is reduced to ~200kb. It also reduces the time and battery used to send a message. **Faster group deletion** -When you leave the group, the app preserves a copy of all your communications in the group. You can choose to keep it or to delete it completely. This final group deletion was very slow prior to this release - depending on the number of groups on your device it could sometimes take many minutes. +When you leave the group, the app preserves a copy of all your communications in the group. You can choose to keep it or to delete it completely. This final group deletion was very slow prior to this release - depending on the number of groups on your device it could sometimes take several minutes. -This release solved this problem reducing the time it takes to delete the group to seconds, and even in cases when the app is terminated half way - it is either rolled back or completes, but it cannot leave the group in a partially deleted state. It improves both user experience and privacy, as gives you better control over your data. +This release solved this problem – the time it takes to delete the group is reduced to seconds, and even in cases when the app is terminated half-way, it either rolls back or completes, but it cannot leave the group in a partially deleted state. It improves both user experience and privacy, as gives you better control over your data. ## Better chat navigation ### Organize chats into lists + + It is a common feature in many messengers – it helps organizing your conversations. -The lists also show when any chat in the list has messages. +The lists also show a blue mark when any chat in the list has new messages. -There are several preset lists: contacts, groups, private notes, favourite chats and also groups with member reports - the last list is automatically shown if members of any groups where you are the moderator or the owner sent private reports, until these reports are acted on or archived. +There are several preset lists: contacts, groups, private notes, business chats, favourite chats and also groups with member reports - the last list is automatically shown if members of any groups where you are the moderator or the owner sent private reports, until these reports are acted on or archived. ### Jump to found and forwarded messages @@ -115,20 +115,20 @@ You can also navigate from the forwarded message (or from the message saved to p ### Set message retention period in chats -Before this version, you could enable message retention period for all chats in your profile. While helpful in some cases, many of us have conversations that we want to keep for a long time, and some other conversations that are pure entertainment and you want to remove them quicker. +Before this version, you could enable message retention period for all chats in your profile. While helpful in some cases, many of us have conversations that we want to keep for a long time, and some other conversations that we want to remove quicker. -This version allows it - you can set different retention periods in different conversations. It can be 1 day, 1 week, 1 month or 1 year. We can add more or allow custom retention time in the future too, if users need it. +This version allows it - you can set different retention periods in different conversations. It can be 1 day, 1 week, 1 month or 1 year. We may allow custom retention time in the future. ### Private media file names -Previously there were scenarios, when original file names were preserved - specifically, when sending a video or when forwarding any media file. The latter was even worse, as media file usually is generated automatically, to include timestamp, and using the same name could have been used to correlate files between conversations, as one of our users pointed out. +Previously there were scenarios when original media file names were preserved - e.g., when sending a video file or when forwarding any media file. The latter problem was worse, as media file name is generated automatically, and includes timestamp. So the same name could have been used to correlate files between conversations, as one of our users pointed out. -This version fixes this problem - media file name is now changed when forwarding it to reflect the sending time, so no additional metadata is revealed. +This version fixes this problem - media file name is now changed when forwarding it to match the time of forwarding, so no additional metadata is revealed. Please also note: - the apps remove metadata from all static images, -- only iOS app removes metadata from videos, but android and desktop apps do not do it yet, -- animated images are sent as is, please pre-process them if you need to reduce metadata, +- iOS app removes metadata from videos, but android and desktop apps do not do it yet, +- animated images are sent as is, - other file types are sent as is, and their names are left unchanged - we believe that for ordinary files their name is part of their content. We plan further improvements to reduce metadata in files in the near future – please let us know what you believe is the most important to reduce first. @@ -147,7 +147,7 @@ This also allows us to sign releases, as we reproduce GitHub builds ourselves an You can reproduce our builds on Linux with x86 CPU in docker container - please follow the instructions [here](../docs/SERVER.md#reproduce-builds). -We are looking for support from open-source contributors or security researchers who would agree to publish their signature of our releases, having reproduced the builds. +We are looking for support from open-source contributors or security researchers who would also reproduce and sign our releases. **How to verify release signature** diff --git a/blog/README.md b/blog/README.md index 1432d95de5..00a84eca6c 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,25 @@ # Blog +Mar 3, 2025 [SimpleX Chat v6.3: new user experience and safety in public groups](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) + +What's new in v6.3: +- preventing spam and abuse in public groups. +- group improvements: mention other members and improved performance. +- better chat navigation: organize chats into lists and jump to found and forwarded messages. +- privacy and security improvements: chat retention period and private media file names. + +Also, we added Catalan interface language to Android and desktop apps, thanks to our users and Weblate. + +The last but not the least - server builds are now reproducible! + +-- + +Jan 14, 2025 [SimpleX network: large groups and privacy-preserving content moderation](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md) + +This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption. + +-- + Dec 10, 2024 [SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) - SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app to improve metadata privacy in SimpleX network. @@ -19,7 +39,7 @@ Nov 25, 2024 [Servers operated by Flux - true privacy and decentralization for a --- -Oct 14, 2024 [SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) +Oct 14, 2024 [SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) New security audit: Trail of Bits reviewed the cryptographic design of protocols used in SimpleX network and apps. diff --git a/blog/images/20250308-captcha.png b/blog/images/20250308-captcha.png new file mode 100644 index 0000000000000000000000000000000000000000..7f43b47bd135606eaaae39e7ab494e1974f0daa8 GIT binary patch literal 476245 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∨8>%CFK@5IEF9|wZH0v>1jbU@zM5ZmV?lb{Ae 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 literal 0 HcmV?d00001 diff --git a/blog/images/20250308-mentions.png b/blog/images/20250308-mentions.png new file mode 100644 index 0000000000000000000000000000000000000000..658c7e0011fd82b62a98cea0ba4b2b4a8f6f5cfa GIT binary patch literal 511264 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%0Af2^ccFsAs#zT!1xVTP_G%xZ1m-7rVxTm08PY2%c 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 z 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 ZWqnp&R3E~m-aUArqNt%zE%!e7{{WcJiFE(~ literal 0 HcmV?d00001 diff --git a/blog/images/20250308-lists.png b/blog/images/20250308-lists.png new file mode 100644 index 0000000000000000000000000000000000000000..cd15adbcbc3a5e020f2619b6685b199cb45de5cf GIT binary patch literal 252142 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

|NZ`ZweN^$5hRoCDUHh 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�Z#OY|2ZpV!%tbGLaOKW>@f2W|AFMme**5 zt&xtb6j0I|AUh<66!pooLf)j4-To-58YZiRkc!OwF>PFkJyf}P|8`}L69pAAt-K^C z2He>IC=-F4FzTpeND?uz&DZ#i-YHy|xTLo!4*Cr({!ZE`t+LGNB2JtUrgW4pnCJ(K ztx6LS)5f37ZFIHojfF;1^^-ac98m!E6RMKLLo(xZ8C)a2@=2oo(6DI6G6;ASg`|V#$=$~jS zvRH-V%yHD5E2XTyhhMl(z+&#B_u(g+&Q-@sI_W7;%?2c1x&`q+vR#tjUlWk+z#m;bJ=xY&XO;+f|?;>8(qsSQ{!Jf>J%z zxdBXYmqRRm1|u#}v+Y`%d8R!wPhV-qu_s5CAxW^bks!sGfw+h_U_6M>be5TltM=eP zMo6PTk+#JoB`(qurjYZM0!0AEcs(1K!VpsAnR-?#g{#L>8li;gM|ey}y3TZzMw9r9 zFml8^)0ycBLJWJBPHf}{Q5N8mvCW9BMl4`raTOIU!6Xqpk<|<)EXs^&;>6E%47p6< za(==Ii>5{zg9iZHgFFk98E_qfBsb8EI)AjEdQw4D9&5kI1QR;!H=C?5elVVtkNlKr zDv~hv1H|Lsy97@YEA>fW62^#F{uJ`GbC&#;2sCDE$hXRpe8gp+Mq%XDk2EgJ^)c-a zp{0J3bm(v%L)CB&8I5D9zf5$O8p*sA-RW!C$dHOwcJV4|iY<~d#su&uSWR#jc%tKS zhTM&F$w^BMG8H>=jwEn!m=5`UYki}k)%UEaW?cZM4?t<~+F?XkzqV(+3m11`gQ&fTADMe{8IntwbjtY1Up9h#-WN6;3o_Q@-N!l(-|@rB#q9m(Vs@ z6^Bxwv`!paWQb6M#L^-AJTGVib3{d5G2Ot{35tSEvjmtSZDQq*uOcO+Pb+0F_M8wZt17Esai<5dlV8 z(OYQ}e+^#O@44zo=?Y1cvL-QymB^T&jUDzU0x3`|KIIwnN1GLsM&*ihDwlXjX8{1C zmkM%)=OJ2v;bDi?b3T+}JjN%oT zmbyqE;iPS%A$^pmyyzK681o8(H}O+fw?LjeG{HoZ_cu+nO|TL1qY#wIYn#^$)AqxG zFlIW+Ndg8UGNkbUY}a4Hi3Bn4hdfqq$PJj}xLir^kPWIBT#{3fhgVm{O8_adP$S52 zvR&X>`JGYhit9GkY3eu$9lf}-{H%izY8i(Mq&QHQ$!@rk2#HcZ7|3J4GLZ<#kWPc> z391=No)9F0G)jv^F=eue_4oJLve?d!ekebOL2fTe@pIKcZhpC8Lz;pOX8QmX)3UeW zyU0EF+PiNiR)v8T``mTc{j+a6RVb z(E0Xjwa-4#d;s%<^xt0b&ZMNIwcNU?A#}6e(l< za>b&FhEk;9cKr%K2i65eF(M^ZZ=s1hmr~JGsY*IwKfWnvS^{wR+vU zd40u>$#jnK^(sE_#1^Ocz|%*?@j3uw=zI#{#Qo~S&yi2h;nbC_*h=+`GtVsM&YfFq z+YXs_ap4T|jPlnKXgF^cO||McXKcc^h)fbI)l@$;en|#bjd|!&RD5!FsRmaViF!~~ zmnQ~xR#jRhsp2}9;Nnnz$T7u22&0;YS|%Z-OmUO4Zn!4;VZx{;{@nhkJf@?Pj*=!M z>GML1uTvHwDH&3(mMpAh|?!AsYfp3S~$qt4t%Ni7MiA zZb=thPPVT1nD;a_)Prd2P)Jr~)DfPOm}qb`G>1nS;w4OUF&1qsT+SmJ@hP9ja_H#f zl@EbYFc_ljKL-9-kF?*)mD{G)emLcG4MNgpLxZZk9edF^wJL; zNswc{8P4~1_>gPwzrPemANGN557^2qnA_erXU^Q6_?B|~#J;|L6Ol|Td4`)9b;o@C zwr$7a1WlYhd+!}nrcUkKj78eHX1QJw@GS5Z0jIERz!M3BDr2~XARj*@WjNx9BW?Y1 z&OhoF)HWiGV6k@9C`i|YkNktBC5!SUYcQS;%HA0}L_4cL?bpPpzy$KAe20WiK%G=l zsiR7Fl%b0PsjtiJ)GO&77$C^o$}a^SjDI*lEQ`!*wZCR&Ued@F(>11!Oie#BJa^6= zF_8^$p~_woc&Q{T!b@5b;AeCM3K3G=Os}^Q7kyA1*>zaM0z0iM?1GNjxM@{C@+`e z3fi=nQ8W+rLyBY$eQ>R}8Gs0RtD=p0UA3gS@g>K)7LijwhX zy2R6k-&Eot+ho6q7kn`LMMoy;V$7dH)K8dbgb#5}8c=P?$Rf$L;x{mgCtBG+))Ppw zNNlGWaijjA%y`6@Vr2taO+WGc5ieRU@PU<4iX)EnZ!jlK@&!O@jKj#RU%$SXJb5y{ zkHma)_>yW*968^xeuI5zI(MIac8tS*nwV!kKdERleaIG!x?_%qiUkuUjKd=Avv*7y zn1oSrjE>vZrml(wE!ESFGZ7!}7HmB?_|;fLF{sddM^v%Un;2_|nm;V~_oVl@uQ zAbhakw60D3j2+Sgw=v;Rj=~JaQ^!~O*>!ohSn8J%BpOPg>MBAm3M6<`ga-MQL}{ua z+cGL&S@1wOBte016q2f)!A%f_Vk(d6!Hj99bvM!5h1V=CYNqX~fxU7gTPmRqsgQ<0 zoeWAOQzK)kPIV+|S=gOR?BbRl#?n7DNs?H`Vi!QFa=76u{X+L73`MR7_ij$#OtD8<6(dD2FDr4{G&lG7HG zkHO-|EQF)0BWa`zvB)gm4g7XJ2Bl1v-c`_FCPm8B$+vp+Ezxp)XeT6IA%($eM}8Gnb3k`;^|#iXPs z>s@j!)2WO+BpHOPld%EPlYS{m#Em?`o;Ez;6vf!mIB#4;CSp&X{)zpZ(=w`IG#zuR`tkh}=1#&B z%~+rbHwe%m-M9yt*nHN|A**j!hX)S$c#6b&Iwu7UZ z9+g8hhUu8Ea?v~$hw3A|&gr}n=6v3MWV-qo<~2Gh+=${_aW0JbNTal38mq!J#gR^F z#!8DP>Kzm&W- vPU#2HklGE-0t8{I;fgAQBy7b%quNh5X;N}GFW*N7Ei@b2-!>x z4jL><3V{&?g^HhP2Ii|)#b*lfSC4&z^P4ChXoP|s73xq~6^jNjb<*!3tLv|ep+yyh z={z*Sa+>aWnH}{D)}6DmuHF8@B!tpIh)O%C{;b%!e#=8O$uiPLgXl6n{Q|UK8xtj*mj|N`Muqr<9(p&Inmq zTeogC=$?2Wu+VW<6ChP^CZfznHjxUZn8Z1bu!v{AGl+|{{PR#jyU7q0V(Tapx4@ms zGKdHnLpMe*O#-szAUx8tDPu0P?Rd!-sq0a$j00JeC2GO~Ny%0wx+0tHk9tbsQmB{; z29aIbunQ70qh@P|o@k)bO&m?IBus2<1Eu_QgDGJZrX*1!N0stKKtLj@(I!HRfMBCz zI}YTMkjIHL2TO~=C8nmb#u25ED*kGOy=N#wipiz4L`$XerDO;27(&2%D#6YhB_xytm6Rh$$Y90YL`A)%*N;3*Gbql} z%twCaV<`CoQ3*?gLMiTvKL`<@;@!d@lu)nCVIxpOh;Wh%E?UN<$>?HM_n)dAQ4oWK zc+QSwOrgXg;qofC+(}9bCm=BJ8x8Fx8R4>B0K)+xIO*7D;h#OG-G+fH?KirpfDEG? zg7{Lp+2h41VYyoIrI?XuOZ#)4ro7|?ey@)n^h;qTm-=N-3rWerhY$;jnE}&Y$?w~O z9Xo9WK&Kkk(Z7d;Sj_6!g4rIM@tq`L=CtD+^P5Sg;p2{*K4T9|%ieJ;S0jWn`oPh$ zVd*E+XgTIquV>i9laT%J88#iOpzCT=yz*m9P%2=VF1fLX}z3kkf&@P95quxMuxzO^G!ClLDEk zScIj1vH;0^Y}-MSk~J0p^StP&O6o|XpW~aj#LGq{%qNZFFsL@hNVk zYeT|7p)9V|2qY;lqFneN5kF01BjC@{9+gMIO8^oY(k)>suK>z-DZfaKMDzlBp1l@R zB#A$(Ux{YS`=><6Ub9g|LXs9gojj%^uW-5_Q&wOlr-w$MkVA`QVgr|%2RKRM&q&3m zxPiYXP#6B5^amxms9tJGw+e|N55HHOxhIz{EU+oiDlC=lvnLENjDvjA?HD|M-~ldD zvI(c1J}lZ79CUs&N!;}5Gy1r|2^{$OpZ)A-N4AK1X6I-*<|g*)tFN8{`}drVh1cPj z1LpI{y`U%*kX(&rGYZCe=c5(+`}=Jn_~VX04vsmVVBnamktmj~yfFx#$Kq2geB>vN zK`_M*&5zAkLYpv>T}mm5QliY46qe}VtD}(c zcy0@+^PhNRz4C2w8-E*pbBEeWy~_|RU$gDRsPr*S>ZTB-5HH>)TH$>})o*a_!nH|n zlTyN^PH~Aqfo2p6I1nDl;qMPeI>Q#WS25Q zD`&kVl}+L5rIVKM=CGCgB~wVW=noX5m82$>TXB>fEh+6!Jdtj=>EY_9zGz#Me%dSf zB{6(T`g=3?m{A;m;)z&3aa%DS{@_Rg&nE+yOr*cM5sQ_`S!cWz{_6t|Y%Qi?0D9)k zSr}K^G5MZ*?wK|cojLEQI_40LVe-Bybj&gLrf)lHk83{a)dCB(dGlsl){yIrGpG_Q zGt9#TOvGn6c;TVN)Tz^OqENBW)fAecO_a?r@ekIB>fB{>GO%QbhWR1MW5daXNLWvC zT^pZ5JNlv^r%qiV+TcSst}0BGs`SLvVE9PebD9NFVHCG8kB0tS2J=#z<0>$fu9W-2Pmu5#^iyJ1@R&Fo~m8lK)!wy;P`lT~C%(nQunoO4J@4j+an zu~4MMCK$7M$VhVZDCDD7mQQD!DNoCWZl#wLk8?_4X|8aJ2_rtz5H^Y-s%jG}^@>H8 z=LOGW(J2-_&kJ70i9q+H8a)iphXHdw<^Lr3wjE+S`8%O? zes}X-atFWak2=iE6qoSCm*-2Hg8ig18Gs>eNwTXyGf}zyN&X0P58*4 z(>LXF8IeE7<@`$THXKZ(Q95F3H`)tH)aqN(C~b0klMxQuwL zICoVl*Kd6hfM$i8;;K_Rg2b!1(?(G<)~}PQ2_nvjYl21mVCkOtSr`>4GQ>1Uvg}~0 z8}XDORV0WqW4e*K1V#xZu%|?&-wW+J<59AEx29R~ApBB8W;!QwP~y$4K?FgOHIl@1 zvrRcvk|dS+p-VA?$eOc5RGT!qBmS4$G&=c8uN)sQs~=qlT@BJzv~}H)R+yaD^MW$2 z^aPhBK_%r65JwZEdch-%X%-_^fany9KFz||K`=$>-C#Heg$TO6*d{Pb~ z(-GfE6!0)SzCryjhNFg zgC#vJ237Xg?P(#e6O}npGv<#{&47}kr-WEZoU;QLi{tWG>rUvJnI8Q>*$SSP9_Bn9 zA)<}3@Px%Q^HF}Db9$DuqRRP6(<#-VBR*DYhcAi-HyhoX%ntrs*dRu-Q&z`p8p%%{ z@hNUG=TCs&OVC*khvXGlm7girL(X59)W)y+O4LT?5*-uy>Jr5%x)NKSCx`ek%BgdD zuHTtc4jDg?+j`Zl=a4Ml&GtTukhZ{i_NaPlk7YlH)`^XB=WwFYV>Yq;?i zI_4Nm=7_m1zn$(?9HCE+c`;+gjGY*Erf>U4Ibv>QN9D|d4_?feK4S-;COF*Bxj1yl z6$smAlj?mH6+N8@Kb&|%np1?GnC!`kGIinylh-RQ#qTEbuyB!%}FKQcTT)x|&vqo6E-nx)p>FQj_$$SZ5o@v25 z7QCcsOsv55xbn;0~FEWNolPXyCZ8tRN;{zhq8oGD=q8$2ZD-B5Hjgpie zylf?<#3^oJb$VXuZn3U@&$Td(7%b_ES=F?_tszu)QB_4%R~+Fn?Nq9XN^YH-InhK+ zr=*DOIh~7XQq{AtT}9Z<$cT&SXba&img<-T+pl(5aWJKpNty5f#N4caV?LdUY15|_69*>R zGBevTd0v&srw+ar7(Z@&xxC@X%m+8l(zx^$E;cnvQ(63Es}V#UIV?7)NXK+e+XU;z zpUZ1*u9Q$okxoLCF69^*4u_Gp6YPM zm^-D66To1Z%#_pNiE6C!4_@SP`BIsap=-+mlvpB$C0m9Qt(cFmLP{=@Hq$UQy)8?r?H2=~|O~z>fPEI~l`8MZuXOHeVjim67yQQ<;1$6T@z~fJo(209@h$ zF1|-xK0bgi6-$AGmzQ}em;F>daq@ux{=A$rM0DcR!vvvl3~uZC2)Ibz6|x-UBZY2Q zI)ZLdgM5`0`R|fEfX|tb2a!NYgX>JY=o~j?Mf&|=z|p#SUVnG8t!+wOGd`q=q_2O*)fubF zD!xe#%+VOL7?|@7*$VQ$-fkF#pGj*oNbl_I@>%V!&Q5PUHp0OqzEIkWMq+AdY4)cl z**h4$>xUA9yaw$+= zOGUmiS!p!6TKdL;r-{%k)k6OR=Btgyrc5Rf9BZ3RkK<7M^ksPuhBb5m{uu71voX3!DBy|PtqZWfD zX=F*T@^Ch?Qg(LAl#@^1XLRXhKUYnSb!RdG3iYp+De{e2E;CS6nYmb2f!gYeddeH1 zwmd2FPr8)5l!!G{IB&$mu`-KDBIT4PUru>a9FIKp6p{i_A0((N(nr?RY=SS4)7fbE zjYR!PSY8uf*7L*)W7$leP~=F~3Zu4PEEep$DmA0WCMbyHIS)u`5AcYcXt5IZf>X)C zzeJWaPsOtE`8?JyN*PYHG#?X_+CNC;{7)5)48&$6ubPo2x^P8pq7yIqn5RgOX(3V$ z94+{;C6fouTvL4ziE(8=R(s&%L6{xy3p@R}K6nV#Tw_k44PX9jZfU{gP??{Y3sdU{ zVVk0p6_|UQ<0a4OT29q&7iW9Mlz#4}g zv7*end{HhPd5*kc@#L`wS&Eg$uEy{|nkW-jy_IM6j=AN8xI|;Qj22f$yvoHN%F-A@ z@enBHrDGe?QI=CGue*#Zhw}g@t1XDD`e4wkdOPJFyq6 zVpqiWrlw{*MQ}+8XUxM3o@8UC_!9#c((4A!Dq2u9jw06+arT`-Ik#x!PY)xt%1E@a zm@Sjng_D;jR@~xXIp9U1OiX2Y0NWl20-TZwacXMP*r0EO0cZPBUoZT$5Jlu*Ca@FseByd#yX4gQT>yT{tCJB0Scw{xr!OQ^d#JMfNUvqUG9>pU}_ zu^|TFOr#B9QxjgfoTYEUj5)AVfB*a6^R3fC@*HfMtiarx3FA&%6ZRCx)V{YL ze^TI9iAoI2<82aEdC@15bM~ES<*HTwvqD%46lR2i<@hof%Ow!HAL`6JXQ_57=L=XJ zGcWQ3afZ+I%ganJp$JxB1 z6jJ+6NWnO4C9L6!kg+VF$pS0Ulv7Z(Bpl;aRwY=DQ<1kQ9owgz^px3)bqP-RS#*)n zuwyoQ?;r*5a=uk2H!RZyKC10(S*NGAl z987&M4pqzXI1A1g=zJL+yHeq6bTfQ!U4C$_r4{eo@F!8|2r6>YwcMC6K4@3{cqIvz zN^)f9T3F_RQhqv$UQ;gva0TIUIl$7GSrBiA84aTEMawm0#8#|stfEMZRmJjz%Xo{p zn6C&+xA4k^XYRN}DK8FQXMU}L=IDY@(SA6y41;q1%*zT*R;uuIT%_4}BR5`S9!_ZP zbb>3KMCRo~lB*i=LJ1t@r=T&|uMYQ8gRz67 zD;)<%QL)O^Do81>f1Y%ytIHK+AeT$4i=%Jx_gk=3Zqt)b*u8h% zZNLBhAMB9_*V>k~>+SCC>vFPBcj44iPqXu%^BlYIdC#-sj$7l~xelOHk0&giE+5n` z`Cce%Ptm>IA}3T0oTdh&84O zr(7naN3zPrSIk9M9?O%LcqZA7bK)h(ppN6B#>Ny+St*r(@))1soEO4=Zvp3eVm>06 z5(A#$kQ9S0mMN3q>AV+FR_;1#99WlFCWa}Gvidx7rgjoqaE5qCL^*LmBIJS2qx48~ zsL$Bs-Hf}8L#_66Bc#&@vY`g3XRsS^qWP0I^^oWc5)!v7qn1RFe4x4Q2$$zbxv51O zq%l*Jm=se?h~SarBetgvR4r@yFS_z-NAi+9ADF!!#$*WBc;lNt2N#%UF)Eg_;4AeE z#Cg7%6w7iFnA?IgIL9K!#?H=8PMvo+(?K34BQVz`0Jn#yHsJ+KO!?!s9>#1{b!CPI zLE((mBqa{QOjEH%BftKn`yr@LT|+TG#R`AuWpW`0fiWL-S;X?OHNjG-axo{JxG^Uw zhz*Y_oDG+#P+$68}}Y`Udw{E{X5r9?_gSe{N)Fk1{(=v16k zj-4#S#WF=$70u&iIb0>A$p&-D$d+Wq!*WcE@n!Pj1ub0zn&7FjI=xqc9zSRTTQJ_AFT z1wnVxi&^%*z9H+!5|lpB_;V&Z7S48ij{(1Fa}Z_D$h)3o?KvkO*pYLlr5kG)Y)NYYL$=ZdG+C6LEfDL`2sIksiD#jBQEyxw6 zc|`>~@iFMo)mGFPm#KXMNlr;rZb==>!J)t*I!l73LuKVbqWUPha&pLC%q^U9(Im%# zD^4nUE~m&qL{SOx;z{zc$~zwp;+Lw_1_{t9lgjCD5~xrlKk$-CGSn?Nph@FW5SjEV zLIvuosw4G@fjP@r3Z?9tbKbOXA>YXIuF3f$8*WB&$}?4tNzR!EY~CKODKO>Q2HX_j zpC=+nJ~M=NOe@0|s7TQnaLI!^=qHOd>;#j4e$|P~PHARI zCpWrRGUB+PqV|!>q80Pn2E`e16x&YKj96kYZj%yifmMVWxM9%~hx4-$kO z$<|nSMv`}$l+>vIsPYHNpq2@4{a+H9bA z!0NCHaL?}Dwsp^T+dq5&!8KZdF*06RAHY|M`+Em%7{PTNf^GE3HjGY~Yt$KZbNp_^ zhb8#g=kC59YsOEe^^NSf!Gno`A;A2A8vUC!ZJI4yFyGfE@(Xew`S6Eq+mm~ABtPl7szk>KE5$Hz zWo604x#Y*1%YM;DtTSgD1)q_IK+F@{i*=562Ar3Qn3CLaQX*R5NO@Eg8_Q`#0~v}* zcZyGhi1(B*rDXwGP z0y$iyGXbTYtO14EcwH#S1Z=_^0O13bM>c633yFhb5)HUe5?#6L&_Mz$k0Fs=ddk~> z3`)kq61zvcahZ;=9a4WHy~0JP0LBD1B>&f8;@axCOrr(rXd>pSQZW)jF{_rba36E^ z)y#c~3;Own#zt$zvPEE;1}a1CBk3FuZPEhsZB0!L!#3E?1@e4~;5yDh4sJ=?urmFe znZoQ7Uxu2jRG+LRnt~TU`SUQ(MmQtIbHXwyOv*zkJ_gwsGd?($mj^L!G;M)bzOqDb z)ru&HB<0GTv;0tMKh`6LG3Y9{Fj1@whZ?GDm20J1M!u0QzXEN>r%UPC~C< zK~TKw!F9Io+dsF#+4$(gjH!nCaO)i!uwHy1q7m*e*zWH=VEvdyZ?5#&oYs14t829G z3i=}i?gQwX`VqKK8EnUAn8AlYym7G6XUvBN(C6cw87{@+0Ne|DUmyB2{9`-vRT{?j z{CJwLZV#h`uTLDnl0L3KJ@=e*?92c4C7UsGrv2@^{{idHTk#DB*aq$CPO^}lbz<@j zpDzWf{7Sh2#K8pV*>#j@2u5>5l0%R#q0imNRJsB=X{m@xm!D z(&SrNP)=So4*A4ermILx%Q7hWl#r^H38_t^!_+=0lmkNQoR&y<1Vx09$emr~girD; zisbGD-HZoJ!nW3ZjVtv3QWi z;EnZAKwfnpsNOkSR188=|DZP*3-u3L5-yjF;na_u&a9B*&Csdek`FBv(7YTe$G@jN z$#854hGVo~=1lZUu$R1mgRVv9NLaoySgF);jrX*S-$@fb_zV2}H5Fq4QwZ4c1b`TJ z5Pyvb%v=1W(BY=aKssYSVS5ssw7`7U{{5A0Smx7+*>Ws`$7It`s7y5I=*272nDkU4 zXC{(sjro)*Q*mOFlSSP*WxUvVs&u+UWm#|#C~`=R@G95J#h2Gwi@O>h&Vo^ymMh9>T(s2B8Xk!+mMvu!19J+gECr=PPcJf<*14xc z!KrZV%C?-9l3apT)+*qWz z1mpubW~_TR?z9<8me|6RkFb{6Gi~E-4_N1u+icOoB{tMju^pQ?*tT8!Y}@Ab)`)Fe zn-LSUFJiFXfna?vf^2^4&A*CJSMN(#8ZlGO8S5!So;4Z|9wRu6_LDAG1?WJI$VZ(ZxRVGLb5mw897| zS_mf~*b0w+MZ4lsE`@Q~$HG)w{?@5F#mUKx3LMsX)S^vYbc-GkPrVV={Qjp5hZ7P9vNo z2g#V=oGv1B&{AB)7qbX<6(zbUFJA;p#&gP$L0MFGWGIKLI^}c<58kt-^$eJpYbco@ zs7JfgJpcHhO^eAYHg6`v)D`T=A0iXxv@KB1CGsXX@+vu)$uq#GF2zNZs!FBEj7!?d zCfKQxQ=W+UgcmaAPF`b;7Vl>XD58Fvr1@u2%Q7YjMvwPSk`UO zPncrAP8zod=ZpR*HzF2|m$Hn{V1awm0&{$#w1O!q_}J*%lhJX0k<)|av@&6)T$L{P zGNGc@m~*Q{Z1V0pvePA%!i1p5Z&bfLYM%P^B9R8=x+YYvmCIt0ez5YXzEKsa1EEAM zZ&Gc6g*r#o8_7%}0WA ztf|V%$s>J)%TmTW8fTewwGZHmU5;TYpgG~(bY_24S1BJS=waa7w|Ae-S-QlxuG+lu z2|H~53~OI7$2zum+4RF!SpBqC8*FU0!;e47dJc5io(CVZy^lR=gT4C^)c5&X^Zv@P zb;FenJGR*akAXfsVE1g>Xd7dDfTv-5tZD77K3md=B`pmI-nAto1_$4jg-h%(d*dBA zxA0;=#XGVOGwr7zf1F+Uop0O8&pg#;%$O0DzQhA|!QR0a)!5Xe@P(%!YWKtlF)=sM z<>kOh8Z{PqD5n%?;;6PMz6s~SiQs6gUieyyW8PXkBjF{ls)=_ryW&$`CNG-jBQeN{ zoIs&aluyWh&4K1*@g(Kk^(Y(U5iAod3tyy(N4dsdiZBQc{EP>rMV$Fuz6l$b^ER{o zQ@qS${!k`{aWLKzwAM!aijQ(PRwoY))L9o6LmU9yfN)|e_SxWC0IX26HvIJuV2SQf z2%dRIQ@7U&b)i1D#eB5(BeQst`X|y=Elj+Ut5#uzJl`rAV=ONo;h6t#C;2j>A8NM( z6RxL0EE)8`e6s4o;G4P_&&Jo0ul(?!y}`F=1hHxDmf|s`?hG*)QSc8ywPbZ+?-|lLr7jm87oc~bCKU`h9_@qH1AK@ zymrcX%W!#4YAWZ-*&LOH?LbYVGRD_i&cx&w=nK87UB}V99gRLApXtsRlWH(V_ENT@ z4waeX3ie|Vj43fbRx7;nm?oHdvLvm-sLVXiDUM0b>8g_47H$j<$X{210w(}ku!C$z z&pzwexW(!^yKQi9zqMnhruIen((sIFwg=xjXqr0P+GfuK|4y5F*lL@xVwH7o-)^1j z*4e<`omStE&bO(-R-LrU&O7NW+r4YM?e6NZT?pj&;cw^u4%^(d&z{=7!yekT+xGi# zu8QPy_RyRrPExhQ;Grm-V+XNkXFi##N z)-O#3|C5U5u~|@0eCR)=AkUc?@_VuYBs?$M{=Q**U_-?o-dtxl;&1crI@^X%t*k@n zG6Os-5SY)HGHfT#AGQ+~nXO(lWQ%45n^^Vc9&wCSj1$_GcH@-QpIG-Qsy`8WIPqEY zRMa`sl4NrTA>(GNV#p9h&&Da9DLM$KSSyv8m-(mj=DFKdduC2B_QQkHASOUOFo&LM zri^{ekTxpeFP`$w<4gqV0~mMMr(=!zFeYIzKlJ}$VBXNs)reNDa9Y2fKm8&I=YW|d zY?p{Np1c4jbB%fW@+UrOT z?bwblqP1G{!r7PxZ?`t`U`D$h(`Ebxt`&XReC*;f*oS@#`2G8LTG#M?>lxZts13fln>I^45^w2|g z3YN5F1CAsnCs#Z1n#tgNQ+rU6nyWqu@hK7;b;T)^V{Fh9jJ$6&Gbc`2GG$2~39FnOqGjBXJcu;Ghj`A4-}*?!zIRW>zPkZD+~kho z3TVcsYD`&np!^i^8!Psc2O)?cefivqyX4T_fF z$>LtgH9jDb2fU(XD)^|OJ1E2EAPSYG#3ff+La!xO!aAua)lv49(}-xXK|QpjL|!>4xH&|a+=?bzDL#%9^Fk@KS=U1QOao%XH@C+YeNu>$!YAAf;Hbw zEjE1_0_>@+*1KtkZNBq%>t6qe_04@|A4Pt_l8`L9buRrc;J4#PuiPo^U99DMrECQ{D)Xu zfa5bA8eACC1;>_RTnv*ZFOx5kUJNIS^5hv)e-{{QA7whu#Z8=&U^*gLxp?Fx@q$Gw z%OxM{EaMvuCtI?s+~q`!jR+R4#DE5%lq6m$NA58V7|81ZWXwZB@;+UOS4c9)o%$Ml z3rG1P`z!~46vAd?KDH+xg3fZTT$2 z_IG?)4qI8^mh9=Rv(BzMyJJh8{qo5=yKlSM`yl(idn@+2XAap*P8_l+&A9imvlExD z>7q&NRS3%Cnvgf;OX?p+Yx!MPCuv}w*^99$EHx>j8mOhA7QR};#4vKLXouxu%5ac! z!3>k~qMb1>Jc!^Nzju%=y`WN-i2!`TlbW~@KrjwF$vNdR7fuXgi$>6}Y#W<3PQ*r; z%wz=S-nRAiz1TBmsM3g^YPoG0g7gr8v%!*zC=l1r17!}47spw0Cf~BAr^HB`y{e24|CQ2{c}D zsz3}E#Ku^phD=|HkeWfJRF(@RA`?f&%4Lw+!ea@cB8PCyM@q4_k@%`;u|X1xTgSa?t^1Kjt#{uR+uzk`b@Lb4EUZcI*t6T_uQ=Lf zE?8z2yg=ICI@P8f+FHWsMy;rA@iyi{&!Bw2Bt`FX_$h>x@n)KH;KNYZ9eq z9y=)U7*}Q{R#uczSv>JgAy_o!u|48cF6t70RW5-lLZ+*VRFqLgi%u5#q)2%1b4>Qw zg*YT9CHR1O+K+rD7{g>bM9kk^$@qP+|{1BYS)`JhF$E5!BId}~O0NJLDY7^vH%IZa!r94mI9LrJK zVtHwoUX{n9Z*(46%s6F-7{Rg``xPV-d4HIcuE513#)3h-+2dc{4A>wwk)vW&&X_ZP z4HM1Olz4}XI0oi?zsM=W$=5Euba02s2+Z9Bd}N~ujZw!Lb2_UT^Wbo(3@gh@`Ug7> z&hJ!OL4QL%R?<*6C^{)IEP;R{hoG2STp_P54`~olO*i)6w=~<|-PB}Jl#2JlmegNVqWCUDJ>F<<)X5upbPdnqq|6o(*&%+C(op$H%ueN!!m)VJ@UtrVb&Ba6`ZglKp-nnyw-F^EF zHt*0Q5Ue-a-t8N$v!lb$3J3XVShGH*x!n$(HQW2dfqrbW)zD(EJamn<9I^@z?d(L5 z)Gnb^;~1Zz8Z4#LBtKsU3<%kJkN4S~;O%^1Z zmzYpVm8c~=8of+TZWd{=+u|WE#usIBWirNsfOE1jU4mo4m9a=XK0B!`03HP*Cl2>h z-mg@61c3FR+}Kje?Sn-$N`XLH9F=<6)k=Sc3DzQciaJu)Ti_s$(iQXvU%$0tZ@D(y zZxS3<0Oqz2+a+rT?OCgaJiveJ%@zAT zX5DYwS8qSR6v6%6Ve9FGPx$qG)HX@#pLq=tCO;oRI@E{A;01Y$nnfX(vt>e#Djr6X zFNzk+MP1`BkFHT1tQX->Kh+ZT2dGjE(HC64Lzpq=Zy?FFK7KczV(l4PbrE@Zd0(vS1%yM63h&NjD3?LsN1AJt1x>Hd!fj`uOQO z+4UehX~okbQHSWd=c+$gY5oPei-KBsXig7YoAY#P4Ows6iu zt7~huIfpF7J&3P&VupHe?>@76)2)JR3f7Vjw>H_%$2QrTXFne^;BEHA!w=ejzTs6q zk+$ft*>=LSUuH{J9D{zW&u;qTZT7S4Znh79@=tBshndf6vP{&DM!3=(o`lKC&`mHwkQ5rM=V!_oiA1ZSVaZK z@rgQ$#gT=2q%b!afEy zsp5hMs11D{dlEl${xL~jG_?Y0>hqnex{)D@PMLf>OhWR>WLz_vLDz}pEyS- z2IicktsBNiF(xTZT43HVG}O?G1@Qbr9s~1XY&FIPPA@z<>!1)p{31KC2GGY_RqYY8+G%Sg0D6;CDN zBHNC2cFF8Dp-R zioIASUgpJ9JC|?L66dw04u+ERt!O@0ELUP&3`;h~<&=XkhBhuU<7%A1&aq-Gcu{kI z1$)cRz&kF0+h_nrgN+8~8@bq=oE)h`l)-Hs`7t()Wqi+v>n8buBbNc8 z*v@_YFj8WkTRu`a@CZv{CZ&ES_PEH&_4!cu+W21nWCzc%%rdcKl$K^6OaOaP5 zUHqcPcOsKLK(&;ulJJDiI;r+2-s=wA65fbKa<2B_G+BJ+f2I zQ?4Lnl#8AUM6?=UjurA!K_D5Q-ED}6oa1?5(~$swK!CrRgBl7e#c;rJtZu>T(%T-d z*k5w_3jV+Mgkk%e3kIwi3Vi7aaz3B(KJkeqh;GF=z8QaQkX<^1Ysfj99s+0v?i|+{ z0GzpU9{Yin(y2~@k(j3$@;^pFEUfVul#Smwfy%JC%re>r203(F@%>XW4`e2dke zHvFnf>YTLlq|TUoM;AKzFl>~xZ*mU3U2#a<$UEg%aC})+pIYAN43_bphyBO(E%wnn z8|_dmUtwZU{+<*1?d|9E7`|nK9ccVqB{r1hEf+lz7d7&!eOp(>?p=?4;loLQ^EDSpR`TLJ-DeU#F?`GgO`d8L_rDSt+;1<4(mRhj%!8JiTJ zXnBW3EozYl<+Z%2jhANS;>`RI26=2=uuQ95R3!0Qc+qR*5|ete$v4NLhvXMOyZ7$4 z?Q0*iwgt0n`l313-_vdTH*T}@`P51uvRUfTcoqt>%~i#4}Twa%@**1xO6mYx1Q zn=xlDz9`gV^A^mrDVQCKc;7Hy6YA@>6L$N&p7!^>%f}xAN}~d zcKeOj+xZti-!^Y}!cKeU#kOGH0?fR1*e|dDjU9i&IauGm$R6DExNU&_LzXV{FOW9i zV?>4>VCpf$vUA5~+ws(6w&3`aeWot1UyY_113DiT`#_I=qZMBl?Z*1{b?YCs?bsT! z2X*C7v)n)Z(8CVHuLaY>5*q~R`sM`lUIgWT`_8x9Q%^o=ZP@YZ-#+_)Yzgbq-;Xg! zXGUkEyyuPU#h-%P+FI<|Yp$`^{l%Z#;>C;Y^>2KW{mCo-%y;W5*HBVznIuUDVq7h_ zsEV@W6@RQtuw>%0{Ho zl=L@gJXQ*SECpJ@lgTPZCR3H(Y@y^gfqY6C2qb>ya9M#jByaQ{+|ZxBw%I=R$9z!! z!IS&#Eob$F^=h~&PgSgv-g%u(T#k7&M>82eMoAY|;kVC7Px za+wTqq{m5>F5+XJ2#(cD)N}Nb{n##%Gs08w(Vv~azuS8D?7-HDSZ-1`XuXXs)-r3Z z?R;tzmYOtMGj>wx#tWYfbLQKjM;?h;vrfD1+FwJq30o{aW>4L8kDdFLPuR4D3vE4? zusnL(P4h#UCEjw^oz}4a7F%%iQCYM2Jv+w6 zh}tE9c{z5q70g7lzr7I;5kL6e_w2vE^u;QvGtYXqz2+}oYZpKN1vYK^bmI)U2RBKO zz+GeRyyFg+`}imS%>xo%#qqg_mGXQjmGMw*56;ylJT&p|r7wFqTnzdoH)0-FGdAWI zLWyK8JXvF1DwDq`qlzdeFH_7XUBoff?0?1JOn!cx-_a4RsLz?ALvns1OaLPlY4n)( zIx#m4@FMY~oCUxv%D&UbdKjBvPPLm3Hi`EMbH~wU;BW&irA6?ZK`q46o*4eC0k$ z*I@V6YkpX<&s<>k+S5XCp4tZwcWOQ3@FDx;Gw}d$L&aWuTg6UWIgFWhENQ`5%u`h> zd41rAyG=e-)&+uKEKdHwlCOWM{+zCi!}XQWN>Hg6YIh>a;22j+k30~s`;j1p6HlBM z`Mm5#AmxoqX&_QY-Fzdpd~9K4n7=6cb%NNo=qG2)X_&wKaWVsa+4PaMjh#AafqC7) zKnpy>Hj<1O@r04PNrFSH82_}8-e+|JX>yJ{Ir#Gi)VX3oCryMAttCrGAf%h0%SAXV zd;mAvr><_XPv6_%Gv%z}`p!E0r&IgwO=tArH8MP|A%bU6p5DA}OAd@^I5whV7XMc;9*Jh;1J|MpnD{bEC%{o;xiyW{0OcH}|?BbXiV z0X8w`)lOhn3>!5)O6)rWV3&;)gv(t_P@2%h5`LtXv0_fHnwD!*Qge_OTuTk`VOc;j z;KwEfE2n$4^o&T86J#1WNquZGmMFs$#}s7CI0AjLj`GpOGQ_Y1i4$Hp z@|LBs@+GoTM=`;%TJ|9gtT_-po&_x+8X2&L>*&;5sw*wR(USr0xYbluOsYv)|@ zC$@0*V%vxP&u_Z+XLiYjueOtpJjNb;@D7_XcaF`TInR1=(a&G7!Y-J*5X-t6?U5(e z+P4vuzvQ%&?T|wbwL1Jp{^(OX?LWTw1zUQ;Q8uT()s8s!9BjRgHRzaiVJDnit#Q>x zfkSJ=?czGn9vmnJ@r3@huYTFy`L?&#)N|tv*Bk$y^W5jz|M}eKZRxV*Sk{ASK*&?? z)Tz^4wjIlVsw0eGpM50lW~WtUVgpqUkcYl0Y58F&O9M+KXPT%{Sc0B}kNhQSQl46X zWN1q;dE;^n$2{IS%1IIki&=OoOH#@Y-uR{yt5monLP9nIr7F#-le+jfR(bb>8X!QK z)MvtFi4HhpL@bsgQWcoUyGSAje?6p<|4}7%9hF&dO(dl47h;bljMtkn1O5r@Q_l3I zQ-*96)`a(q4-WLg;|Z4JD-gp zoa@r*Ce89avpS5)FF9ezzWH#S-MhKYu6m%(UVBCe+7dN|EN;I9Zk8!|4Q)Ht7Mm zYM-1*?`KkjAQs^WCDAyK&oyVtd6ReIDf?fqYPHWjSnokO@lPNq|HQNU?RBSPFhe;6 z%|TPFBhxKMGE53lfMot9$w4%19bPs$|F}M1zfBvgAM+RBf#IcVD)x?Fwb)JAoAtZ* zG}`;;<4s|>z^%70(mCx%!)hEt)508~D<6mtrW-5^{{ zd0bplrHT>c8 zJF@J;G8SxGh;?N3SO_{a*y|rTn^HH|4q%koFysb}JgY7JQ4aRB)gYZ#nr1N~ig*S&YyU%vQdcFdxsws_%k>lx^_`_|rPtB*U} zjywEto6*o_Q|sEW)_t*e^r~0w79d59mgg2WmgRds-}u^BJt#l^gcI%VKi*}ho_4yu z`LEw%bLP&)uWAE!{k7NHr$6-xJ8b1j`|WRjWp8}lYwergx!h*XnuU8DGlOhb?;xZc);<4DcFT3{5I{f6v@Ogea>BSki8O8 zilV+EU$LArmjIGi?kusBF-#(r3nxP=lMI}9jO0a9*~KEh)ah7X5OEvcv4WP&2nVxc zm5=n~PMq+{Y5QPO7@u-lyuQ#0|3e^7{R*#w|KUg~DS^v%aH>5ZlQs;q<##<=Fd(Gx4D!kLTP;Fj9}hLZGY^)jweZCDd5C zN^HGc{iP$xQxg&>&{<4fDWOqbqRJ)1JlGFjmeeFY6|qbVk0c-4qfDGclYi8sltBSg z=9hTKlQY&~+`@Nb*iWgi;&n=x@hq1H(jMl`$D?{g-bvF`H#mq-Z{i|O_6pK1mA4Lt zxN>>o#7gNINr9k8XQqJm76eD0Or|1ZxcM;dVn+w;Rr>2BD94&^2DJ>zKXX=}z5ZDU z${DQUFTCubYE0kQqOB!w3Bx=&AD^U-)ssW3X5H+fGDpf?2zVc2;sb z!Jw&eKEey6eEDn}*4sUT7c{rRcb55LDe2_%M7v3u{X{cn3s~1Z2kPuG$ngbF|7tnp z!UI+!S0iKRLEXHtsgIv9=1+g4{lu5H6aA5IvOydY-hlQzMapItVbKyB#6IS}JHl+tXtz(Z!_K|bPB`beHWOPNHZ-;Q8(a-| zDf1a8o??s83(i=2v|af6_u6a((jD0H@Ymn|yq*8H_t?UD%WcoLjduGrKezMFxX7kr z>%)g0f6$tz%&XCZY?Q`{YMyZhSuPQ5!g8Ja?z`9C@>g%NqmMb( zgYx(N;|J`AKmMt`?B#!I=bwMRU3lSz_Q8*Q)N$*ueCOz6kG1QsyVidClON-OCG#dg znH4rX!;uColVR}BFFc1CaN6e|B|;u}z~?&_u%7HQkmXVs+?wzn0KXW`2ZjNBO|=or zjhe9+y^kSMD_P|^JrgKM5A~O&m`<2~dmpPCE>YSTb`99tIxSieK3gI{=dg%7{-S|>g)pu%D;&vBZnd=$2Q2e5g!`(k8}F$RVQ;s4l_$oSN#UI zjH{Nh4dF_1vL(L7RG&No&D)VVhzyzs@U%X?9UkCJ`qOzL#J1;dDczu7b$^rn05knR z+kqa1GXm&jE}A`LFT)Eh7aiS?Uj%}zO!Jo+ZXtEugAI1Y0}b|@trgpW4vsc1#b=0L za@2rba3tiw*N^(A4i~EoAAFq048=EYZ?d=lvB6%oWXL{rZlBFT+oCV4N7vgVa55G*3d z!>lmx$Cgb`*#3?Kw&dt%+Okzg+8{m#bo+09VGEZXW6KX+g^PaBn((sv&{nhK&c4j% z9daaaJ@&`DZ?h|IzSX||$~R#rlSbTEEq2jmZ?L6{m)q8TJMF6LueVoWeKc)fef8B= z-!jV<%$$vx^A7vX4OiO=1jGwwO|_--7TbZ&J$B=Lx7)%aSKF+4OKrGiwlxg(SZ~K> zEW2q8@kF6r@=Pct8t3>qa|jkp}cu^!<) zS0Q#r%%nwlXHClNg9xTce9Uu%>awd9Pm()M_C!mS=K=Qv2cGcs1X0EbdEQAbv9~O@ z6YzmfIO7U9M+Fa6O`sU4as1kYA9)_y0RmQGov@S-?;fLPH3EtKa6bmob(pB)*w!7) z1jW&j0*eWX=p0P9 z(S`UWL-hc2bn-I=c`6iKoyYzMPG>JYB(Ay79&fth*vnF}2_xl?emzVT!KCkag`9mU z+Zksm{N$H1O&pkvz}#B}0TO-`!g?IPv588m#51TAoX5$GaH36e9-K;)dI4~3);mHF zNE1GR6_js3P_g%7naKCohvh2=5J_&u?D3b*>$R7k$WL>li-2tC%YqTA3Xl{Rx*nku z*Q)FbALYEcDHi{c%$HI5g3CtCW?QG(vz89}r-b>iz)yyAhWaztG}|Zcz-ew9w&$bM zn2GHSH(}=cmz(PCmyg!lhamgbvpJ}u8Q?rGFwUlb{)T4z&@GU||Fah3t#r&daK`;d zPgLwj590+mES-550&E87YQD_nP-p7m4=7)~waMQ3>qa{pA7*&ZnSC}7`^Oe%%~`kf z2Bj}jMr0f~XAK-UK%=ZAM0Q*txZVT|Y-s^vv2NEho`VzxZ{-QkR`7#IcB<;01D|{? zvlQgYC<%@xrDg=SnxsfRR^#ZTI#o@Q;Bz{QxT51RJ%%Gbw;$2uk5I}Zk(>*>U^!dS z-L>0x-+8@FJLC}Ci}Kv*)2tQiqkFL!w7zW?wnRJva(#B!9ly2jfA*91{x4r?L%4Y` zhi2>VzsD9{_7Yoi*lM(Eo$cAZ$*%bRS8NR=_HKC0`d6Q1?d@}H<%)Kk#ZJ5K4>#G$ zV~??U3lG7UKs)Uu%(%~*GZR~@wb;W?J!%i$c(D2#kP6V zCVSoM--sPprrUx2SZ;$^?o=x}xjx*4XPe< zA6L2ZNA}%sf6K8)9(A-Gd)zTeKZx@ICI-|0^pOwS6YC#$o@btVs;^&X{}QKm{K9eb zO*h&3XP+6PQ~2AL|G-{w$%`-mMvfGlBO-a_Qt1(ZScl}5$26%Z;^MM8PUgA9a?F?z z9hKlc10NfP?9!1o6d95ftBcT1P0L~e)^RBz=#PqNhadYd`>vF_o`&RggKoK++J`N` zp(*63^2iU#2^GsjDdoA;WF3AzzF|F>5STB*8u39~LtY~{!2TI!@bbe$Gx6>g7mH)4 zD@_bK{lh~5_@Zd20(FGzJ?uTs8S-H}brAydKi1iucqn)YCdIfZ0<>o-vF`{HV*ec` zMH0%LI~=0=yEw4wuT`cA{?gBYneUr%%SftBcZwrpl&F%wj(20+Cm(mH;m4N9CUeZ0 zh-01Vpq~#aoYdpilVLnh+Qd17YyxH(c)baLk^b2KjIjnpMgJwXVf+YeKM%Uw9A@)^o<3H%RI>bOqRPLXX?R_yme(s+$cMBAt;3 z6p+zvzUa9XTNS?Rmo4`5r|NCR6nqts+b3Ze##f)yYnQI+^O>2 zp_?200}3C`16l^zHia_M3&QGan%;hvDGY$AfL8>Y#98)r#fT0kTEWXMOQuoO?Kp> z^4cP#V2vNvbXBmVq<`-M47B~W8Ed@f9eup@HMiRFr=NwFLz}Rj;(hj+*Sy@$e%+fe zyWVYe-TSaaWu4t~#YgRB|NLtN&29MjPlw%f!&P>}O}E&qPCLogUweg3oweAGI^{XI zZ|ZErQycAuYp%3^c*jSrf<03Q5C|W2#PRr=Xg6M(?6!IH7TNnh_OBS#I&IpN>F6%{ zZ5X@7bYRQDMGF>KJzlKr-0}o&Wb6bneSr;NISVfd)LMF{Xv^q6Cgv8`LST2Idbe_&9@6Z773^G){VH@@B_|MZow!Y>Ye zm}$S<%ZDDe(q8%U%j|d8-)N^l>kK?(_4q?rT4C9%mB;SW@3+;8e90f*Q>=belL zmD5LNN$xNYiOs~mUw44LNU|pe)d|Kk=wN3-IBgf|C4wIW!mr3!-~EkChHN%gtu4fu z!@$u~VxQa`&ss6oA2|mF1f}hfYk@f&iQa_)cqMf41490uSLzRxKqr^7wjxMf#`mRh zt8K^j$z0xo-BHDvD2t3Z804LpI~RdH5kkhgg)D(WIXY4u>%@nLCwAA_<#%DbW#nh! zVd2sfaIc3Id^!_JYe5M9UXOM5Ke!*ic0vBqBl(gm-!sXYmu6^}zeeoCvf7{CU9qb+ z)Y%cJ@1LH8ub$3f8}mVhoT;~=fMbHLCZrsJr&irkHZa6Id?-qdrc9B&2scqs@oW~; zLKB_Pt}LiDi|S)1YA2SPyuiGI9a;EPnw%NGU%UnVkW_@Gd6T85DfVSj;VKJs1IQ%H z05bYiZGysu$BXmdF=PDmC+h7m%oMM~4aPC_TbLof=$IrZr@AU5SrtYgWlV(Jejeo| zDL}p%vG2#QMPmzmV7v0>+k=~9)4n>pZV%SKpVn`$dIn~qF)PlcE4+F5LduF+L-xJb z^x4XVL)filzyo5LeDVY_xv9=*{AMn0DZvbPD!>B0-eii_|`k)fUh7Zf5$Hy?KS8WK6qho z@DD*bXVQxaVrf$-K+EyaxD#^Nq!4x7j^TVrA&nv*&c0;m&G?e+h52#H5+f}0pwnk+ zQat&}ZAp?GPix9tb2B;*sm-xCqAHAqH&vVmToi{Y`+2rJokF}Ju=C2$vK7{gX`g;9 zsc3I)u?>%|wbkcdVAHUs{J@TJVEtqYbm|%WdA$BXB=8;+oxWx88ZPbw08d_X7i0OhaJM z-MV)hJ|?NyhCbnzb zv3n!jSB?4T?X8g1*eh4Si8rvs+{Cay>c{dDrd>OC*t5McpS-cw!xtP+>GFeqoDPFD$7Zg5>YUr;^gyz}hS zpZyPPHMyKrd(ZnnVDEbSTk&%0H59cke(pc*gcDBkuj_eRrG}h5w!?U2#9PeEEQyCS z!mMQ4M-_==as~pSk<=K(OWtYD5gjNOr??#3iFsl;<_BIPwC~p+?GvU94`N_>ENL_u z>5KUaFj2zYu%4V_fF_?Oq_q zbG2RjaK)bgT?pb~hJmLuiK7t;h2vnNl)&=NfeA%*NAqZjD1gL58NntTuu-HO`*3OQQcUG zlNOl6n7^7D0`sH_p7DU1u_DZ$&Xky?l>~HaUUvHtK4u^sT=W^|0|*{}wy_>RVGrA0 z3S+GMlS2pbkpTd>d3j-H!DXz-&5iHi&IS7}$a0*Zz7ze%m#{`W?9G%AIRLmUzEXx8 z@%{}Bb~0wR`NdHN$0VPGV7sycVt8QUm&^Dh%?^cPh$HOyxloLrYX@H@MQ783S@wsw zG}x2)8SbJ(hV1l1@%eB(0Qei|P{>)sF5HNxK<_PoPA%aHIs}_20L70hadQQ(DSro+ zue{+IgZAF%A{arS-vb}Ab_0LX?rBaG)dHVnC=kYFPy2!7I6tHa0!%?+iVnzBJ}H)D zk}n}SN{$A4z7%k>Nm}qD;Im_piaYc?jiu52$RrL1%J~nI8lQ<*`{(qlazx6-P|?5^ z!#Tn7Le74|A`_#jis|fYhVZiBjKwRg?>QG@#(alO!OZHO9b0Vn{AIRu2}WjYqxkz< zuC=Sa{vCVCd1u-D<`&!5vD2Qq;Yw?pwZvZZ=0CG0d?nNnMBZ}qb^KAxRxg-uLkITT z^0QxM3-AJG1vmW0txwvIzVL5$(QDpd^A|0(0c?x7amOzE=%+u7HS5dm$YqDw;u$k- zHuh3)#)pLF<0VuE?2Qf0n3bMy>z;bV4)j-S_DU|RX|s*%AH$ZXJ3@1MgTyssqo_q< zxX43VNaW^N&wyBI)=d0{kL2o;+!1~V0}Y>7eLr3*NK*O&XoSlTOF4o|5hL$HjHgn9 zPm1)BiL>%dpZfH_<0aK2?ErtSLptu*qwJHP{*0Y+!Wv)x!Ws0xf6sgEm}8H_ta&f` zk8lS$v)sr`IeJ`&*l`LL%dU(D5@V&dfb>AinQy}e@)V}L-|S=sU(WwTe9T!=hf(=sKEX+@dMkHbq@@)FoCL)5JNBQ~1mw6z zx&dPtKPt5L@jBaqNvoY0ujU}&oQ;|8BNh((OQ2kW>PuU|&vEaN*~9jKpEYE!|9Qn$ z;+E;d7}g2>431_3euU4Lz`qPL!@*nm6moi1y~{OZki;2ELE2$Imw=cvsou&RNm^b5 z(M7Dv6vdBbcHdLjtN}s!akI_0eq4-S1itcUoxNrYc9p?H2$!HxhLdN6zsW5nTQS>@ zKZG~73B|7=I6oJw`$8Ze+LudQ?%PslpMD5C*g)qF@KWZ!oo098>#B#GJeVwL0l||z zQ#sgWiM1k?shTU`YaPX}mXV%X{0D_EI;!(UrKw?D0cbOCyn`~93G>>Uoa8-s>Z#u- zL2=T2YQ^Y6YoC9G5Bcz|Y`*M~3o@l|qPd)=JbhAvou#mU`3rinBLu!EhLM!4{5h=;4-i~m z{@U9c?Mqlk{_bbh+dI$c^-lux;ektC@M}8);;^&I4y=#wMW9VNtTM5C@$kVLl67HX zwyzVFLr3(CMVLv0ET0M)6f^0X&C z<Hy?SQop}OwQn6-hTYi!q`HZtLla4)Xhq~;y*Z@ul?rstL(q-*)Kdrh=^)z&Gv2zQ=F>TRZQRvu&_4Wc#}IVlQ*7 z@5XQ4jZ^u%d6TsxfM~6sje80Wn%+Pld5RmE2$cg>N3|cR;t z4!AQ5J~HBK%|CeJkiGEuA)AUZp6^m|49C05_R^C=!1}i9Ikw*V)L!etc<~Xew>}ihSsD=>fA9|c zRE`UfFH7>J2mVB+n^bDXoy##f!#qwXPr(^qvtS_I$DD0UG5l-;c0C*vm z#o*U{1nsmXr$WvnUo4FWNk5h7jIM{BZ>@zO;M=ja)Mk7HY33prqrH+uS9p_$YX_#G zTmiSUgGO>lv>d$2jtalCpgeGivsymZl^Ra#n3(d6n&PB#=xnGtxK4bqn)#Z^RpSD0 zkRcyAf+p$7q@Nxrs|hU1G;)HuRE0>WweYH%l+@tM*%vSJGWIfD5W$iuJbCWky1{ln z@Br3*cj4vDC+vvly~d`_U4*sg*kR?CYiz^SH`;lB`6`<}dkJ0??XtQSZ1p#9zAZog z83@W7vAm?)e)Y2}d@NqEXt@nnrrY6XU2Ida)5>t&fIax|efF#W`=9pex4j!nE{?{` zT(fQ2y2bAP#n0`^xBR`eWAF5V-A~z`o!e~hft_|>agQy-8uI$#R@>dX#~$9W7PIqh z)-t6PvsCpqeco*A*@dt4!Zz0>sx6{sNWhV5Ufovgdb#bO(w^OTLWo+lXouP}T)spd zv32rgyrgES7Cz$$P_et`EI7Xu9d`pD$b83jnzgfR0N8Zk+~ROlu?<+ zW4_cGAd^Xpc)-qDibAoV6e^Dtt8sV|1uGZ3w}SLZHgzWDN`ad4lA$iZUc|x2Zc4}W zR9gu^KKVm(441H1p(1(tI;Q$3{(+ZbeA#m^HXQnw-`3j~?tu?@;qy@Z?M2|Yy9-9~ zgVPd*iPwcFhwN`5Z2NtfZO41s_6*Fj zpRy9$E&97r#qmCjjiDA;;sSfyX%A!FEZF4un|PYkJa8rjWGJi6Rex_oNh!}H1O-ZF z-|?tAW0J#{z!5CXc>o(iaN_M@EWYndG|oHq`O5)x-8PEO*(#&6s!! z_h5y<8JIW#jSnrvQHHV4;q!*<#Y-x7<-WwtSAo4LKx+Yo?r%fSy|Mw(kCGC=40YySN8&U1VGWe;Ag{$5pb zj&k=^E%UM|8S)92_>m&$4;MLw56Hc#-o4>@}R&=0nCb&J-Z=YiPXI z5>h}Wm808PIGm73!~YHV>F~p+U_WBqq#wnD!mEGXWIugLuPvH3 z%{)%Dm4&}SNGzcRbd;}w+m%B$$Wx=Fh*1(X&PfY)#1-`&g8i+_EfryJyaLyV25U{#SW3PJz^Zq0KXVY$3FHzyz28y~hCzVgrZ>^JFbQ%hL8Iy@Gb*^3e5VkDbmwr?z zUIwC;WdF?d^1dZu;cUmc$uFC3V59>Ci@aVgAX{5o-L}uuk(P7@^r;p5m@5q++Ne41 z(aC~d$)$ObuCm)4k30nAi0hDXR#-}DHHK3!Mgb^8eMk`)5IPnKk$tgvUgQ%o!aJDA z`@;ngQa5J*sf0;+svw}pb##Y zk}M)bV38Pl4QXZ0-Bh?V=cbno+NW=-_yyDa$dnw)v^FSLs($6KWZD_B5y%4`1OsTg!u>bo!)bpLE582`Psw#Dl zJW-h+t9iBXPx=Wf2_NG~OUrC`wm)z_;zoW-$7JSVoG;ht{cgbEicUdx%pUO72m8~VaOxRtgZ+u1u5AZKwL1APbfT_@ibtUG@L z9w-d!&(2%TomKQfp*sJ#&^F9wa|WIK9B=sI$Xv{n^Ea7CF=K$3A?j3umt1F# z2`%gZ5pD|3%rklagk>#f<7xd3`0x_--HkWGe}*j?KXGl7{qx1WhKWjVSLbE*f~d=r z-|saqg+suYKCCUj>PNO5 zzlbfwUfcE4=h)(7*I3g`OnE`cV~;&(f4K68_VRbWAM4Ez!`DF@Z1c{YcH4FTZU6Y; z-`Y?!7II>3TieV=!?Ugp9$4b*-gocYY`5Nft8Li6&5l0mc$+0-e$ZI!#-+EiF(R-YvGGZolmVA5&6)- z^CVKt=QQ#X$s`AgWzt+Q%h_P$mq?CIekB=JnRx+MD8`&syzUwVV*4JTOE2>>|MOb2 z`owZYp13?o^`{!Ndn3l7Utouj|HMrB5xDIiMsT_wu&Bc4idv4rM z-sHeFV0V|z*oo!Z&A?zRUWI^_zp96qC!GuWm?w#f()*D(R)|@Whf;EsTs2J;7v%v6 z2It2w9kTy=DVDxs4Ck{t*NSr~OKg&|j32l(_fN4t{6%Xp1ql1CXeS05@g!hS9X`^; zB|RU(FAiK1TaS8k?Kx-GUAMbP&9SfYQlmJvxvK%Faf~%ovjnPZ1~_6DS}iZTEZ!rtmN;0!pCqD*fFzGp_JsTNsOE<)6k;^j8zg>hL3q_!K?-^ zTAuJXozY{TIs|}3Mp^Ork3^LTk0Vix`e{4l z82AA5k2e(`)b8F60RK57-h?Oh4SP|*Pj~0yCOQ*KUHCzS9;`3#>cPIx@VOK49oxWz zSp@23MX7rMK0IWQ`66~~+161>KR(3Hh#eERlH7wa`+In~^9rmb@5ap;PC1mok>yw( z!$EXf^RWH%1^AEgnM;@8I>XG*{vlhs>UewQ zN4{XQu}675e$svH-kaBxYKU_%qKBLhX2?~Vano_wg?|1 zs^A6Ap1%EdGrst_2D^fsalwnMVcHbn>wHVa3ob>VzI+94n?Bp{*!_0H^*_dvnkUgf zc!vXvOmDvRYWw{|ciEwbEVkypz1B3)g(YVz3ejVVZPCYzWSB_wvLWYyzJeK`y2bi;E!LkqtSM}Omq9~ zw_-i~TkHexdyjqeLm#v$Q>KC^+-tFo)C1UQfi9)KzVHnM>gE3B4Om*Vci&#S`3CGO zKYfO?#n+H@E?l(8e*V*+T31(xe`JWVgV-XY5&N}vbadFyfASNXjb9X)+R;wzqa^D{ z9;mp8ESAMwhOa44LLqt3nkZrBp!k$0GVGW=l^A6Z&y-_9!ONnFNJi-Zj@L?BCM`~& zrD7=nhLPl{lRA*cA|v`A+ayf8v2c-IpiNT!sg=R>9!x}i?hlB7aewc?WxWjGO#Jb| zoWFW=-MRlFjPi7pNhcqdw|w-jifzM~&M|EtHbA&*BR&}RP{n?8cfI`z+X(*(dat^_ z-fnoT;@b~0uw{_T-O=JU^O<|-{lk+Ldu%6O9>;E?tFT2OH(c;jMs{y#cW3 zb#f6G%lQe?xs*sKtA$z8A0G%xwhw#) z>$JH{gG*b!_)xul;8$2jikq5G*yQ#4%LUa__^}!$UnEO)ZCH-Nzjpp1=xxOxXRY}z z#_t|#w10vx49w44Hsro|=K-ItMaQ*aA7=C*$BziKW2UYJve#n&^Urdp7tE0RpojCr zEf$ZMKWMMQOoL&@{2N$P&m}FiM}2L``4#aW{ISVicX^{-v5vb=z)|@MU)VNGd@1y> zIYai{=in&-w%>g#I=cJu>2%Jt$L*28@#Vq#`ky*+*T}#*{PUt8SXK&9Ru(>J^J^2_ z`Cwc;%!~3YhX5sTQBS~SWkPw2EQvG7$G1fACCC?#l9V*_k&^66r^+MAl+a?cAgk!w zaFF&A$w@-xE#&OOLQMUC?7auLZb@0^TRWY7a_ZcDI?*(tQ6z&7N)j-D2?Yb@%b<_< zauJ>TJmZY_Dq|ee)fvVa$HzP>pqOw7qksrV5DX~Na3yrpbVGNa?vwX9JKg{9t@^K3 zYklk6``deehw#kQIs2=sH&=dFty*h^O${Vt`1;zM&Q&ky=*a42SEW1ezALRRpV6Cv z)aUmdO-GMiFCi=G*1vi~`jzkbmUQhme`9*c6Q8U;uMVvzm#(P_j{|LH%c zB{}2QKj@L-yF?e4I-~i%ze%tC)n86e{gxM{E3SK>-p8$Tsvr8t^qQZ2X?pV0zFMz8 zJdjq;eLTJC4_}u4%MbrR`n$jX%e0`|LY(v7cj!p^oCiEG9o5Qp_dmWRee7MYOUG|} zZ`wbef_LcEfP$v6aE>|bzA`o{m-CQ55yav|1Jcs8#@9RUt=REx3@m}!% z^dEmAoL!EN?9*FgcI$PLb9#W}ZGZKS^!?xa;@D+};fH_Z$I_KoT@?=gh~pqU4m)4! z%&#^K73mLt|M${~lc&?cgL@@g?}FF;??3&MKT2n{<9@kbRXHwPs`sqF|DXObz5I86 zM-GgR6zKdtw(+)>-~H|1N*~o-4eI70I&01sK)D;>g|U%2T15?NFww8Tas1HrX%QF` zS0&jrA%~0-B?>A}bfyCs4n8roLNxS+s6z`w1%TfCplVzzC@}GZT{X=Je?d2Nk%(dh z|3>y_0*S|_$mE~kDfx}~cUXSz-0}vJTko1lZ@pU^LUKTlYpJWyn~A)&Xb5nAvf0B?sp z_71V9otbLo6CuaDg^T|2?c0CZvagXnL z<3c*d111`Lzor-SFX>pzkABHgx=hDLcqp>D?V1hRvhrXD1^i8*{>U{Zf?2O-Z>1z6Fy;@rv$D>!$QJwSV zRZ;KI+47hC&0P9+Iq|>x&vUWsPAgDOu7dyff3ci?Q@aO0@t*nES^o;1U*Dshc#f_7 zrR4pvc+)lO=?A`OIbUImFKiW<+jp2v9h?*W6SQ0ZU!OdiUhw*b^!0CANN?AU{3DL7 zrZd{HhbCG}KlZ`l1xE1OU~(=`P{sv5BdTA9Po6%IgC#kip1b4-UY>6Mhj*tlpZs{b`4LY@3;M{<1)cx?hqu2q{rSK7 z-gNY#4@l4a#_!bEH?N4dvS_164~brz4qtymy7JCT(ue-`o$0b`Zq(osVs1_6v+qw& z`}P;4TfXSYIZZ%?!O@X?WD57k&{Yn5_3*QPVX`5G_s zrp18=PJ{4*cKsjzd5=i{?=QS8z4%4nlOFk~N2MS7!GD|n^388f-}!$TFVhPXt`ft#flcX|NQ@4aYa^wlj;S2JHaFz*=c;utfZ~pqP zC7kT1J^dMSf@acRz3r{B^UjX=8GUUu2eq6VvOt#fTFV8Ph0L4Qvqp_pL*J;y?z{cqpVc6~1 z0*DbpIrt+~LS0su3AD*bM@IAw%Q*caZD@iq|1>a-eX*X<@ey7I!_@6TON&wu=Cx<+@1bq$|hC&&IZw~6auN?-c0 zd}LIryG3t=Iw4se7z6F^)l2G|AdU#YoF=aGnIiw1hTKMU<}ppsOK4wrt*4f4x|$yu zhKIa?JaB^PYJR6{Tth3Qt1blee$3MYZ#&D8$xe`h?9=8oKCed|iXCO9=a|bOo0k{K zQ}%Tk`u$DaSomHccT|Kv^Qi1T-uU1E002M$Nkl4kr`D_wZZWb9C_WwDk|_tDWyZyO@4YZ%N^g-Ekav3jdnt zpHKH_Qu@_Dol7Sli#BZL0gf3xsJ5Ek{52QSi~npPy-ZH{%XC|hv+7AZ5b-LhV>;LO zvH9d-smZu53q6F_*09q02pM z7t(GG(xY05Kk?>Qrw{(#ucq^R_omZ#ygz;Xy~oqB$38SY?1@j*@ra{o_2m89W!DFU zc<5nGXRptmNPqCVznwnvk&mXm2lwgiL^_V8TR;EeNl#CY{eq{ZJ^PMm@m@-Ac=PMi z2VeQS=^Ov-uhYD4KP}yVGQH!SZ%qdt@QC#IXMbzD>e`3u?%J$&&ra$Xm$OTV06`06`)waN+WV4j_|<`+?sy! zr+-#;vX=h!|MmUp=YQ^J0?yB&!-vBuf58jCJAKtxe~r$ZtFrpRouUsXU*nuctt-kf z^JbHrn}A;eC`a&BF>-;0{GO7at#JmFxYSEy1QTDNOxIv&h^)T?MY1NHnBPo}C22$Z zOS!NY9T*uGCs;tb@E5WM0G!asHlW1YEI&syk$F*={)pm%AjyWlLZYrWRT zWYg!}u$~^NFJq%(QA7TKe*WN3#3b+MxQ?)~ta2nY33+51YR9 zoipjBADmC`)gfynYGe7ZH6hujr~oc2v(kX zE$TuIJ*PKp>2|}^ESZ{PzMu!G*0q>1d1uF4Hez_s)L2e?uqoAH+ov}XSl6*lF^oLv zI*&L883UNX{`HT#5Ie)){`y_%GF@r2bN&sz(ygR+@SxfqC?^=9ibKB0jba5o) zn9j1l@+&UH6@9h2mW{(uT_DV97oFYltEH0(5^p3^!?zg`&z4XQ3o&N1l{9?M|nj7>M7TK`pVEU@>{MTt-1BRXF6>Y7}&a9;m z-SOe{zW4k^y5Yc4-A30DGQGQeU6a9H9TS<;+eYsG(5-1NSI&1IPS=#8=W^we=Gx2c z@BZOhscVX~Yub1m5+0u9$jJ-8>$~+~p3l|MkpGr`{a1gb&zf$$>8A7@-|>Rm7vZ3pd_)qb@X7b@XMg;1Vold_|OV=s)%oKb4MMdtLh3pZdv0?&BW+ z`RPS3{@(QTXFfB%s3cx@|x198ct9N?kfGXIb2cB*Rgqlb@*M&1zz)z zHad8eiNY#lctcZ^mng(QLokdn2)`%LfY*X5+FPFL}e}67LmK#dChW&i)%s*F-Ig4B40|hr61Cr}HjJg$BLz?Ny0`#g7+3E+XOw$5` zQ$KxpLy<){+07C)c}}Y* z13fkC)Ex6W?zkfzDAHm?M7kR#(ki+BruP(z8w+Y|g2D zxh?{-gTg}H4MD9R9OLFw!-a$zz&XEEyX4P$_;Px>Y=NG$@Vo|T>Ab#jCWri?*Xp4w z(Zz@OLV=tEwH0T?k7&Yq`Yn1VdeOmKUE&FKm7gcFX;N5tW#sibqJp!}S4P<_aDaxg z98w*9$s?KGSlr=PBJ>3)exf{Wwn^n)le2cxa%_&aUv;O9tRTZEzw5B4xY1-C*)&V< zp_Sd3zbmEX&hoMiq>*BaS}Gv5=DsfH`!ub@+|tk)hII2;llc`JWsvhGEpd$|J%&se zQ}zTjS>tOdb-E!`qSi7>!|}OY`wpc2S6`pb-G6^t)9tFgyN;y8H|dDVfveKI4o$9} zKBKSs&B}2;saF?%INkN(+tclDdRO|;Cw?ov`9mL1I(nKO^T>zj4%ke3?JxaE`aHb? z@scZU)J5-nI&t>y^jClR=Jc|E_tNy(2Omq1))9-Dh5b63qBGCu7t+x~*Q9kl{j{d7 zyT$!$>AipVw)C67^mA#~h0|%zp{vsFBL~y^uEXizrI%}`d?DR)+eh>!r^U3cPw1XK zaZ**~GP!5F)$*vP{7^WU+Ih#+61XgmFB@vmDLmyVPfd@1{1fCbza-sr_ub8r5YB_& zbo0&W=uy2@MmoYN*I>m8pC$i~zxZFWP*Ke52+N8tKv;xURNgDEx-$JgKm6~~ztpbw zU3Yz4$6fZOYmQwTPBssr{`;T#In+nsfly%jpPujqUywd|pUN#g%rlF-7t`~<_1n@{ ze)V(G$3K2Y?4Vz*V=(NR^ZWpOd0{NV91T@O-BnCCX>49swktH?1|qCR6u52SO4jYk z4rl{+8ay%@1epROu>n8m2Y8+?f zg$Uez9tnV`r=hlEp`^}wY&l#1M#)8%*NG6dv?~DGo^+!D7k?A3~JS=_U&X39QI;XU5?W(hJ`*KlFSlp#ohn+pUqz5Os zMxA!>!q#gcsiAV+*tt8`of|vM+tQ9oX>rKoh{jjyLq0E8QJ$xFg>xl-lLqIzJ~5MC z_@}$muYc9KctZ+j!E+O_bcUla z3gpcnfZk80s)0bVZP`s}DS!gY*gg+=a4o$lGLPX|N~X>fEhKBU1?I4vn$EBscPY&e z&OF3~T&A3IIf3TTr!L^Y_i?SnOl7T(%}Z?gO0po39a>Bvt%4<)+ZI$LRnpWIs7V@U zn?X2j>88n}*4S1APNcr#rw1)pF263Fy7!)VAnQ^+bU4p_=lf5{xz?Lcb|29D%`cG? zxR4I(Z7ermejBzVBf)X;`lx3oxkuSdcf;%)A4ir(?{<- zo$ivt8;|HUYzGggSO4L!rDG3!Y`RGgc3yG#D&0BLDnF;^I&_@oZSQ((`nezf-gKqj zcJjD~Ju)3Rct~ISJdlnY(AP%ws>D8yYIKPf zC!Ilj?AmM7^*3A}@>X?>=z?yEF}8Qh;kM;*%NK6Og#7hdNhhbpBUwUg4uc zP$7rcI});8k<8sA3ZT5>Y;Go~)8p_(RPbk$T3%A{0k^D(3oz)7fVc|b83ciVii6C; z91GPX3$0liM6D}}s+bA@ntrh0ZSrr|{xGAIw=t;{ijENNZreoABzIPRM6>2R_=>gk zj4S5S>veMMsD?aUi?^?QK&tlZMF(7;^3ns|H1<$!p4}%NEn(@y>Rz9BnLfI6&3Zbi zyTNbJ9d8``lTCYwXp?YQEVr@OBrobOzIs(3@X_~1BzM#)cYRnOc`Cv|`JUby3$52U zSD*2HNtW1`*T73+9KYq&cv!5rFK5#HIDYFxj;jD#>$m*P@(r^YMga|GD+%VRv-$wt zWtU%?uD#*fGQX+ewIQ^qjhj2~yiE_!-;{ph-~U{C_j~>({p3&mLVCq3UXh;r+~;mZ z%?Ry|9dk>(`KAY@tF()9K(7?z6d$q62WQ_7p=FsZw;Va!WA1L=^f~TI$+@;gO>4`++bzoJtRp+i{%z|S ze$x^2Hylivv8~=NKiFFTs6=aUTx&DTagDQn;~X1a(FRIC9aL>`OPfjBddX7F*yriz zEyuPvHoT54(;8Y@QXyGCjF0m=E|G4(ORxW1)Y(!kj>qrUQIut^mow?q`kWrz+?(cg zu6#i|)oW)?rCoCTn4femO%E??Uebq$R?eT3f4!2fI(k|9vM>3@^tgMUp6=1B{FZcI zdIe{b9pAO{>7U>F7wL5`ds#ZCHvt}g%rnv@S6vrQ{q48?b9(=q-;^G3^Nr~dH|m&% zHX@evn!+P{4yBc|$I~6M>EL1QPHRqF(bqo@UVBMe(9_&}JdAA$mzEl!Kja!FSIU}` z(chx%B~Rq~aNMFKM*|kem{&of#JteP+@ktpOlO@rHa~N9H^5aw8SFUF%j`;M9CMA~ z`Ksluc*JTa9XnbZIDWILJB(@*ERk&f@!$4_)~=`kH9sbF46W0SA+BYZrXQQmeC477 zaPZgpiC~SO)}|0venNjm)5x@8#+j*?pDOpOn>*5zaW;MNDM@lY}V|}Z-5L`UN!CCNlmpI*6e|m#zx=Vi#me+HicDY~j z)U|Y#*6TOjI-CCD&Xlg!vDCZzs;sia$Udrmu9GZ!eX?Q=>DNDdHGS?ej;~60D7W1` z<|do*^l`>RHn@qRifu-SE0vzprppuo%iU%thRN#6f4HBIYLMC;joT`+2P!d-XOceBYZj_fIQK z%`rc(ho~-SlFqNS!UM~@SxnH}8qKlZSOdRl-K17fFjH9r& z5rzg}y8wigBC1oxMfs?-LLAciY1crbYO;jH!~m<6^$TC+!~U{MYnBQvok7u9NTQ1e zGHQE<=JstR0EODEn)5Z@vNO#6qqd^eW_z=g{;wtV;mTL$n#!`W-b^yUnL!P|xyMmj zY9C(&M5LcOjyVP@OcF#VYgFdW@bdTOUF38L-Io;>es@@WU6S^XvwexzeVpSUmdg^O^Sw}7O zm9Vqt?$$Qkns&Djrb~2mLe&S2|}-umd!?l@n)EWSO5_UV1=dN=y1bJ{UqkQ1v7 z3SH1+f36Mp%y3Dg{Pa}n!8OsAJJ*AMGrw~=&aR7LINy>lLG0$m#cCR3)E^Ho6oVC!5ik-IOiP$uhZmfe)4r_ zXe*)yRtPIp%^K;3wjv5xgJ|_vB#oSgtU^wOztPa@2jm6D-c+N%;D8%Oj9CQ3a<2Vt z;9IkwvCNH?r#@sY{lMd9(~rF)e`M$$I-n4Q?aGdg6kEis1 zJaRofTOZW9U(Y-Jw|8sJ*3~iNgGWm8S3Y4bTYTyZg{ z7E@t+N%7OXgi7sTK7jq70sRQ7!A=ts2FqlkOmY!_v$vQx6!Hg{!X{M$Fy36nHyC)( zKJFq;$&#r#=CeB2%@UF;vox6oaK6G&0fZnOopDOWcg6I(Nf%2l+h!XxKDVziZ5Ob# zdYBlNdElp9JkU6szVPOi^!Deirw`pZmmYM@YP$ZY9**Q_NBr-~&Dyg5)`~j6y)*-i z$tBx~7Nuem&86r{%~TmiI$PDIy3`ZptS*SHzsH%?^nlrBP?@XnHdM4(Yp>fR+nU`M zbJpc(*n>AK9;%6!uC=4p)=KWvkEY{qu!pxQPqf{+@of@DEm8uzC^e-MfkS>4X zqx7KR>2&Wsck8{xr_%ZRK9OeRWUuS#=vnVum9|8rc|8z$!_kB3*d>S3nJcfB z4!#7eU6ozh0oUEaSv{e@Z{J}#?C`C`jyi9SIjz?e&dQGUxxH}~UQ7DOoJifay}hpU zTpuioK%@AI>$G8Y&5@@6xZmTH=X}OY#^35|6!(>EH;>V^5u^!@bFTYqoO!$0Hdo7a zrLzFn;*TF9&_Vb(B++S z5n?_?>y(u|p_DD~8PfFeQXMlfxHURU9TpA|7lKMmkuVZjd}E3!EX=XseVR&(xklrP ze-t?ZMErdbwxhNkNqxEvrT2&}YeSxGBEumMzX~Hgvu;z^7`CZ7=E$dRI`t!?n@%PK z{jGx^W7M|5uBa`vh)EsiC9$^AnC@a``;1+eN6Z^aZqaK7AFg+F^NwDgQh-3y-e2HM^qY5qcfs z$@4nCvY;IeE#6vr=ho)rMDNwxMvkUMeI#ht-hF9q;ebApbGcrZcqpA)Iw9KSv`a_u zRO zRcFe-OK(ki+Cw!i<$Uvs-zPp`E&bs)r}UPO%%r#LJ>_rF5tq9-H;z}1Y4VV6k)!`0 zy^{Fp*YQTPb)9SH3z^!G6aNJqGtotX@sqZ(<;wc-loM zMcrac^gBR5UsoU&vzQH8p82%c3CTd`O)La%v$bI(=`Gb(Q_V(%ba^K%Uv+l}VBi|3 z*vm2twA;(ZwvdTzfz2#7#`%r_5jOfeh>~et=8njyb8xNWmz{Q$5FBS{hVkq27?Nd- z!j0cN9xRsF40pV_q*02tbfdVHK@&nJ&Hh+ISFiUJn!Q)lrlacYiXi&Ye!HT5S1B z-Sx0Men5qwTJ?8dt?bof- z=NQeKYZNmT$X1MTaje*;O(Fx{A$r3tb0jVVtX=Fbb?E!~nz=(LzW*8c>xTH$l%HzV z^?9th&wSWg`ho|prw`~Q4ENqI1naT9oWpvJ?}PPlCT|Dhx#bnz`r>Z)l{zeb&5_mg zq=)d#*i8DQocMcnyoJXdkh@n8kY1%5Y5V1z^YJ}=9PWhYqbpwL^IDo)-sLCml|K_? zs8PfU?pCpWXHfg=ej`XBZ*in7s|y{>4e@V*eNg6Bfqi|>6Z1DVm~e=|)n0I3jfP0K z4zuuVT|NcUx3r(%x|W(_s*XA4cZ-l!)z0{I&992GMZu8wZFSyKEwY9?o%v0XI~3z* zIE+OMlTQk@G3w6#N~WFIjz(qL3yX%S41$`nPH$!eLW?Y-j^mA-8iy>)ZuJ9osIg9G zT$WXH86Rpzl${wvEwSd#xGbrQ@8@bg;QBnq*KpebeVryhF7)|LHz~jArxIh#NEieL zmHU$GJ=aP5+j6>;{ZjSwh&64?Jz~-TfM-{B$uU3o;Ll61_`x4fkNAqGrmOU{{G~^( ziX$Yvnse`UH>BNq`U{z4~CPzTSrrN@7kl+jS8+E_JfQ2)4}=*eR}wG z{*p`6{DDI{6>}!7-G5)~wx8Dnr2FKs&+Aj(E801qf6AAo12^2L?;FWWh>62>(w5&T zFm>20Z`#aHD_K&;Y=uA5W`19QS^iYx+C-E(Z)3^VaFiS87#N{XYkWV>)U6!n9rtb6 z7Qckl^CYq@$9npC!$bf7;-94c!gp1@>HAk{x1Qa2^tE!LZzvV1`sF!hUMUQo{t>RU z;Jn#|*9!9t2D{)7I64y_zjGg==~i_y4L^6qX)1KZvl=0VMglq%hT$PY z9ck`A9blCiR+Gm8WMlASz?I;OlXMxa-?2@lDh7iIEdZiOi(`bz#DkKy0QBsMyjG@9 zf>;oFF50FQrs|m2mD3g>n;L5m1S1tVrJ^(nCs$BYI*ph*U0c_v5U@{Yo|^tskMyNfs8sJlqTazb<^a-Y!VxUar{|(%jkS-Yl|C7S#IBkN%@ClZVP6GE`Z}f zVJ$M-pWDG{w@h2p8Iwgh-B)Nu;3vVEzKxj_AM%7JraybgE$P?(_zmd^$F54(yzkcZ z5PbmWv7i6Aw07uVI)3t`9s<=X0QGtK%Wt|-Z$#OX&fKrvZ}pH1Cywi!>b%a7o{Lu~ z&duq;Ozncttei|UXFir@^{|-yZ7ukF^+2hd^*x96HPB1+MbE=&R$u*{)BD(X>(uPL zUWF)Ueqr&jUQxJDJN0@H^~nBo;JSV3u)addMZ^Lh0?<24=N@xYnv-4Y;h0kv%HFLb z=Umc$zUDAZjx)wfigCmIKwa;q%yFN-FRx3zgPaRe$3x<5W^I^ZrZ>*A4Bt*{SxvhD zutKLBqEQVa4wZQzb^3qtk5*iPY@_~I4|QWK-jI^-M77dmeRT5y7BTBafTW9OLgb^z zM+D7fa^%KeAlkMApIfj^(MR%n1aS=y=J;`|H`oFoKY3`lhgqy-7#q-W62`$d5Va+g zP3!9+KT&7|6b|PLb1yrQ$YG-FY0!=%-LYbb&F6UaZ3Wyxa;lE`I+v9SLzUt}^{+H` zVEfKk`#qv3ZKw=GEJJiXQ5Pp0##F;!PBFGHd9)#SsutuDbm1+&<=iyYipr#oQ(kjg zvTMmqVW|k)*i%`%_-7td#J~vA8BRWzo;a7s@~ow{g!DN0q;y04wh!EPLR%VJza1&t z;OQ483LtCfKrYl+v8~m)z1mi5nN3p*=_~EyoDXB1QQXhBC|(D+toN4kx~l*B^FN=y z^qEgj?_ZrwZ@={(eKq8x>8tcL`O6RNNhfZr$#T8LWtKBBr|(W{_k2j7>7Gw} z^x)^LcI@Znr0?BzNXKCgrTG&d(*?kRc&L>3sPEF3GH0~|zo;)<^46F1fP-n_VK?YV z47=r&r7mCFuiH}Q6jZL&nq!pCm$_Yx$BUs^RxJrgoj2u_fwSdU9dJU$evATmcOL65PQ9WQbcOc+VuFmci8tm1gA7uF`<9{|Nc(5g!L8z8N8qme%y2SJ=$g3 zo$fo%b?L?XZe|$rt|?-IB{~nxG^EKH*M(=!x?Gt)^JJ_!TsBK; zDMzQY%-@A0Z6jXmF)y)8MtT~-`fbZp<<~;%!Zd~#ZG%c}nHh{jzao9Z^YX_XZh6qn=^gKTf4cLNdJUq&!>+t6z3;X= z(?hPlB7M=Jqv?i&M|D13k7e##O6Pa!bMyyxrDIp^OK0@aq2qe*IbZbLt@G#SPTe23 zys>^&&NMq~=e5&)MrX)RFd6H3N}9b_(VQOgRB~ajcFY&k-uc}+udW?*z4c{b|Dm*a zVLn~?lrKz2Kj#^G094Xq9xsIuUtZ{y==Ca7px>cR8wTAdoKcZ69v`M3jkBh{oD3b3 zJt|=$NNuE*)i8vRH_iqCH;nXVp)Yfwb~+*&*-fHC7Z?#CWY=^8)Y(jBLd8SK9;}yb zL_}f;*@N}67lj`kgwLw-7o#I8Y3vVAD-xx@xo}a!!5^=)?w~bCR7usxa(+k*T*5XN z0ZQ|~2(wx6w<}-a84_pM1SDCe8FH^|bD#bc_7Pl^QY9H!)orf-0wyx!0A)qBn^f1-^$B)M=#CW9{pL9NZR`2lDS3k*5JwyxUZd{TZ ztEhTt<72uvh_g!e+q}s`!yp>s-&lEttrgYjilj-7R9jwZ+Xc7Hw=l|LMig{IA7T^w z@Zho-3S5WC_FxU(lE6FNAWI^{^Dk3hp8oP^%Wef_$Ysj;TEaM~H4SO#YAc<0YzsG{ zK~9ZF=uX<-{Rg@AgdBN(HVZZ$x;j0?*D<^zaKS6kXcFNLW}rpfE^X@qA=h$PaSL(V zc5L3R{e^EuPk=r9E59ne=5_xgegF5pB>m$*yf+Ny13uSWeN8&2&4CYTQayR%Tw1*2 z?zE&G?maq&azGygx=*iAyzRt^bWnQ+hYuV~YkT(V%`T_X8GSi(;Q${3(%V;5u0`Fh z+%?0i6IbH(iK{v{eqQfDUzY9_t*m+njAH%i-Rg5^?@lwP?ntZeKdrY$=)&M{ z$}dc_`}d^vc)F}xpROgxuB|%nv^m>2w*+~Q{mk#tZ}+_B==J4->g(v|TM1K6xxkx= zH64bL9*X-iYMwsM`KF*vqLq)57@+7g9vFE96a{hT(D(w5EVQC&P)!161#pHrF7RLy ztl_Wm!F=)fUB1|XE@iCkH2yQ9{L}??Q`Jw@S;^b;g{%qGdz0BTQEEVRPI(4od}Tp- z{E==drSP(@;dw~L-Q$0-oLoV>JjUK=)N8=;X=M=}%tu+j?l8Zz5t520sk^jSCy_H#Tm>-^eY3!YBbX+4#Xv7s@CM%C1v} z@XKG{RPrrPH!V~z6bWWvvWs*rrDzBoX@VI@*R;lDm@#(u%uP8wk!1;49>1n>x{b3G z=i#f<)&<_uD@gY z-DX%$9#Eu&Z$(bBDV(Cd8hjdT>*yM;F{hv zLos~jv5d`PJ9D99vO(oc&;U?%xB6kszYug z6nEOknSNZ+N74;WrqFtWAm&2BY(Hp=3toOo-)Qpv1PmL|Y=lEr@axAH9vKi?!uJjF z*(y&8R+v@i0`}Ep+-P}BmnB+%iH8ajm4rFwB(2~|47hQYO5PU0IhqD$s3$VRLcD%j z*BeL<*5s14c~j)Lm0eqsO974$Rw`4sf5ue5;}n?0ikvU8!BkT%!nzxB6E=WeqGs-) zB5VE;!NLx~8W{+Nz(jREw}k7zZa=qG!w0)7up_T1+KT-)p|D02Fey<*qUyYMH!i>Y zvhMx9Y;B*BP!pgJTStV z;LKy+uXKjZqy_P_Rmg7+HnjWT!F1^IE7P9+`hbykT~>4! zK0b0F#$04;u-s7B{0vpg5DbQTq_{VYqqTxuTh5uQ%}+f=acz`iD5bxAH*_>G{noL0`#Qt+LTj3d`75RUiJ)H&WDdcsbEo`NKIJ7u8Esn+9=J>v z_I%pQUkqx>U+j){D`jcQRU1zk!uUc&z7BobmO4{qeAeb)k8LQjY~}^iTVlo(enqB? zz80*4V1K0bWBbY1LdZJ+w<=(Dy3k}j)>nYNXn?IpF`cZc5UE}z@%7J@DV_wGnqy8u_05f@p6Z7vvU5&>RGQ&>oV=bkp`^aH zq(a2>`Ks<)0TmoPvWMgc%u31h!w{@b)9_nUMG*Y@2`}8jOy^+_9GC?;Moyj1IjGG6 zML%>ntgGpL(>1XL)XXeEnZ|J|yylhg zV6S-x6dG++HAxMt>6{MQR%bjADhC|B^9u{vLz-$N1q^dzK}#T%L(Pw<2ml*o zRaP4<{7%Xr;)@QV#whC)FyLWK(NzE@DX5L2T0~6;ynCUW%a!3-wMS@H*J2v=!J;2f zfet{)m>#W4*L4MmU~;p-LL`DT%GuaYyMG`x_dVJPwFI1;Lxlp=v-S z&r(h+Ow}=;oolUat}H4`-2jd&)RdB~{%+3%K3;3nu8|NOq!v}u5gG2dSZ&hCT9tt{ zt<#PRe;bJ@uKG(1!V@7zHG|Q@9a{oJzlHS|;1*;09Agf__z~9y1Vpymam^IS4xk9E ziNV)-YXqLjYx{xK`I;IjIn+B8hO}DDQ)`9F@XS&Df>51zYErP^w-Cn_Tayfa<*cBl zb=q-ZGHGPdRqz3?X`Oc5D6JAO)%bq8BHcJyTUDoPff=6rODoN7mRm+NkikZ9IMX-@nz%1a7L-?4#nbX~jQO}`%|4_ui>nom=c)Po>2Ql>QnsojVs6O}bKOAD zpwYI~d8cdn!0KR{%%A0Fd2QO3n9uFwd>!|CevR5V-7i3&+J-s?-!BX@fYmh6S$3VS zX)LEtXSn&Cuj#<`Wf^b!nkHyP&@37t9fWM~2&n`F9FX9aU$HfFJ93!-_%lzN$Q*rs z%OTzlf9NSf&5x159Y8mEa3#~gAXJ8Y*xq3%a7Y9PRR{9`v;K+8Z=FS8@H-s>b0%~Y zc$5tQ!Ew$__FI>&O}CPq0>@|@H>wOT^iii;c|xSP0J|PA%&LL_KnX$DOEcIGv!QMD^?Y%TX3m30NjP0PME4=-kxUGz_hPc4$Jos+NI|^M( zpcvBB7Lp6*;vu?DHwq0Tc6!JTaD;JXuHl)o_gf2BT7x#vI%3TKd-TqZ z8!1f*Ot@ie?Zo+@OeW7}^Gor|JeX0GuB1JL=*)xxc#VJwME@5@QFcr;}R@a2%p z>Jl63cpTjLv9S$q6=l*s+y&^<_49C>cSxz_)VN&K8nQvU5+1Tj-gbm6{X{IFX&i4L zxp-7956tF^n0(O}QPBe~oG$XvC?9wd4gd&&$jRncu&wfk82u0vYW@~j@FsGcSw?5y4^Y>x)26BEse4oC?ldfgd^nIE1 zAIk#Q7+eH+LI@mkO$@C<SI4T%CHu9B@SB54q$>tLBBo|Hf6 z&{zHtiHLsrn>;vz735sYZ@7en9OROYOeMR}MFb6r0wb=Htu15pHuUH~SFx#%40BxL zNLyb6A4ix8GPWLH7C<~Y!YmC(9EzGVl=sWu6e3f%^=C;%Ux`NS9|texLkc7&E8tH{ z)GJZ8VVq&^N1Iq~nL92Jf*N5t!D$fMu`wlVX^3{L5&=_l%=PS3v~tG@7pTdjO}0n> zBYjaqT{*O@^Ny{Qe81&QL+o^+$Kh^5!3T+c;>s0d%~R500_jQlYZ>Bc_-pylWI7LS zuyuf~zM|_%4Y47*Fx7ZK;370-GhiSN8cRb`);>zv*Wyx)Qq~Z-YEDb9HP-Z`!8z2t zb$T4W9c-d#%3n(?obD4w4GFiAR#wNN4;IlthAQ#_BMlW=xaKjPVKoh~ zhQA^-EMjMb_hRK3eh0`R6gf@a3G+K|`Vs?npB*9zm=Tq&jm`=dWX_j(5`K#(ZvG6E zoTl8CU2N|}mA+u@^E=;RD*6g;h$LV}^!1ZBOf;@XRG0<4NgVe~=wutd4s_E@X5-fa z>U1e)k&?%xN2570PU*;+m)J%t-LwOOD*E8b>|i> zvD&E%h&CalJW~lhLHM)b)1tq@m6mPtJJ&ol$6ODk=0*;iWMOe!;;ebqvFb6jbMwbZ zpoZLXu0dk|^=r^HhC2rCP||@iUU7iU4{eo>!qzlGZ^&QCMNZVA`E5rVo4SS6sW2?m zjiWQ%aj2AtFdO#Jj~$k)L^!Jrprr{CB!R2&W*vwdy#qLdpsDHll8rMR@i>~0*+<%%jq|4K=i%)%hzSCbadhgM zrdi8Ot2pG7H>~0>Nuq3bWeiDVM_kg@6=bFbr$2(@oXfurS%sX0KWHkRMmPw;!xr#0 zjg=Wz$4+CG(k_d^u-5e#YJ@E6g5Er#CVmjsnqXNOn&AzpBSnehOb*M$-HdT0uE)c!`Nj;w8-a%y5x@ZAYaaLu}7j z8!EnRnE*{+s%)K_N1J8V8=OE;ng3c$8Y}r9_YQT@CA}p&eZthwrRQw_ljMt^;R z7{#vFLesQphj;@EwvVc7!nKYxTVe}pI>Vrgeo4ZiCalwz*mPVNL{JM086yBehhRax zDGE-^=>HY&4fz|I9V9wcuthm^4XUeSd&xJVhN@-==KAl{r3i;;4cm$Fje(OV`sy$v z&wtEuHY&qRo9D<@M@yCKt;Mt{Xm-lqNVA{@u*F25zs@^d7iuUC%!Pudrm4-T;g;Q} zuW8I{TJm+;dGiFW5X1#{pI8h*TfsZ?K@n(qBg(*60lJ#svSZLFagQH;W?|66)+xV{=C9>$gsbVyTg9+Wqx@liu_NltWvFyH**CYJht9CE=8r{K zOB2_k;L3kJ{-}eBvPrhHMvo)Qht9M>oF=xi8cy250v#Lvsg5_$@8Y>Aj>E*7&*{1^ zxg6!iD6SfVw;Qv~(r&dNMam^SYz-wXcF3IV>YdudpR==ik1xkrWHR;EZ1|*dzd0dM&jfEWm!grX5a)O$B8Q!&2z{?FF6r93x_gZwMB&R#tR3%#5st z$qC|`fsxSE1n(KGud}Km!+T zlU5?q4WJT4avZ#m+la0w(pfu~oqWke(U7S`F+Xgn%i~EM8~Y78#2>sxeq*i@k!}FC zrcc;|ldXB`^Z@A)XshL~?YH_^zj~$&>Dml7*8H)E%_IiCUA>0LE7?`qD(C=Hg^grC zUs$632iOY;E@l>9ZcwRi7`p7r0ieAbbpa5(hBfR#-jD*W1hz?~#_+?E`Jo|KLeE&T zHK?Q0JllyYosC~vgrhIdx2i#nyITKtRMk9FbIker(QFt@GixJ0jLxc>0x8SV5l(w; z#SYTQBk+aM1wP`TKfcTqp;TayC{4Di=S*XOX)`nv_IONU7L#ri5AguAj5<9eXNZ4@ zZc|vT5Mn0q2(<+{otJ&^UF|uSe_ijHqlg-aHp36F6x+m^RW)tVtU05m<^Cpkk0r#r zq_wVWR~}XwZ=sHBYQr2GN1jk?&5-_1=^RFDgE1*UUT$0`}uLSLv*daj18-m z^h5)6k`7Cs$O}-brA=im&SzR+xsD9aR9$tzz>f=cE%#|Q!Zoy3?ha;Uv5P1wFB%P9 z!wE7n)&k8L49fUvs=G{0lh_w$DL<33$KZ+gQ&Me&AeU6EsL zaa}=xBti`%-_y{O*}+!0fLHm_9T3>6DoM!dP+20dH8`#*TWmL_wYEcUL;9M!Tv>9T zrX#4Uyq~ig!)sB9A@pd5q}Qaag+r7rwoajDn0gcam2JWAd|eKN6KVyd3571Q`iZ5q z(3L3D@f$=HPoKV*Zy77zlI)w8MTc8mQCML|Qe*;C^m%R%#8CMmZWJ~N0msPi&=<0r zEfiHhwU{COg*2w#sNl++N=zihzaUSRec@Ss;EiDaBr-er!FS~Ya|9Qsm?MZ+JDhUb zVR~U|jybPnY0X!vv9d{ItCj6^iXE2a0v{4l_~bpu(?>schrar^B;DDy%*}%>ts4+V zx@Lv)-e$Ja6^+CU$&$Kdpq%_UfQ{Gb0kmw8L4?fQP0Z7k2BAW;_*dA%Z(A~9C{ePK z7A#dh6UMJ6?g6ffYBj@}gMCx6~nlfw<4oUn{o1s;O zTa$yrSj-oHN)$Oz+IB$gN4eY@5acvE>62wlE7u0#meWEnMk?FlGUhl6*3nN{Tu3-_ z$f?YeAzboWoB7HQiC!r5HYzQ zcFR<9LrZIwO|k&Bh6Ns2E?1_HSvL9}=uB*xPgPN-9?$>axgyeae4o+iVo} z7*1RV;V5Sfw)uvU4;Vi+u$D7nI@ScydajJQ6JE)s-mJ@NKv^xYXcJ(C7CgaoaPafv z0FwpuV`yqx^9;sjHnN{Ld^C2M-7maGO{`iXqdxRw8KYXZ52@`4N3v8{%*C$ox*n~n zGA0~L@}dh{q3H-NwhKx52^D*3*uoG3^b-U{n&*Yc=DEZI0L^%?7BYzvr2qgx07*na zRPwAqzk#e`5QdSCpsy{`tPn!@3?CVU!_ezkoneo8FLZGch&C zd^N468GT@g4|+6FqRVhi2=US$+PRuz28TI&kULY4yxfI<+D@s#;Xw1kg`T3$`RZ`=n=|1xrS@C-{tIKQrR%DvCwi!9w4dz9j>+OizgK9sawxd4XN4Bd2zPhZmIqkC`CCDeeTjPj6 zQJQ<(s;oyUqRtt|`_&KbSO4F?u%32HfoW_P;{Y45i}l2TKDY3is>KhyWYVw%9(c zfwG@bKRP8t56ULi2m0J;{V~U}hD>53a#XAsPkU8i`{%WxG-1i$9nt|D8D}M!x}?6T zPizB+pMlRqdb41fae9D5L(H5{TOKIo-%R$Tc+2n8kAs`e_%msj{Ic1!Hn%%a>*WJ; zrrUN*p-aV-sQ9-T959SfC{fPr@K?<@oiI12UaJ`)JLMSJ6r(7}W&<_NG&K9x#h%4o zX>rd?T0Orf26^rLXPufr)KM8wbYx&yw0n;_u{s+ajtLDM6ABYJ6U-y{Kk z2FzJ43g~8`{7etj&3To5O?9%U#SUk6LH!+Pag~YnB9>uMdhCGjlAe94K%9tq)!pU7 zu_)locv4&!?!4-j37-kw4a@Lk5|zo}I2=?NFng~G$K<6iwMW32IV9psq=!B*FFH;_ zv7oS+Evm8amyk8Vnd~lhpugbsEGmJClykqzzLUTN&VuI!n|?j+LUJp5)HR|{@NCIm z)y=#BwB2kOJLsi6+X?W=JPLvbI2YV zVIUk3jPh}zp#M=$>J%8xF@IbPhLbG<3LtLC9T_jK$I(wG84N&T9t}!+)xYTP1R{xwa8A6llCxiO=`#fI z0;d_;0$$;^>+~TH`%gswL75M!47QOvomV?DA5fR* z(8phcUf29FhuLvtuBV=SK^YebD&V+w?-=nJm|?Awy5vT{&zPKopTur`AiAeM89VqT3`o5q20@l><)f(uedmqM0p3@_YTWFYZ{`^>_Ab6b!ye? zL1^jNTFh<>1}s*9DfSu^6}d|q=;v2fv9P8+njCP{SVReQ zYW~(mYnqJN`Y#=Zoh9!ip#QTW#K6P$^V;2GS55tWpH{$=x?P~njM%Nb80An?gfn`c ziCgs+yE1|u*9oOdG`X`IiSxws46zfgn28dnjVt+Z*af5Bd4(s7)E@E8DyDtI*-_ix zqdQ))yQy+9PvZQs5a1LrsL@NG7AP)6*g@rXTfL($)Kr5^2&YF~IP763lKR+5pE6K+1v>vaSNN`?J%|jXdguo!5B@qixv5KBdJ1Cmq`<({=*w$0Co@#orkmeocM{ zGSNK)6w*QO98v7s|zZF+h|Jy zdc-kr2*!HBWXqb(#Vl*%PR69wP`@&{8BTCDIs5Fj1HYy-D03aU5qfQt*ZsKObnc3 zO zt?O%A_EYcdEbfzV9PqPp4i4zTfZO`m!L4&9wR^R);#{&ThQkAl1)0vp;9;@l);PCU z&uP`=#FKZ&Sgc92XwhYHF3`)41A1`Yai|<9CleC4y5~iVLj(PGgTxK;17~o@)S_}Q z?8Ck>^pKAWj4>8K>Wp?{kz-*yr7|#&4_h!!+Ay3tpl|QdD!}ov1+@)MD2p;};RS7& zdpwkj33lBUSpaB1$>Y{Ge}tXX{Wz`2qz}>V=hYE7zM*WCg$Ijc>0f^i|5pt$)`M_7RA~LBc*|C_6IZu9x!R)IkSM)bph;F{U^sLo?`q@U1FE z9;XYz%pJ(g6vUrhfpT5!K)Z40ls?aKj}zLkIjc&e?RKlZh#hDfj$F`Z>|`@<*P8?2 zaeQY)b5Na^`Ga)^Jsg`MoR=WliTRoS&c;eTe+SVNe$d8oRh(#!NA6YUJFCvcMK1k{ z`G2bcnanMx)xj8FtSLKfC+10P;^LXP7WvfmPUX66*t{ltxeH3W`PiHBp9yi;s5OEz z2Q`w0*8);d|aTx zE@*UfdyO3`cVfrm__hjXL1EV?oH|WZpN)Y&&Z5s*Q_g2{`);4g!eqb>HCLz?t7Pa| z)&j|5d0rC`&Ls{6lhHoegyY71%^#By3peeLt?d4BCLH_c~=8y~Ok6h%lIMRRM!x`po4t=TgGi> zwLAR>7@bT18D>i&&FYD)ScHy@e zunS(|s0Y=-qUsGB>BIH>utm{>ljqLMPSTr>Kn`vbgQWc8ZY`* z9_jpcw>ZcP8Z^_<9&^$(M6GJp3*#6`SQwLhzPhL#^SmcB4UJtHbQq1BxYfi1)BQlX47hJ*}DLbmzHBpd0962rqxNF1C>YNrI7Pg_lg2sY`oi5K1 z-4F~L(L?!gygZpAC#q6*02`xvNaTjFXe+f1cZYajmkT2Ilppb<e-X%@+*JYa3ixR#JtrkN(L7*wMDwRFfUqMrU8n}x zhl#ws#dooA(x_)_5eBSrnqsxUa-v~658iiTrRj6cNj6EW!t0=fy*kGie;XTFG* zs%BhmMV{C`BLn569F&vLFJF{VKg^T6RM*R@({P$Kr> z0=6<2)dnygnP2>1a?|%}qAHCoE=#19;UQ-3X0bMLSI={T=_>YZC(%WpzE8(k8B2$? z@ep^3r>SJy){Qb^BW=Q5$M`0cC}(!`s~*`AMla04;OGwk4cL*s%&hJ`ov^IlvaZ>b z1u1sPEmj8SV=V$q*?6lUrt%Qa#K9FLw`IA4XV;3s44#JLs*kgS41DM>M!+fJ7QCIn zAw5zb4N0dW9r^7VBj(_jtRY?76$>Aojw1xinng~@dkQ`JVc}$_iv%W)b$&FT4wcE!a77+@q?*1$A1= zwbg}yy8zs}ubp!2qkQaW0bK<|2l>7nCvV7!ZIxYJ#{h zq79INLr*%!u=120`I7|8W6U$In4}r=4mir234@2Z180z3W9pv^H|C9+7n@jE>2ElT zyTnMHQwB(9l@4{4{o~4ki7dD{AM`b35a^5aQR?u1jlC5WhPwyQhkntW(L6()aonc1 zPq}7Jgr2^{9nB#dY8bTW<=iR`3dedvmk{(-#ua%VD^hYQ0lkdNRh5OgT1Aer%>(n) zXB@{8oN*I1pmaFf!qH~5mv{0RW66<& zL*s%<9eS&4(56bJNPwO6OZvz3>jR+<%uRW$+s7q`?g&$C3#Rj_1q`fh%Tsy(>|Rrs=wV`DqGUi&w@jAdP$=H9uF68{j)(77R5wiA zOp;8F3o0iMX9Rd%6aZG`QVV~r>LfM^TQe@YS#fgPT*bM`dF!oR$E&zz-PeqybN`6O1k;lMi zSAA3Am1eQVodfz7&Ie`b+ZKz9`X(1QIFekL4EE7V?j*@*E7dy=5{@Kw&f-j({=(wi z4=k=rY6zSd79jdDJMQdq(uX&)i?Y^%`lO!Ea=So{g+7wV@Wjc2k8JwFxIkZKcbxX6 zkI+A;Px`w1hIz4pav}|P6<+kk(F!pz*YF43MgcpypurJ{TjdBWfj-KPBmL*JR!oi| zd6$g-w?`F+lL-rLS=;EgfS(K6O02~bVUzvSaAF_*EU&#MeM1B{*_ zd5z$0Bsh{l!&6_p{fWDdzViso=V;kc4`)f`;;x3K1=SDjobQT(=b}7zBnOP7JRHm8 zSPOS~*b(%dF^SqV;8>xbew!~ks|z0KX&4XkJN1{R0Ny#lSyO@6n7G~5gO$9 zLWeOxAByKAWF_N*dW&Ze)J}Cr2>tq@DH+oQU>l8P`W(E>`^7*#ge`r>TF5ri=vB9tQ=XYwUunaQ+HM&8txX=-*C|nK1$4&@oXmIpd@|;56eTN4X80Qq1DVV#kDx z&LRYQZs}9s`7HRx@(WuN z(Ka~5^sAu>6&TL>ipmp@P3nfhR-kQln5Hp2nJ7LIiBNBdDe zu65WUL{Dr8sC>xL|4_!6UD6zH?c71!Ihd?831S9vb=wb>bad z%8_?u%g#AUHwn}O^%V~XOBW70ZRR^})IW0L&Yq6i;oOdUy)5OTaP(8`bBsLlgrLdE z_%*x&1&!OCz5q}B@zZ1IWot1_TE&a`iMsS#Q@E1>oE=*>fN)%K7UNA-O8503Y!Y4A zoi~>9*r#t3m~(jp7;Q~mhf}UJJr5q*ntsZ@%&70PA;iTba~&c7MsjLD zGAAy$kj@b@kDaM0xCnyilZ>0FFZBod=hTBb9RFfe^*TYcgMOyn9$E^f7-Svh6SASs zW!TiMFJg&$ngV0kv@5Mr+a%G&B+o0pc;yrMS}%F}42uhkAh)D(Tv!W$9~Yc~$}Yd{ z7I?z|9g7W<6}NmzVFQix7V>od+XQBI^?!U7kuaf#vcI(lseM?PEZwU4#Kg`^96GQ=DXZHlglzGLxfR)zo4~UK)EjNd-I=&!skCj4SU+rH@mf~qc;;wq zgPyU%=D=|^*daO9tb69o8b*JqkGLZw8T4^B7&)#;zhZ2u+BTIjDs>cs9n=*&@LYIs z1dby)l+^)SprKv>b0?{}i)f-L$JBk?olqxa-bR0A(v-DLPaNZgal_P1TTT5njx>z^ ze6%VoCm*%b))4)7nt{1*Izbawy8`E#k!Ncw^tIVL=I@ObBNyl-1@Iu^TBd(^9V@QXzPn`?e#3IRr@FtKs;i&U^vYDhj z<7|SLI%VgAI%N0Yj23C?i89HAnB3+xlf^A&O{|{Oc(Vq#H)$IdN?zrKQ%zstYo<)X zlmJJCz%DH1qVLc)#GE%rFER=2(%`JHgMn=ul?@#A#UE*gN#rGLi3(K((ta$MglJR! zc(*)51GVW7JD$+9sGU<8@^O~&61XkOZOi*LW^iihIy(($qrNz5gwxH=VN%_FLdS}5 z>{!ITD;{krrM`Vl*~bN{o?SHBjDAiVac77=#>cR-qn;fc%cx7vIEV{h(dO}Cs^OR; zB0Lz(xSA)M$S@o}?$+>d7#9Xmuvp-9(I0UbIBvze#M%9>JB(>*J8tEV z3-n9gnlh`HP}9N{eTuRXASt4VC_5K0o(p2m5em}pHaDxoYyM> zhl1$18{!8NefNOA$f9lC$cyJwREaoUaSTCq%KU`}`Y`>FIXP^i11nJf)H4?iGF@X) zx`=_5n6&=KUogcF5M<>tQ7+~>+L#T9>`;hC`a+Z17Z}z zX>29Hv!=el(G%Wah0WZpVg9h3OgZ7NjN(Y0iX8#_IkJU=#lyc`WHpuB6G?ffD~=#y zBl8IlYBDb$(KwB{P0lav&xO&rAl4WgJd;MYu51)<&Om;7Nj`RSWn1S8uWTx)jr zIs_!JCQMl|%jQ&q#(injGD)@NDe?ud@s6jUnSo4TLSfhHyav|{GnfLu9%$^KUhKf6 zx-1*G^2XVSsh-JEJ$+T9jQ2es(j>G%W$BNvmd4Q^eSQzeoCRLvLz4_V;?KnxG#eC7 z14X?=gO_p=C?)-ec?idai4#Xi)e>i}v1*eg2r(l8@zgbw1rD$mX>>8k;2g1=1fHE| zc9kdx!4GMXMt6?ebO1K{5gQ8~UrqD)pnO~)aC>@}h%HN3kn-wu%isY>c zOtABXnKS9sU(glZab8q1s8e?49b#8QGN=Q}cTjD3NEOaSP)%q0N#aYS|A6eL{k4w7 zZmF!)KP`BwOPnRCRA}vp?1n#DP*oo+`fzEjRQs3lIKW4Fc8Q+jI@H66(>cI1323(j zFKDG?g5RzBzErCxZxX;M=aw+-#d%52p65jmYc~t(w<5bt;~2guVPVV1rv!7y=!t?X z-fOQqrQhoRrnSm0Iq$xg$>BKcRvA=PYLi}lhAcVlqQ%*11T%Q0kFWhPE-;PBc5%SQ zOVt+`Tl8T{HWGw8Bzw?-6SEi#gMi~gK@lNQW<=eIHTo=0VmRX%D_OJ?ju0_*3>}UR z;~{93-p-sc&b)2IW!I#E<5{~^2WzyTnwd0C_uwmn zB_14oj^{*Hq>M36ppN&;h8gvzXcyTU$u8au!g@&?`@zSB@` zAtCvE2oBr$fE^c*TpUt=T=YaCHV`Mas39C^j@@v<#9dF`+I6We9Jr9jL1*WD+yl$V zz<*$FKSakNuT2xiNZ1gN>Hl!|mJKxSKyVzBI^<%AO&|I}^Xvl#UaTNJ^aCH^!3OfN zRtk49RZpWxF`cT9<9l5kTQ!zK$$+(u!aUY;&l#ZG28gadwGabX#`nb-kUr=Se|puQN6B@-Hhn2DA8kKGt0aP&P9vDmWk z;!?2CZYM}Yu3PEifmL>@IZq56k?bG&^;G(lq*p}9yCj(*tGrfg$p3y%rfcOgw*#k96r2DGdi`2Rt+15q1N4_hQ=RsukL-5Bu*~heb zXy|v@h8DhgN_?nP{S!yssSYBB*$LShn*@}wn3!ozGHUncx<9=X_*mQ#aGmA%lyAQr z>=siS)C=?LMtzqH8}Gzty_uIyl01J{5^#lRlcq5;toa2#sojNEQAZl=`lI`EVZ5xq z6z8~C@(2I81I60G`h|l^>3saAF{30HN~|NyFRZJ~9XOaVo%V@0$zvnOF0q!ohFl!b zUimH>(8&Tk*vl?HcS`QlF_zdl*VT01IhVZ}&s^O0h2jXaTh4mGnu&8SGvbJ3F5hG} zY?X>qa|{!E@6jDa>J{T~isMce?G><#_Y+40r5`<%9~<(6=GnH3r+GS(>4Vr_mXqlS z?MR`OhJv(eq6{nsOguh^WR1e6>X>6&p47wA&{kh<0F8o-E9HFw`UVJK&IW{9;)NZbNaqjtA}7$zW07suyicG^pUVI)TY^J0aLAUXIqyo z57=(VdVz7VlpD{SUCK#w*i4t{Th+H~QhBCpF z?Q5Z%$&Ru$e49~s@&7-2=K&~JQT_kP>+hu}R7nD%7wJWMFG}x4<&UD+JNAMVQ9)1; z1f|&cAtD_GK_FBC3lcy;igc8c{_=YA|9;NgvwL^n?!Mi9`;r&kN#5Rj=T13uX6DX! z&YU>|s{s(@=jQDrrh_U;vAC3P@Vn@fu8+ZW9n&7npIq8`@-W;F&I{jSF4bqOy%me^ z;got3jub1)s~8Q-$3MT$vu&|5F(kS$oItWarit0p;dQyCjY`e8EX)D;VxiZPO?Bq7RO5Y3nZKRnSeSIC{wv3 z!U#HOz=9i_im`+=UimV?z<(sOCVez1QOhJBc!$*;R-ggvai4_ngM=X?Nr$qo-l-5^ zEZm3za~OrAozpP6wV!xzyu;7K@va80X24wS$tpKWB@0m&JFr#q2-I{jz5s~P5k;C^ z^CqU+Fw<+j-C>}o$0X$x%_@+J%wt3TbNaPcdgPBbIQI00G5TxL$sbLvZBo%_v1LBu zkPaIlDbY%8*+vOi$B)fO0|M(TTec^eptiwGoCa)pB>v@Ihy&n99RZ07gAQs#6I%u( zpq2f464DRh;Q2_pd_GqC?6jrhrSJWHp=-I}3dPO$E0$I<|NQvHo-2)CY4~FK7V|HZ zej&eN@zeg*#v_eN@6+^@lWwWF<-{*HOg>%d=b>ouu;qsH@T2{jE>HTt8oJZ* z)AvKAl@6P}A1dBRz4y;EU<`U#WowoAXPiKK=MqWKWn!(=IKe~%0FCZ5-+EiIt+HSM z8bCQ?Gj#Ec6E>{Mkp@@oLEEhe)aZy3T<*2kIQCb6_-ZfbX|ERzAl{(9$P(+N)6$0GA%qxJRnYmGK9O zD;q@-%%s!NV_A-L*f+u0jZVISVY^p)ChaLJnVdk-!NdZ|Ih)W?Wx>)2l4}`MgXXz%EO)n)YcF}k}XO)1-6=o6RCSXla z^bB@j<1~AL*l&OkF75*&=IC3H5EvtFp}$Eg6_D57)&y@|yB11k}#e44B(u zD^q7Vho|WRTD%@zQ-wY zrP@$knW){X%>w0c7QecybD++FItS_;Sn)aF-}4qha)epk5(J@>?qewL(aA>WW1E`w z29+3(S1|_^k+GXG+ctzNEr2JWaX1b=aneOnK1cf7^Afd|2?CvSG-x9&hGul-5!@lT z!jvmyOd<2Z**kFY&*%VE6xnkan4QRZ!Ao`)NC+(UjRXFYb75A4e zUKIKkcPW0HO;wRl#ZllNX0W;!c7}%T#Rkfm-Ez1BrrI)2JWf}t9yJpd-6}sbY@Vxe z#6t(LLt0(YzP53-nxyJ+>Kv$Zpw59h2Szam?0c`#qJ3$<#*ReIMD${bg<&2*i{%z9 zx(LHn?Z}5Q9KCA5IJVZ2&?3DBKvp}-05*~4GvZ<0ggqkYons{ja8A1Xfg75X=R$=A zoZGntdI4S4I8WE(+A}|L;15ez&-vjjK(QYM_Jggke-6z^vwh{FShPa^?6XB$PMI;@ zfa4bx8nY^;_o%eEo;!6CLs`&K^@FgHI-`BU&k=FrQ^xsi0>W)|M=$ZNHILF5E|Y(C zH#9pDh|-TaFGcd0kRA(B>taOan66tMi^vK4px9l-E-|K*c+RKJsVsg)pL`%J=@clJ z^xCHN#RHGX8*3x_>FeqallnWu%F-`y262~QCm$#z5p=rB%N&bA@=QqhNXPmck>b@8 zt#hExfjS5392i9$;Ct`j9H5M8L?o>MbTi#41W{u6DXS!a5G;a_4l0^}V}wwc53VL{ z#z|B?h|4BMfIcQ5S!KpT8`AzZ@>2lDN2JGK0;I%9i>q zgFk@p@^;Xe{(~NPD|>hL4?0KQIR)gzeX8B-k$nk?x4j$gPnFHEaJH^?5#MHO)FkOn zwb367RydXFe96uKJaz9BG-@;FqzYuIijLSOsd^<2Zs&ShdQ2 zZ4AW(V|&L~Th=O+GJJpsEPFL;mR+r*-M6V5IA5zxryj4)fjS539H?_(#o_?p{kEk8 zVV+F+wnz}fIEy5eRq0wd(qnZSaE{LRc&&P4!gaCk0gG5m0YsPM2nOIqqL1!07G?n3 zoD$k=>FZv=oYO?HVg^tJIUv+C>A5}OSH1(hvU*_BW!*yqDk8|tu#yIFWHLY=aU!67 zV8SGNG;mx>r=2jQPuf_CVRZsCm*hej%XOTlijDNBeuSNR(o9wk1jb1Vt1#G+hejp? zmb~r(!4yjWm$_py2(9D|wYKsPZ$%t+OS@o|B_VOd=!>mB;Q*PLi@8EWx$V| zFc+spoq<--3#**|2xLXQdkjTY74Jzpojetao*7F%G%WsLD^$_Y0j%X7sDkAcB;}(5 zlqbA_r#)T0cGyT`JK(&qM4a-|h?yNz{z@Hj0Oh8_Q)%RS_&Nvb9H?`k&Vd!41APCd zG!29WOPWj<8!(BGt5$%7v70mjpo^r~3>Zg(y+AA7I6DEd$o!)HzV+K%E0CCI|TbcW5l=)L76igorfS zKwOQAL;6UAIgbNSh#CtL>;{cr3j|&nuX!d+v9ZY0K#)fKsnRV6P;$yCU>K<|SJZIW z@QI!^qaugavGK8Z%6gUOe4OQ=J@TC_+I%P{A6YlHw0-iDrdh8I*aGG$6P98q9|vNk z!;vM=OjbyobSVpf6h3)=ge&DpgUNvz$H-*hHwnblBiZoBL;#67=UX5_XL7`ZBXTR| z%X9pTDowBtp6Gw(3h|zcyeoF_gt7y~?ZjQfFsw{yvo2sx{=9>q&Pl zyG#g3Auv~Wkbb$%Vlq9JB2ZCIdb~#88kHbP#w&)#v_P|9mz3w{Vif2HouD*S zbqWcep-*&=k&W`c?w(W`@>3l8d5Ibkhw(Hu2|!DH4Le{_zoxMRoJTB|<s2WEaEp<>{4eRQ0>Kv$Zpw5B+Dh>cUCP`}Er7^opG0#N4};Ox90 z3&NJ;6wT@z{7rhSD3A^*uqv`x?E&jR_(lJ6QmrD4M#kEkDl!=y zrG~&fsZ6U;m)ynzWhKQbir#07IhdfTeN3Jar$d+Zp;DTd?0$2@xn#jGUzYMv9#hU9O?8g zUL2anO_2TB*hC;0y)sg)bcqfUj8EbS(ucaMbD++FItS_;sB-|7mL>^nuybDBXjPhr z8p=$vJ5gp1=Lt|_A`lzosGV?N&>R_W;B-pc09jyYB0lGJ^y&{liC#A2J?blr?Q8%= zCm9F@*u=$>6iCe8=Kc_hLy7u?F#wDJe`qTdyO1BL;SZ?o61oN8QXc3j6dpgoFd&f0 zMclYrB(S_oxkBj=3ZDe>y5)+hh)sqJcSbl0R!czu#jUbReCJ?v?)}ubSVJ`8> z0Xyj$MpHcfFX#}mk{f2P!D~r%*~&trCMQh1448{9(lGK0rZhZHqcuMXWjF{GI@9vQ zR`FazU@qA#oH63`lj3$-8q6tQBhjNtmKim+NXgpB&=su9tP95l5^aerd8Sd(;j^ZP zqOKSwm6iyUBkfMpuV`gC8WATPPnkR}w70A06>#vPt*zPq7A))v3l?-$l^68&=}XZl z>3%~?yG?#5GiBoAL76FgrH+p-OART-~U5WKUM%@fgppwHR39wBW_=A`FpXHYx$d{tq zUO?jOnJ_qbhy(DuU-TJ)--H%VIV)(!ClUhw%o6V*tH0I|n2SNiBxh5?xjfp;xA3dH zkd;5G{v&TlUu2+{M8z5-^-N;`lp_%*2ot-EF1qgBp~uyxR@BtnTNO|y9e6Qy%DB*~ z6FbXs6UMh&Ui0TKt};(`1gYFKx3tL8c!PBabg{-3o)Zj75*@Ee67vzNsv+~Q2dHzP z&Vf1y>Kqtd9AG?PTxc+OKC-l7J;lNKTuI82qI0MYcDOsG_l)2PaL%E3ppNud#WM~+ zKrE^!07P`-k*FhmCVo8iGB>V8%=;ldq}<&S4Vzkpo>O?)LW3vB53q;?8QbY>&~#e- zYC7n`B4(Bl&otqdQvBf+Qhjg@_qG93H|WoEh6yGi`QzR4a2c|b8|XmB4ahe2S@9X# z0nz*c(xX&8ssK?3#34KaME=l&H^5JLBFouDVbDC%e~SaJk*K3OQ;dJat&M}qtaCvv zv=}vd9q+SYhI?02V4mIl3B7R{Vi199Xb~00uuh1c7pFF9#x z$}pfpH)}G{(Apk)G)1Kx%tkA1G=z6)d4)x<2d{IW&Vf1y>Kynl<^aPPDvq3L!Tm_O z2+lR8OG?aPdI;9|1EeB#W}_&;8`T#4^xvG1c{VNz*)a|?9*z}A;GKa&AwA+LdRb#6 zl3>y&9Z%n{_@VcVM4L3h7KB2|AMYY4B|JbAe1fGIQ-kqDd$I&nyq8m#yki&)(TT;80bmiB6H)6)^wrrh{RfVyKJ^LE$ItOyhbc$c|Vl9wY4dX z6UfF@n#6<&?V*bUjIwoJUdAyuvv1zi(yB(zrkU!jv5=Gl^Qy=gf?{~YU)s@Td?uRq zU(xQZ43uw0BYYIl>-C@CHkLJM4f=-hEvvga2kIOc%mIc>#)o*^+~D`M_A#{$filbr zvciqD7}JXAp0l3}Fel?=2F%e%MhecvK?2pFty|Iw*VkD06OpbWre|38N^G>IQDCG0u>|Pu}a};xlCl3NWo(q zaj1)ta*Rbj7I-#>Uy^$De7$M-^Kh><1Lp7vhJk#u18Xb_yvw+phm7y=ZT$E@~ZHZhQ5+UM~A)`RXQxUwCnVv&d#2yq~=Rx<)y!` zUmv9weR%RqUOrJ#u26pXazc8$Xp{w+rluy-!6zO(!$$l-zv$Mp1GI9|98F;;A69mt zA8=Q9m2se7^^c={EDuM$@z>qm6#Q0FE+(=u|>qgSepGnGv zUI4dKbA19G5;~{Z5#EAFUH3v{L{tV>^f^ZLEE+7$U zH}QO8KsrnaI2F}4mFA_%Xn+dMJkdE;lM3#K$`?>dTF`2viJnOo86*1HnQ;;iUaVlw zNY)SKL)(GBrQ*w2D^A=PYkKMe>F|No16C#Ai$&6fgPoYzyJNgaQkhy zhudzwE!=a@J>k_?Uk!}{>&-f%6tkNvl4C~Q+S(Exet32`_ShrC1s9x`0q84IIhF^l zv+^Fg~Qm+iGbi4Z}OSl5YMu?&;b){csS_RLQ-tWCB1&y#?-MH4I}E62qmoJu9h9Woy07fV$zKgK%1LT?2r?%rsj>LXBJnpyXToHk zvUi!6Ro{48bW$2DY#hol-uuRaoUn@ABom2C6RBs;yD}a~w@FH0*eV~6Luy{F=|tmz zB#&byT_}ea#poPP-25~he050@`MVpg55M^NCE<^Myd@{tj5XH`C!O?xaNOJ95vEU@ z9(sC)XFEP8@n!J9hj;$GdEurTe=pOR(G|dkgc^w{QgYNtkYf8EX`|T zrH&YY`F^E=g%=JeLW&Q7#5A#)HH5&#Vb0Hhrr!HWo0ZrgXG{Tr;;=SH`iR-Fc+#VC zQP$i;gJRf`{!K_KnN5}pC4&w5($gVNctN|M4rwRs4>UC=`4Mh-sH-q3rs$-^Ngo_T zop=>HZ5C7h7N~GC75ml@m_t`ujU|~asw8nxgK{}5O>!T7hV^SYMoM9!IwmvX7Ii`j zmBX~W9C(*DL6m(hS_R7CJk(cCB~mxVqV|U2014>^P*GCK8i{k;7^$=j$%(=#Lw&85 zDrw9}$^l;AKJA;~%rj26On2US*RaW(-z-y-?cwRCp9AF z_10T|g#$#5no!{;OqgKT`cJtWh|+38Pt&AqnBAw`Omo7q>cMKx$3FgvaNBKv4BzIvW#6$MX+bUTHx0G+6*6a!m? z=iNA|qX5tW=8I8h(H|?t09JI-Q8giTKpj%_vHC+lzFHTtF@vnqkO~RkD*ljMr`=Ko z#I&PGNa&GXxbimBf9Zgw?iCk4AVg#%B;cGfBlklhPq~%#GI54SloOLD{wSB-8eVQc2nP}c7O zX8gIMjQU@5*Q|u;oQt#xWTKX@&`B%hXgF4>2|bXATChpXb)VElsU(WN8zSqX@v<+N z7*y|!-;(?{NJSvU`sx!LX3U0$#&F$rR~sl_bFH<)X{UWJ?7Qz8@7`wb(rt)Ii$A**lV|Szxfr<~kL5)8TTVRR_(8t7{Acq_ zew^0>;(a0K`SUKHUVrEQrSc)ZzxOhiQx@W-U4EYV&*`w-r}HFVp40UDeSW<3v!?++ zxhGGYw|LhxPsb^C?`e6yPLHLhbiK~}In5&vOI#dR{C3)Dm+<`auY{J?R%@T0hXvA4 zhDXH7Ym1(ihb`w?X?h->w9|bdeee7zCx60tnsCC)Bu~4~=Tka-I?pt(36pl2^u=T6 zGw(~qNyo3>|95bJ_QjVGDK~b-5vtMsY)@GHyqZ-LN{gxn8vrND=6Lc7?}84AwKO0N z!~)FuGy4fjpR|#3cSzKl51tDqHiRcMcMFkXAp~$buP0hdF}0W^F69KYGj&1#nUhSx zkT^xD$Wb;dy8x45{%!yOKmbWZK~&C(%LY`_b=Q5RX;qYQ5}2|mYn$L8^+!tWv=mDZ zaE@teOeVr7%AiZl>Gkop7#+3ohXc-OzF&HbKN%d$WDW0Hx*7s=w9cr)tn=bzeUiHx zmV!orW0y91XRk` zO?r+yrkl04)vqG=8gh0>#qG6(FH*;L<1?C}Use(AoDY10Z%eQI$^a=)qRf956M=P@9du9UjbRq1!SKE_t4W6d+sQzAnRFwhY`7SfS>y^D7 z93($V+0S0jv|HPutubYhHe@g(cfB)~JQ6&V>EJXFHWd(u?(2=sKylf*=LQ785uX7c zz*DX3lZ6t}sanq*ro_sKU#4ma8D>1%LW5>J^XAPBFTM1lrMC9WwZ$Ll$qCdjsN)E? zNJ|~-ufIXK;>xSTbI&~&rcRw|pbg;h?6c3>#>#2ariHDx+B(diKVQ!eg%@6UUK<5_ zr2a51Y`pQC!y0Q$Q@Y9S9!oi<9f4Y7c=E}AhNqu-I=uM83nq3S%0(w8`DOC-sP{+je?;m1C%mjavYOiDs!Egg zMIZ2q4&kBiS?dcA8U6HyXGQNLYNIc__=2^kl~-ED=$>NDxdM7oUS zwC#Kz)t^^<4lpL7M~H(4%fi&zWSzytDyT%l6=oTI5}eG|DMIe2#!T>h4o_ zl#va!jMin>gQVrqOkJYVGgth9Ph$n_Sw&dk0D7w6v}qVY3A5g{ts${|gj|+l>x@>6 z#Lt^hu8K!@Et*D(*H%e&TGhc(aiLKda}}Ohpm!YxlBEK)nTbRd1OlkL zC8-+@Fz?3r>M$L8=I|t8`7l$fJ~}*YZEdp+k)-n4>#v1YRYGrX)>bt9cAg)2;K2%| z53|4>fN$Km_VCzakA|a|sS6c=A|VfffbK9m!Ohbb#h4L`r+(y-T_drQ|b0?Nk5CQ0fSh6~O=FMQ=IUm6q# zkJG;Mz3~3`f5-qXlZ;{xpwiLNEU9w zhZ_ea^U0Gx6F%|rPlZ(k)_OJR02q7MYFFU3uo z9*^rKKl`OtM2^XLkstBrYI63&ADkJ!_ubPn@BHS%i!KS>t+F{@)4j3g?{jV{1ROMO0fJee{v=r7wOi+;r36dfI8{ox|y;pA`W6X(@b8 zhRK81!R*-&S$enJa`T{IJMOe|TtU(Vj`WBV>7y?k9=`PDQ>;DgzUxkd;vRg+Ax6uZ zYpzuioEJ)S;lc%B-+f}+N+(|I8oQhG*}`Rz|HZ z{o~Q{+IFE;t3jEJHV@Sa-`F^QWWQmB$b=th=X^^04Rs&W@N@>}m!C(W^s33XP&(!K zL8*B;LK`dd(1VUQ z)ryLY=Io{i&esdF0ioF8P8x`ORt(oIzAw~phAnYuM<*WvCgs&*Ma3ZwmT7v$C)9q3 zKVJe<+Jvo^s2vzU=f!Au(4X$ZdR7m#GI6blF=dpshQM4ck(wyr333ABarcB!0Ct5RbAhUfd%f+L{5Im88c~ zJixqaX?Z1?jc`~70RYs5i4#nscK`kNg&&-CW>{_2)xw67thThM*UO>SfK-{kUM-0e z&;RVhC!5l^Hw5b6`SxQBB;D}4KM1ssGeC9y_1DOn#yR1Dx9uD5xbvQ{{q{SWKcF1< zlb`%JeD*UZhcT-6OD_IZ0I)#U0}tFEzW9aDhJy}#s{yp#cH78GCxU;ElA;R^!NNJ!uL&i`lrg?=axp!~@vpA5V1vZI0W-&}E3 zIOLGS3~Vt0K#ITBmYZ7ikACbE;lKkA5?@-w97!wBJMTx~tnZ($a=#wVJoASp3HFb} zKKtw!-gx76dw=`ww}lISa-o%rJdu`j%^!q`%R~V12|c(|zWVjB>#n;?7qda9#rA}2 zuDLp#|KoGR?tAVO9-aM9lLnI(jyzvio~NIFnyk;<7_Ped`Y>h66a#+|7@r#6{jTG~ zy6dhh)uio>m#jRX2JyY`ofdwmy2t(G!iyzkUEl5>dF0{n-uJvaeBgs04(qPBo=sNl zMX8_U0|`BI=FZWcg>Pxar8C_0hg)qWsk#t*x)DLxlpJ|gKZ!;+}GPmh8S%CtSpY?-t!q#f{Y}o$&?{5$-XN9kS{giO%p@)Z) zKlSl&{skA>$_Ohevu52MPW{$5;|6F zw&}xs^WkH^RN{*DJHnZ@E(Jztg0O%z$|^63D;{mvziH${8fgbTyjSQ!jxi2{2TU@T zg(H_ml9FRzoP5#sb~O`dp-9D?y;mu)Lc&-czv9J0FI#`;!6J-Gki%za2DC>I!dnZ% z?+W3d7r=~K4pM(4{StQD3I=5>QC+3Wr-<|qSYrZFUC~~&gQCkq*f8ZY?cY2)QN5L0-G4n zo1($6OPbkji&_o$PV>+?6b?c8&>vp3s5^`w-yU0Z1&JUypAInVQ&61AZ96tS-&xM_L-o*gWS~F*wL>+YxKsp0XX224S z3_>`7>WL@5H=O$I?`qk8iu_}zf6MmUTRNvmx_#DJ-w!|i=_aPyK)!%4KshQJ*WYlH zz{BR5L18CJCD)MZzzHY3GkojRZ%BP$ldy`w_Onu@x#_0ghh2BuJ^Vl*W%JG7EZt-g zAX;|WWv76ez>!BB8ZNu+(s0;eM;O>O0y95sBplGhp(FhB$*00@yX|N|`>Lz13;Q2% zpaE5aqIPrs`R7_JB=8^n;70_i+l@Zv6q{|fxomrX%*;z3c+kP&;DZh^Ku-if4ASmt zGp2{X{Pm%*>1LbRAjUh>twxopfP!m8O@MxT#cw1b zemKmSF--uQVU|4m!-L9m%1SGRhaY_+Y`oF>^3z9Gv~IWEc9P)sgfqW?M!5Q_tHiS{ ztqz%d-Fxpn8Bl)rJ@;E!OHe;M?X;7rO6R!3j_!si0KyY&BlE!COT)V9?2H6NTjd5Wz%Tz>gw;R|2< zvZ+z=!DUj0i=c-UmqzikJDhX&56u5_pZlWP?q}5oCK{cPvcnELhE*heKjq6`3}>Br zMmXn3KhCH^S)|ktz=@Th9}4W^m>7Nh6Q2qbq#8v2qz4b)w8pgXd6{fH{d?ch#PVXD zjSwdc-WPuUvx`-KXNM1b;6t`LM_lR;^{3r--BEq*{P6B~zgH8>twVQLcLva_Em>71y$#1iC(Tz=2cSjd@wv6qD+j1oUE0qQ2`#iL5Z1Ztk15%@C^ z8CH=LpZcTDSq0&=RrJqUO~4utW+!`8$EY^hxhuoUY$O>P@uHQ60*Nb5s+y^tGV9N^ zphDG9TW25~O6B6Ol|oM{nE$#LfkxOF4fR$BpyWX#G25d0P+(?hk=9zUOFFd% zp8=%Z^A|`3pt(t>c&f#f=@tr&tdUSp5okiUHsez40K}}c(iBa6x@_>Hy)h^rfBd_` zMHl`gTyynR;ZvXbjM775+avXmn=F~{oqnctjyJaxRq-Gm!2MuJuMa=s$Z*w_zYUN5 z;~!zw-FH*^D@khk2aAVxjAZ1f2(qbh2NLY_Q{sfb*FJ9c8Dc3sdtQkJ^p^t1o&#hxj#Bz^lT=OpdHVNbE%p?`<1#tE#k-{k2VP{fPqHfdG>TK)r4oIu-|@f zHDLblfB#$IvrNp7G;5!dv&--+=jZ&pm7TP)6$D4~dG-kIp^c zKskQ)ogjC`-*Km1!WrK`D}4IoPnvYvGAZMyMVo1HNR4OCoE{d;7bsDBlnbyltnF9_!LZEq-4B*Df&FsJbE!_Ht z8w@ZLw@WJ#)7M-(?DN(G!j!2iWx`TCHi$E+r>($$>a@4FJIuQ6mN5IlKWlHt1lyF` zr49AF@BP-W&2~FjyP@qo_}2%*{ddm_pjoJssVLX6!cib{A^9LXVF`$o38R~9iH~?=K=%6H| z<O%cvMUPa`T41v;b&iTqo1RCNOBi6S(3s$yND8Df zBt;p#DRdYGsWC85h)!sZP#nU&5LAW2c-e+Qcr{TyTk4($K&M|K4zRRB!(b*iMqh-b zC`6@bt|l*{&zZAGlC_R7wj%-3x_py(CQUmux;m$kn2(ur0Fx)9s1OCuI-euR_!uP-b1CtgbS%mJ`j)F<@ zP($hN?zO?jvn6f7^wCEjCpC-j*k(qlA)BQ0&O2vWuyxm2SMegCaNr7nVxZhxfEQr? z;)^dxY8MAy!Z`Tt?d`L{6KQI+3eym_*nF$-x4-^X5~>KOoEEM~)SrCvpHg=?!o1OA z-c{v7LT=znz6im5LoDhmV|6G|XXj!I&kq2gv$I!#bDhvGb(lNvyfZZk&<5i~x`@h! zVJOl}>O7N3fg9=q07SVt3@chDiE2bhEE`l-z$FfeU<3#;AvXhwZZ?u<((*hZXPl(e zI5tS)&;{@5>6BFaFZK**#8EbnNBRr^+ibn9y<;%f|FWDfNpTAEjfYle$$R`!*TOL&wYs&o{gU~W+qUQF za4uBbMzgLtWux7oQg_{zzYgneuyNRR_r26V$A&p?ydHjk-BsZl`5$-ui3ZkbZ;w3u z_i*iR|1a!&z@cG7ZBVE0{YUlx`|GX@H{W=DIO3Rhh^MhFEz$Sox#ymdU}CEE!C1Eq z;Q{UEcvztPu%q4))>It;+yVPHDvdTx3b)vD8@2ELu-O({OC4-Ot0TVrBG>L|PuE}l z8>tjdwzfi8uXC%U8vM{KyET5Y$&)b$wFS)OAq8D1K~Rt0!N0K@c#xhW9KveH1ersG zP8=6Ddt!(IhYq(%Y?Jrq`Ct4)F_&d|$&a&N0OgpaWECCrkKhDw8h8jn9uq68oGN** zFQL`Fe`BfdUfP0y2sD64$ZwwHUx0J0>5Upk!-hz%nF8cd$NM5H)OjJxeyk}lj~&cA z8YLOM4qn1=s05^%5f!+h4-2xQT2ViDZl@$@eWr7cNxQV}I1)M34i-x71rulKcOs#B zXAMccObuzN@}NDbf7x;s6Age;e%d;9nkPfLwrO$|cIBk2$`*N(s-KUY5Z40s!Ew2{4f5rD196#(%`S?R!G zBb|+-@WU%N4zy9{m@rsO0VFwqd;Iao&5!HTN(brqg82)~TE!y|KTH&nmTf7dhkHa4 z(RaS%Sd+jXdDJmd-PzVaPPDWm#>-*e5nX58EwbJI*yE2`zUGnoA;~W3>^}e8v*C4F zGMPSY4SQ?l(4B*_G##nvgKi_Qur7jSo7u*DB)I>PbbOwy-QbVzIt~Dqc1sX@$+6Oa z^E{me?oT}O0ptO=*u0CIBA|>pQICfo_e_Q+Cg3k;rGx+_obx-&gBX}JdDk!NMmXxz zX^bI~-)z<8c>4qC^r@b(UV~eI{SA!Y`Fx3Gh${=+7jwL0BIN0fpWt9f&H?j;heOlOMkG;cXKmTcX<>i;c z1{-c{llH&;`M$8#b~}c*?69-(46wc4`s;_CcH1l5bp6$~s)2-^bf0?iiSUziP7nL+ zua${?_qVoz1))1{zd7u&&;F)rhfsokw)M8#hbNzSEd1k<**d6gbE}gnQ&y6IVijt{ zI7S_{NN42X*|W{+)SIQImihSAfTuB+m>X&kSSA6G(}Dn)kO3&?lZz)~4yr7Gb<7O% zIq@_I4zNek9@QPBdV{pw1L*yKtyAIQ3zB0dBg;x^3?T0m-eGbCC}*;SM4Nh|j}M6X zivUA%sJKu^l{)IrR!Q)>N(SL z6SDXw){w%Q0`r7^Rcfq6D-cD<6ey8>R#U?@EOdTS&QPdZYLc*|HRcDb;+Y2CrH^Tp z@-|4~q76Foqp<=U04lhefpVn<{}DqP2GuSDgTS zFc_o9jK2AfQa?E4kp08L1&acfD$pmlXbF)W9Ub<;C`2;@8}D%o7A&+Yo7XUni1d^J z&Q&PVX;KfU!eDw19ZYwZUG}T+o)h2Yj~Zw<3=_3PJV^j@`t<4HUz$v~WH=Kjxw&K3 z?RK}0HbMUOipx!{=0hL)s13gK1^eh}fSxmFu7$%?e6O&uByIr==cqO(qQGXGwl#>DU8wpU`AvNT>9uATGFUKabDw znNYodo7e6>0pPwV{B~Cixw^t9cnk85Z5AYfp4zs$)|&tbMN%$ zhaHNKX}Y|OY|6!z+qR44olnd1e5Pzjn`cOBea!LivHsGheHBy&6FB-08-=MGs~&}a z+d+q0J=12aZL4m1$2kgo^IN6vh2tIj9^SI^9@;mumifhY&=3wk=J>Fhne~htMxlXr zGeIjs3vG3ZZwl=a`}G!8KhbZ#)VpTg@`tdcfc#YTZB$x{wNH;(?v;8cJ2ni)u?1cB z*cupny5i6#TCvU%!5t@9E)t0M6ENwU05JqdfIz@F!Xv;tf>U_)-|XNPs|oX^P69~P zqOPfva4rjzoNB*arFcf3ir2M@qydaC^4PCUER;`>EDW2eRxTY!a?_eY&ThfGGHKnd*z}%KV zRRJu^(eWuz0>dN0*20ovE^lrZVWE)){YBna>+L$@NCpQ6(x5*|pQ=r_SUmVYhUHNe zNNwIE<+guM1>>kqTBz< zD^cZT^*60zt=N%UR`{h@{owIHU+ibs!Z3MNI zD5Wu3Dp`jdevEw!Jr4T~Hr;FsYbU6%;Am%y7A*|VKJ!%AavPmvAiebb4W~-Z`qHBh zKO~{V{IJDV+Z6o1Sb|mjP!7JzNI1KNurPlYt*AsPDAv#54V(NXs7qt9#C#Z*6sK1} z4^>k9;uytPD14wq?Zi*TNrNH<8#NA^-Nli~pDMpnqv&`8P$#v@)UqZmo#H3^-vH;e zmYzd9fS8pGREF9F1_0h1n#6MisxHGh3S&3nT1ILt+*$ic-#hC%A0oN2T*O%mk>Un5`|wiF4Ep?X8L|3OC*>k z#6087wWh1gv2284(4U(~JBQOqV%=dba%@;@&6#FHoQV~O3DGVb^phvN=N*p8N0mja zhAl(JfwoD~&BdWBk4Z@^q)-O0FRv30Pz*FlpIjb z2G2eAJ-{U2E}7=aH-pWhf0bGb{9&W$jyvxzu)J+tC5wq=0K9{8ya|t0Ap2hOYv07B z;oiGu=}DHGWTMj5+RQ^gu?`ybommv_z5DjCt<-2xb8(tH+;Fb|x*l!fM3)>@1k_WI zx&q(;c^u0QDEIwLwEtdx=24LVCm^ZilV=iH#c2~7LGn%<*E1h3jl+qb>1nzFKtjJr zPh5P%Q8KSRSc$8OY?5k%Op-bYDkRMU{pA zd-8%|`10pGviGSUf;phKQ(G07b^u@>u6}zeo9b?9poybjnM*F*^Va^vCrwH0buE61 z@v7pq!Bu>a3ppFWtbh=&Ql$!3DE#vDTZ+V71<6#xFo~fqNek&)l$di}lDWs9_g?42 z%+5MOlXTg;mq@=?od{M>(##Wyh1!mFz+vZ6K>`2~%6r8Xmzxy&#TQ?YbhgdFv9*gN zEkHD?C%?Pldh@|j3Hs>9m!!S?QR*%C%Jg2FzTh60>)5d^vT!mx{NJU&w76@pvyKKK zKw(GNci*>~-;Fo^UH~*6p&t1i0Xp&Ty6bMyceQQU1hk`8g2Q$>^fMViJ%F%0-!Dra zI5uS#a!qPKNU*S8apX})hp%c=Anv4-PL#fIJav>k1?#T6zWHDH(+i}R{;(zv0%)1` zAZdoo7_ve18rciK>86`a+MhqEumsBZfsb^-#j@sr!UV#6x-_Y~-n2Cy544=F)Hm&DgAFza z(`QT%*I$3F>i#~}dvm7!(U!)IYn6qb2gCF=R+R;qlWe7gxV%d{{LnF9Z@u+HizbPf zq+}(5_RBjQSDvwRe#ylbnjhhP;%XkF_y_v?1S;0k#1B_V+jbs|;^G-;(8(Y5%S4hh z2d@0}FT(~KZz`)&+gV-dbtWt~OujHxh&l=TDV`BffA(L`WYUSD1yIyy2Dh8I(x|{T z;d|h|yTb$b-W3i!^k_>H6_$M7G4Xv|dpj0M)rC!=`DuE7)m{N*tim#;ppEI)QvhnT zdcYmJ%K$j<1zfR!MqEHV>MiJ|1HhUE?g9A#G3;6|4@Z^phl2t(K|(L874sz3o~wquGV;&kXj0#DI4qgOQdmac+ky`U?{uD}%_|z{G26*whu9#U=cena(SDKvCiglOC zvQteNxK7Z_41pl&<+~(l;~V$zoAyLrouU)|EKleC2727lx8rmp`dAtClMdrytG$X{ zHFPB@t1;y@Bs(W5alJvdmrE8UC&J(dDigqsL&c}9wb?d*PMFXpFx0NUvHGC4OS0Cc zLAX@_l*1?Z(DPxAKz(qk<@o>{`x=_{!QkT;XX4`}iQt|;KA4fR!Rc9<^;4_9fI11A zTesS3t8nabZ?_D;paXsW{O7+J$V3W_B?3UlOHva!=bRsei!b`Ah1+@OT@Ad$6R(VQ zWLf2~Lk|qM-F`;~Ofrx?p!B||6B%(||Hi3d?X}m*fY`nF-pBmEbK0r0Z+?NK!En_`|@>1Zmpz8TOt{w%1C>JT3iW(s1(0pRfaY z2$cT#@LV*{Oy2Rv_e;>V2Nk=jlTU^-#^E~vnlycfBLi9O4LE7e*Y@F^!Vex`@J(V?K#aC2G2qc-*mX5 zo!uj+J_g0o^bVL`9#6taah`b@&#tg-!^;)LK`W~ws9;_7n@hF&H8t$F{~_iV`)I5m z+9+*knl`fUxBtOmPnkG8{HV8wsZw$I^)D|BuS%VXRWZ_of2=T)ZoZ58HESaGmvHm% zt__DDdwf`1-@|+0sJh&D*Q~JJj=M-&j@6>U;5|Q&ik?+G#uA1(^ptS`KH3`+ zUsN6vZ+k}@=@Rfpx=Orp0&Cm@z7QHAaYyoQ`=4TrW%es_%pvtfFMhsO0vvn;LfMoE zAdM1XebW>g6&lAsi>ZdLE?7J#8WF$PZd@k-oPjSZIt&gW5Wn4lkC)1K) zG%FX-K!5owr+nQG`@t+Be{b9WfUu4LGS7I*k%B!w5sUj9SIB>@S z!T&qqU1lC^pS^Yu96OJdj8~-s@ylQS+^E=R-~G%kG=m{=PzS)W%O7u@74}w~Pk!Q~ z1`PMyYj4^4U7`uX{Q^PJl;7`fyg50safwOU+a=XULVC}i?v^UbdrbO%_uY4e+i$xy z{Nk6FN}73*=^VpzcYaU4IA~zt#4O*br+z2ga_h}@@XOk3uNyuh(|cnS7ZZ=149e+> zJ8Zv=o{!bxGHZmFUVPD38A#`(_kSRK{NpDZ$aP@ul5fuwGL&6W=d@sRos%x#|GxKV z1>*!e*%8xwSjXWIoezBQL*d->&JQ2{&<6&kRV>de3nDLA`M^;RNWhsWQKv^7eu!;4 zrM~vkgo#y!bI*+c_1C}oZ8-MmBURsHi?+{vf{M}OY(SqL=RC@f-)$-ax$k}BB&RsK zLtpstBaRBs{Og%;+PA+II1K41S=(7_t+izO@ZXvCXA`i)4p)8*fyEt@kE4%1COjoe zKO9K3(~jH8R`_vY%^5S)re4mh9=zv0CuMAvlN$BwY4=M?TF6;{7rnT+%2Oy*)^DgZ z?R_#CO!Fk0LKG(O+^hO@M>{=MWqPWDwq0}Z=)~0cN_ztQab6QB=JSMi{$DAhP}-d-kXp`75fI1x#b)C>4P zz2T7N1GME8>6d@<%HI2r`RNCmQBguQW`UL>;Fy;-d*Dr}I^78fj3%@_=rDDt5$gkv(rzS^%{-{{DA^g1`5q z_sJ5=Nz%jKGArS+Ld7~@PjKLK zM$gd!Kxc>VpYc6QYp=cc4tLynuk^<^3|Hykmo2v3$_8kAsUP3CxRREu-7zSm3d1q= z*I##I__U5L|GX@sVAFjsO%TwRKSh%k%tr!PfAgDPd(^;016$V|CNoLP;FN_y9d2N& z`e|8)fhV|f+6Unn?2kL{9R`4Z{No?lVNV?Rg!|U1r-k>u?}J)_xZj?cP^De5JUwp! z!RoTILpW%(ey{q6uWx(X0b$l1cZUmg2-L5B`3pN-iL|NnPk;JzT45MtdheTU9!r>h=u@W?+w>9kgt?QtADjo&D2yldo>^Ai#&8><*dGC%v-FW3P- zzfwD*9lP6M#~n2xJkd^p9XD=#w(WX=)YZ~c9l+Z!fBCCsN|Sbp-oHC0jvO9zzySxT zeUM(Xq(K>+zVY%egJ$b?wDt9{GP-)tGfkK?~Bpsi7Z}(HDPPJFDjQvJ?xotaN z@|&N>XnCfc(9W^9e&h8VFw_-}KJEltCF$>vrdB=78*jW8es}GaVb?wOQQKZ$-vJ>u zId21mZCT0RoIWps}=$fJq}AP#A_Ny^pte3+r13R`J2U)(%s;9JkAK%o8t zFeELcsuHk{xPuc|$6vLscKYu=<~IzBuc*7DHoN5Z}LKNy~W_Fs1FIUgwhs8m~+ zHD|69o|Dw!7gt;-ox6hsWOYbN^1-S$O}>Z?13FYj%alVkJS5$yKTMp^p#e%QtHut1 zK2IjEdbRFXN_tMaKpm!0C&MAha0ftfb5>I|^Ul8U5c*K4@zZHJWPpy*I%Y{|UCMmA zc8*PP=wsqufBkiV%BMoNR6)pt!2~eOapLHeyTqAx(;>0RsFKCLTdWS9Z`A9;q@E}P z?SXcI1s}rX*HfC_^nJG8qlFDrdPt9SR*_YY@#@FW!e5#f#pp|?P1~d{s5>uL`hEWW zP-#~CK3hhC#R}SXK7FI@nR4C!$D6`a|9m_g^Uinc+b~t%gRBsT&j{AZXbYG9@*-QE zJM^gIY;};h@bjL#ZV&g~acel?qz_BIY)$iH1?b_~4~7|Q&QyP#W`5Kmee{Y;e{O0i zJMO$&=G)_Z@$V05^PPL(zI(!~TmKN=^Zt)Y#b!#`ZPL>l>dIGA(}K`FMiUf)rbW_~ zrp)6cUCx(mYG^SK?q&c9=ep#?MIy?$R4LFLipFB^k%%L)?$V4v*=HoSP_bc*Mg;~yTaF_x`tPXOAn8VOjboJq z{q>>TQdL@;06pA6_Z-y~!{tLoEb$)wbX1uT1ftf&%7T{%zDJs+)87*@$&_R=Q6$Nw z%`IcbNNf1H?oPN8KtDNJmSA zl=6yA+vk{ZU{OKSfk){*lv5#{QU_^n(xxnZaP6y8fL_9xuDB%JBju2wud?!lFh>U! zEnbX7Ch|5vKe#(arr72W$ewr7$m7g6&eGBOqT#3OfRyO$%;Ev%Zr{8?<*Kzq-`Bw` zlC@P=T_uODZuuk)kn?F^00M9z5yy-eo8o9&4jc%d1~DEVKTj8FHm7Cch{qq`-Um1Q z3GV~#v^A!giUAWa23k)CP>q8gcMPHcH`)b%X-Arb#hjYc$TRm&2lmw0UV9z$;~nZY z4lX^8r|J0@%FDwPx{?km4y22;ev?h2KE2VP@G;O@sO3big}! zgq4;tGb2CXDfJ0}^vO(`ryjS^btrnUORwS%Du<`#b&)P-u{wlr&_x-ri$8NFhh5

Gz^qBy~dLG;k)8YJns5Gm6pDtr1ZQJP@ZCBa} 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%0AoSlHN8Wd?ArYRLr4C{4hJTWT9+1+|g+#g(*j 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ŸMX>4Wn&akp$Ki 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%0D1oj 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

BiQ-hAKw2Dq^BPHm*WZx@O1<4 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 X`@?ah^#uVE;-w_7E>|gI`QiTn4dE%? literal 0 HcmV?d00001 diff --git a/website/src/_includes/blog_previews/20250308.html b/website/src/_includes/blog_previews/20250308.html new file mode 100644 index 0000000000..ab55ecdbca --- /dev/null +++ b/website/src/_includes/blog_previews/20250308.html @@ -0,0 +1,12 @@ +

v6.3 is released:

+ +
    +
  • Preventing spam and abuse in public groups.
  • +
  • Group improvements: mention other members and improved performance.
  • +
  • Better chat navigation: organize chats into lists and jump to found and forwarded messages.
  • +
  • Privacy and security improvements: chat retention period and private media file names.
  • +
+ +

Also, we added Catalan interface language, thanks to our users and Weblate.

+ +

The last but not the least - server builds are now reproducible!

From 2317cee3ebc8c8f6feefabab82f16a29566ece74 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 10 Mar 2025 14:54:55 +0400 Subject: [PATCH 135/289] desktop: fix postgres migration (#5739) --- .../commonMain/kotlin/chat/simplex/common/platform/Core.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 276d3e3909..a9f2dcaffc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -79,7 +79,11 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } if (rerunMigration) { chatModel.dbMigrationInProgress.value = true - migrated = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) + migrated = if (databaseBackend == "postgres") { + chatMigrateInit("simplex_v1", "postgresql://simplex@/simplex_v1", confirm.value) + } else { + chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) + } res = runCatching { json.decodeFromString(migrated[0] as String) }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } From 5050d6082579f6791a5dc77ca5bf04dfb9ebe966 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 11 Mar 2025 08:37:48 +0000 Subject: [PATCH 136/289] flatpak: update metainfo (#5740) --- .../flatpak/chat.simplex.simplex.metainfo.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index db84b67e63..45e0f9a418 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,22 @@ + + https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +

New in v6.3.0:

+
    +
  • Mention members and get notified when mentioned.
  • +
  • Send private reports to moderators.
  • +
  • Delete, block and change role for multiple members at once
  • +
  • Faster sending messages and faster deletion.
  • +
  • Organize chats into lists to keep track of what's important.
  • +
  • Jump to found and forwarded messages.
  • +
  • Private media file names.
  • +
  • Message expiration in chats.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html From aba09939e2a3d748fe922df902321d4a87f52b8b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 11 Mar 2025 10:32:02 +0000 Subject: [PATCH 137/289] directory: more permissive captcha rules (#5741) --- .../src/Directory/Captcha.hs | 40 +++++++++++++++++++ .../src/Directory/Service.hs | 10 +---- simplex-chat.cabal | 1 + tests/Bots/DirectoryTests.hs | 14 +++++++ 4 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 apps/simplex-directory-service/src/Directory/Captcha.hs diff --git a/apps/simplex-directory-service/src/Directory/Captcha.hs b/apps/simplex-directory-service/src/Directory/Captcha.hs new file mode 100644 index 0000000000..54d595e96f --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Captcha.hs @@ -0,0 +1,40 @@ +module Directory.Captcha (getCaptchaStr, matchCaptchaStr) where + +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import System.Random (randomRIO) + +getCaptchaStr :: Int -> String -> IO String +getCaptchaStr 0 s = pure s +getCaptchaStr n s = do + i <- randomRIO (0, length captchaChars - 1) + let c = captchaChars !! i + getCaptchaStr (n - 1) (c : s) + +matchCaptchaStr :: T.Text -> T.Text -> Bool +matchCaptchaStr captcha guess = T.length captcha == T.length guess && matchChars (T.zip captcha guess) + where + matchChars [] = True + matchChars ((c, g) : cs) = + let g' = fromMaybe g $ M.lookup g captchaMatches + in c == g' && matchChars cs + +captchaChars :: String +captchaChars = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrty" + +captchaMatches :: M.Map Char Char +captchaMatches = + M.fromList + [ ('0', 'O'), + ('1', 'I'), + ('c', 'C'), + ('l', 'I'), + ('o', 'O'), + ('s', 'S'), + ('u', 'U'), + ('v', 'V'), + ('w', 'W'), + ('x', 'X'), + ('z', 'Z') + ] diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 79654b4da6..575c7ca738 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -39,6 +39,7 @@ import qualified Data.Text.IO as T import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Directory.BlockedWords +import Directory.Captcha import Directory.Events import Directory.Options import Directory.Search @@ -67,7 +68,6 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) import System.Directory (getAppUserDataDirectory) import System.Process (readProcess) -import System.Random (randomRIO) data GroupProfileUpdate = GPNoServiceLink | GPServiceLinkAdded | GPServiceLinkRemoved | GPHasServiceLink | GPServiceLinkError @@ -455,12 +455,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName atomically $ TM.insert gmId captcha $ pendingCaptchas env sendCaptcha mc where - getCaptchaStr 0 s = pure s - getCaptchaStr n s = do - i <- randomRIO (0, length chars - 1) - let c = chars !! i - getCaptchaStr (n - 1) (c : s) - chars = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrsty" getCaptcha s = case captchaGenerator opts of Nothing -> pure textMsg Just script -> content <$> readProcess script [s] "" @@ -491,7 +485,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName atomically (TM.lookup (groupMemberId' m) $ pendingCaptchas env) >>= \case Just PendingCaptcha {captchaText, sentAt, attempts} | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 - | captchaText == msgText -> do + | matchCaptchaStr captchaText msgText -> do sendComposedMessages_ cc (SRGroup groupId $ Just $ groupMemberId' m) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] approvePendingMember a g m | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts diff --git a/simplex-chat.cabal b/simplex-chat.cabal index bbdd766a7b..377fb41d81 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -408,6 +408,7 @@ executable simplex-directory-service StrictData other-modules: Directory.BlockedWords + Directory.Captcha Directory.Events Directory.Options Directory.Search diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 31b36159fd..1e22b1854e 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -13,6 +13,7 @@ import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) import Control.Monad (forM_, when) import qualified Data.Text as T +import Directory.Captcha import qualified Directory.Events as DE import Directory.Options import Directory.Service @@ -65,6 +66,8 @@ directoryServiceTests = do it "should list user's groups" testListUserGroups describe "store log" $ do it "should restore directory service state" testRestoreDirectory + describe "captcha" $ do + it "should accept some incorrect spellings" testCaptcha directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} @@ -974,6 +977,17 @@ testRestoreDirectory ps = do cath #> "@SimpleX-Directory security" groupFoundN' 2 cath "security" +testCaptcha :: HasCallStack => TestParams -> IO () +testCaptcha _ps = do + let captcha = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrty" + matchCaptchaStr captcha captcha `shouldBe` True + matchCaptchaStr captcha "23456789ABcDEFGH1JKLMNoPQRsTuvwxYzabdefghijkmnpqrty" `shouldBe` True + matchCaptchaStr "OOIICSUVWXZ" "OOIICSUVWXZ" `shouldBe` True + matchCaptchaStr "OOIICSUVWXZ" "0o1lcsuvwxz" `shouldBe` True + matchCaptchaStr "OOIICSUVWXZ" "" `shouldBe` False + matchCaptchaStr "OOIICSUVWXZ" "0o1lcsuvwx" `shouldBe` False + matchCaptchaStr "OOIICSUVWXZ" "0o1lcsuvwxzz" `shouldBe` False + listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () listGroups superUser bob cath = do bob #> "@SimpleX-Directory /list" From 45c7c6bc6e0494996ba29331a275449dd948e416 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 12 Mar 2025 10:30:04 +0000 Subject: [PATCH 138/289] directory: use lowercase letters in captcha, accept any case for same-looking letters (#5744) --- .../src/Directory/Captcha.hs | 41 +++++++++---------- simplex-chat.cabal | 1 + tests/Bots/DirectoryTests.hs | 12 +++--- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Captcha.hs b/apps/simplex-directory-service/src/Directory/Captcha.hs index 54d595e96f..d60b09df83 100644 --- a/apps/simplex-directory-service/src/Directory/Captcha.hs +++ b/apps/simplex-directory-service/src/Directory/Captcha.hs @@ -11,30 +11,27 @@ getCaptchaStr n s = do i <- randomRIO (0, length captchaChars - 1) let c = captchaChars !! i getCaptchaStr (n - 1) (c : s) + where + captchaChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" matchCaptchaStr :: T.Text -> T.Text -> Bool matchCaptchaStr captcha guess = T.length captcha == T.length guess && matchChars (T.zip captcha guess) where matchChars [] = True - matchChars ((c, g) : cs) = - let g' = fromMaybe g $ M.lookup g captchaMatches - in c == g' && matchChars cs - -captchaChars :: String -captchaChars = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrty" - -captchaMatches :: M.Map Char Char -captchaMatches = - M.fromList - [ ('0', 'O'), - ('1', 'I'), - ('c', 'C'), - ('l', 'I'), - ('o', 'O'), - ('s', 'S'), - ('u', 'U'), - ('v', 'V'), - ('w', 'W'), - ('x', 'X'), - ('z', 'Z') - ] + matchChars ((c, g) : cs) = matchChar c == matchChar g && matchChars cs + matchChar c = fromMaybe c $ M.lookup c captchaMatches + captchaMatches = + M.fromList + [ ('0', 'O'), + ('1', 'I'), + ('c', 'C'), + ('l', 'I'), + ('o', 'O'), + ('p', 'P'), + ('s', 'S'), + ('u', 'U'), + ('v', 'V'), + ('w', 'W'), + ('x', 'X'), + ('z', 'Z') + ] diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 377fb41d81..f8370e4391 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -476,6 +476,7 @@ test-suite simplex-chat-test Broadcast.Bot Broadcast.Options Directory.BlockedWords + Directory.Captcha Directory.Events Directory.Options Directory.Search diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 1e22b1854e..e9db100e8d 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -982,11 +982,13 @@ testCaptcha _ps = do let captcha = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrty" matchCaptchaStr captcha captcha `shouldBe` True matchCaptchaStr captcha "23456789ABcDEFGH1JKLMNoPQRsTuvwxYzabdefghijkmnpqrty" `shouldBe` True - matchCaptchaStr "OOIICSUVWXZ" "OOIICSUVWXZ" `shouldBe` True - matchCaptchaStr "OOIICSUVWXZ" "0o1lcsuvwxz" `shouldBe` True - matchCaptchaStr "OOIICSUVWXZ" "" `shouldBe` False - matchCaptchaStr "OOIICSUVWXZ" "0o1lcsuvwx" `shouldBe` False - matchCaptchaStr "OOIICSUVWXZ" "0o1lcsuvwxzz" `shouldBe` False + matchCaptchaStr "23456789ABcDEFGH1JKLMNoPQRsTuvwxYzabdefghijkmnpqrty" captcha `shouldBe` True + matchCaptchaStr "OOIICPSUVWXZ" "OOIICPSUVWXZ" `shouldBe` True + matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwxz" `shouldBe` True + matchCaptchaStr "0o1lcpsuvwxz" "OOIICPSUVWXZ" `shouldBe` True + matchCaptchaStr "OOIICPSUVWXZ" "" `shouldBe` False + matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwx" `shouldBe` False + matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwxzz" `shouldBe` False listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () listGroups superUser bob cath = do From 364aa667ad832c50ec0f79a9fd9b8e783b900129 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 14 Mar 2025 05:36:45 +0700 Subject: [PATCH 139/289] ios: scrolling improvements (#5746) * ios: scrolling improvements * changes * fixes * fix * private --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 4 + .../Shared/Views/Chat/ChatItemsLoader.swift | 189 +++++++++++++++++- .../Shared/Views/Chat/ChatScrollHelpers.swift | 70 ++++--- apps/ios/Shared/Views/Chat/ChatView.swift | 53 +++-- .../Shared/Views/Chat/EndlessScrollView.swift | 17 +- 5 files changed, 274 insertions(+), 59 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 23b167a9ff..00260cc05e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -66,6 +66,10 @@ class ItemsModel: ObservableObject { private var navigationTimeoutTask: Task? = nil private var loadChatTask: Task? = nil + var lastItemsLoaded: Bool { + chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id + } + init() { publisher .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index add28cd7f9..07034cf8ec 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -60,6 +60,8 @@ func apiLoadMessages( chatState.unreadTotal = chat.chatStats.unreadCount chatState.unreadAfter = navInfo.afterUnread chatState.unreadAfterNewestLoaded = navInfo.afterUnread + + PreloadState.shared.clear() } case let .before(paginationChatItemId, _): newItems.append(contentsOf: oldItems) @@ -104,19 +106,22 @@ func apiLoadMessages( } } case .around: - let newSplits: [Int64] + var newSplits: [Int64] if openAroundItemId == nil { newItems.append(contentsOf: oldItems) newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) } else { newSplits = [] } - // currently, items will always be added on top, which is index 0 - newItems.insert(contentsOf: chat.chatItems, at: 0) + let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits)) + //indexToInsertAroundTest() + newItems.insert(contentsOf: chat.chatItems, at: itemIndex) + newSplits.insert(chat.chatItems.last!.id, at: splitIndex) let newReversed: [ChatItem] = newItems.reversed() + let orderedSplits = newSplits await MainActor.run { ItemsModel.shared.reversedChatItems = newReversed - chatState.splits = [chat.chatItems.last!.id] + newSplits + chatState.splits = orderedSplits chatState.unreadAfterItemId = chat.chatItems.last!.id chatState.totalAfter = navInfo.afterTotal chatState.unreadTotal = chat.chatStats.unreadCount @@ -130,14 +135,16 @@ func apiLoadMessages( // no need to set it, count will be wrong // chatState.unreadAfterNewestLoaded = navInfo.afterUnread } + PreloadState.shared.clear() } case .last: newItems.append(contentsOf: oldItems) - removeDuplicates(&newItems, chat) + let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, 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 } @@ -234,10 +241,14 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination( let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId) // Currently, it should always load from split range let loadingFromSplitRange = indexInSplitRanges != nil - var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count { - Array(splits[indexInSplitRanges + 1 ..< splits.count]) + let topSplits: [Int64] + var splitsToMerge: [Int64] + if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count { + splitsToMerge = Array(splits[indexInSplitRanges + 1 ..< splits.count]) + topSplits = Array(splits[0 ..< indexInSplitRanges + 1]) } else { - [] + splitsToMerge = [] + topSplits = [] } newItems.removeAll(where: { new in let duplicate = newIds.contains(new.id) @@ -257,8 +268,8 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination( }) var newSplits: [Int64] = [] if firstItemIdBelowAllSplits != nil { - // no splits anymore, all were merged with bottom items - newSplits = [] + // no splits below anymore, all were merged with bottom items + newSplits = topSplits } else { if !splitsToRemove.isEmpty { var new = splits @@ -320,6 +331,28 @@ private func removeDuplicatesAndUpperSplits( return newSplits } +private func removeDuplicatesAndUnusedSplits( + _ newItems: inout [ChatItem], + _ chat: Chat, + _ splits: [Int64] +) async -> [Int64] { + if splits.isEmpty { + removeDuplicates(&newItems, chat) + return splits + } + + var newSplits = splits + let (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll(where: { + let duplicate = newIds.contains($0.id) + if duplicate, let firstIndex = newSplits.firstIndex(of: $0.id) { + newSplits.remove(at: firstIndex) + } + return duplicate + }) + return newSplits +} + // ids, number of unread items private func mapItemsToIds(_ items: [ChatItem]) -> (Set, Int) { var unreadInLoaded = 0 @@ -340,3 +373,139 @@ private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) { let (newIds, _) = mapItemsToIds(chat.chatItems) newItems.removeAll { newIds.contains($0.id) } } + +private typealias SameTimeItem = (index: Int, item: ChatItem) + +// return (item index, split index) +private func indexToInsertAround(_ chatType: ChatType, _ lastNew: ChatItem?, to: [ChatItem], _ splits: Set) -> (Int, Int) { + guard to.count > 0, let lastNew = lastNew else { return (0, 0) } + // group sorting: item_ts, item_id + // everything else: created_at, item_id + let compareByTimeTs = chatType == .group + // in case several items have the same time as another item in the `to` array + var sameTime: [SameTimeItem] = [] + + // trying to find new split index for item looks difficult but allows to not use one more loop. + // The idea is to memorize how many splits were till any index (map number of splits until index) + // and use resulting itemIndex to decide new split index position. + // Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`. + var splitsTillIndex: [Int] = [] + var splitsPerPrevIndex = 0 + + for i in 0 ..< to.count { + let item = to[i] + + splitsPerPrevIndex = splits.contains(item.id) ? splitsPerPrevIndex + 1 : splitsPerPrevIndex + splitsTillIndex.append(splitsPerPrevIndex) + + let itemIsNewer = (compareByTimeTs ? item.meta.itemTs > lastNew.meta.itemTs : item.meta.createdAt > lastNew.meta.createdAt) + if itemIsNewer || i + 1 == to.count { + if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) { + sameTime.append((i, item)) + } + // time to stop the loop. Item is newer or it's the last item in `to` array, taking previous items and checking position inside them + let itemIndex: Int + if sameTime.count > 1, let first = sameTime.sorted(by: { prev, next in prev.item.meta.itemId < next.item.id }).first(where: { same in same.item.id > lastNew.id }) { + itemIndex = first.index + } else if sameTime.count == 1 { + itemIndex = sameTime[0].item.id > lastNew.id ? sameTime[0].index : sameTime[0].index + 1 + } else { + itemIndex = itemIsNewer ? i : i + 1 + } + let splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.count - 1)] + let prevItemSplitIndex = itemIndex == 0 ? 0 : splitsTillIndex[min(itemIndex - 1, splitsTillIndex.count - 1)] + return (itemIndex, splitIndex == prevItemSplitIndex ? splitIndex : prevItemSplitIndex) + } + + if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) { + sameTime.append(SameTimeItem(index: i, item: item)) + } else { + sameTime = [] + } + } + // shouldn't be here + return (to.count, splits.count) +} + +private func indexToInsertAroundTest() { + func assert(_ one: (Int, Int), _ two: (Int, Int)) { + if one != two { + logger.debug("\(String(describing: one)) != \(String(describing: two))") + fatalError() + } + } + + let itemsToInsert = [ChatItem.getSample(3, .groupSnd, Date.init(timeIntervalSince1970: 3), "")] + let items1 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 2), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items1, Set([1])), (3, 1)) + + let items2 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items2, Set([2])), (3, 1)) + + let items3 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items3, Set([1])), (3, 1)) + + let items4 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items4, Set([4])), (1, 0)) + + let items5 = [ + ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""), + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items5, Set([2])), (2, 1)) + + let items6 = [ + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items6, Set([5])), (0, 0)) + + let items7 = [ + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, nil, to: items7, Set([6])), (0, 0)) + + let items8 = [ + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 4), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items8, Set([2])), (0, 0)) + + let items9 = [ + ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items9, Set([5])), (1, 0)) + + let items10 = [ + ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), ""), + ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "") + ] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items10, Set([4])), (0, 0)) + + let items11: [ChatItem] = [] + assert(indexToInsertAround(.group, itemsToInsert.last, to: items11, Set([])), (0, 0)) +} diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index 873f24d5c3..c1a1eec7d2 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -9,25 +9,23 @@ import SwiftUI import SimpleXChat -func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat) { - if ItemsModel.shared.chatState.totalAfter == 0 { - return +func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat) async { + await MainActor.run { + loadingMoreItems.wrappedValue = true + loadingBottomItems.wrappedValue = true } - loadingMoreItems.wrappedValue = true - loadingBottomItems.wrappedValue = true - Task { - try? await Task.sleep(nanoseconds: 500_000000) - if ChatModel.shared.chatId != chat.chatInfo.id { - await MainActor.run { - loadingMoreItems.wrappedValue = false - } - return - } - await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState) + try? await Task.sleep(nanoseconds: 500_000000) + if ChatModel.shared.chatId != chat.chatInfo.id { await MainActor.run { loadingMoreItems.wrappedValue = false loadingBottomItems.wrappedValue = false } + return + } + await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState) + await MainActor.run { + loadingMoreItems.wrappedValue = false + loadingBottomItems.wrappedValue = false } } @@ -36,6 +34,12 @@ class 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( @@ -43,26 +47,41 @@ func preloadIfNeeded( _ ignoreLoadingRequests: Binding, _ listState: EndlessScrollView.ListState, _ mergedItems: BoxedValue, - loadItems: @escaping (Bool, ChatPagination) async -> Bool + loadItems: @escaping (Bool, ChatPagination) async -> Bool, + loadLastItems: @escaping () async -> Void ) { let state = PreloadState.shared guard !listState.isScrolling && !listState.isAnimatedScrolling, - state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count, !state.preloading, listState.totalItemsCount > 0 else { return } - state.prevFirstVisible = listState.firstVisibleItemId as! Int64 - state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count - state.preloading = true - let allowLoadMore = allowLoadMoreItems.wrappedValue - Task { - defer { - state.preloading = false + if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count { + state.preloading = true + let allowLoadMore = allowLoadMoreItems.wrappedValue + Task { + defer { state.preloading = false } + var triedToLoad = true + await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in + triedToLoad = await loadItems(false, pagination) + return triedToLoad + } + if triedToLoad { + state.prevFirstVisible = listState.firstVisibleItemId as! Int64 + state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count + } + // 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 { + await loadLastItems() + } } - await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in - await loadItems(false, pagination) + } else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded { + state.preloading = true + Task { + defer { state.preloading = false } + await loadLastItems() } } } @@ -105,6 +124,7 @@ async { 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 { ignoreLoadingRequests.wrappedValue = loadFromItemId + return false } return triedToLoad } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1e4c16b036..693efcfbb5 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -91,7 +91,11 @@ 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, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel) + 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) + scrollView.updateItems(mergedItems.boxedValue.items) + } + ) } connectingText() if selectedChatItems == nil { @@ -262,7 +266,6 @@ struct ChatView: View { // this may already being loading because of changed chat id (see .onChange(of: chat.id) if !loadingBottomItems { - loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) allowLoadMoreItems = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { allowLoadMoreItems = true @@ -584,7 +587,6 @@ struct ChatView: View { scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: chat.id) { _ in - loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) allowLoadMoreItems = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { allowLoadMoreItems = true @@ -629,7 +631,6 @@ struct ChatView: View { if let unreadIndex { scrollView.scrollToItem(unreadIndex) } - loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { allowLoadMoreItems = true } @@ -647,10 +648,8 @@ struct ChatView: View { } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { // scroll to the top unread item scrollView.scrollToItem(index) - loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) } else { scrollView.scrollToBottom() - loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) } } } @@ -731,6 +730,7 @@ struct ChatView: View { let theme: AppTheme let scrollView: EndlessScrollView let chat: Chat + @Binding var loadingMoreItems: Bool @Binding var loadingTopItems: Bool @Binding var requestedTopScroll: Bool @Binding var loadingBottomItems: Bool @@ -738,6 +738,7 @@ struct ChatView: View { @Binding var animatedScrollingInProgress: Bool let listState: EndlessScrollView.ListState @ObservedObject var model: FloatingButtonModel + let reloadItems: () -> Void var body: some View { ZStack(alignment: .top) { @@ -795,7 +796,7 @@ struct ChatView: View { } } .onTapGesture { - if loadingBottomItems { + if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded { requestedTopScroll = false requestedBottomScroll = true } else { @@ -815,7 +816,7 @@ struct ChatView: View { } } .onChange(of: loadingBottomItems) { loading in - if !loading && requestedBottomScroll { + if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded { requestedBottomScroll = false scrollToBottom() } @@ -824,15 +825,25 @@ struct ChatView: View { } private func scrollToTopUnread() { - if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { - animatedScrollingInProgress = true - // scroll to the top unread item - Task { + Task { + if !ItemsModel.shared.chatState.splits.isEmpty { + await MainActor.run { loadingMoreItems = true } + await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false) + await MainActor.run { reloadItems() } + if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { + await MainActor.run { animatedScrollingInProgress = true } + await scrollView.scrollToItemAnimated(index) + await MainActor.run { animatedScrollingInProgress = false } + } + await MainActor.run { loadingMoreItems = false } + } else if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { + await MainActor.run { animatedScrollingInProgress = true } + // scroll to the top unread item await scrollView.scrollToItemAnimated(index) await MainActor.run { animatedScrollingInProgress = false } + } else { + logger.debug("No more unread items, total: \(listState.items.count)") } - } else { - logger.debug("No more unread items, total: \(listState.items.count)") } } @@ -1147,6 +1158,11 @@ struct ChatView: View { } else { await loadChatItems(chat, pagination) } + }, + loadLastItems: { + if !loadingMoreItems { + await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + } } ) } @@ -1247,18 +1263,11 @@ struct ChatView: View { nil } let showAvatar = shouldShowAvatar(item, listItem.nextItem) - let itemSeparation: ItemSeparation let single = switch merged { case .single: true default: false } - if single || revealed { - let prev = listItem.prevItem - itemSeparation = getItemSeparation(item, prev) - let nextForGap = (item.mergeCategory != nil && item.mergeCategory == prev?.mergeCategory) || isLastItem ? nil : listItem.nextItem - } else { - itemSeparation = getItemSeparation(item, nil) - } + let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil) return VStack(spacing: 0) { if let last { DateSeparator(date: last.meta.itemTs).padding(8) diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 9756489628..670707c0c1 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -171,6 +171,9 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu visibleItems.last?.index ?? 0 } + /// Specifies if visible items cover the whole screen or can cover it (if overscrolled) + var itemsCanCoverScreen: Bool = false + /// Whether there is a non-animated scroll to item in progress or not var isScrolling: Bool = false /// Whether there is an animated scroll to item in progress or not @@ -284,7 +287,8 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) { if !Thread.isMainThread { - fatalError("Use main thread to update items") + logger.error("Use main thread to update items") + return } if bounds.height == 0 { self.listState.items = items @@ -302,6 +306,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu if items.isEmpty { listState.visibleItems.forEach { item in item.view.removeFromSuperview() } listState.visibleItems = [] + listState.itemsCanCoverScreen = false listState.firstVisibleItemId = EndlessScrollView.DEFAULT_ITEM_ID listState.firstVisibleItemIndex = 0 listState.firstVisibleItemOffset = -insetTop @@ -322,6 +327,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu var oldVisible = listState.visibleItems var newVisible: [VisibleItem] = [] + var visibleItemsHeight: CGFloat = 0 let offsetsDiff = contentOffsetY - prevProcessedOffset var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0 @@ -389,6 +395,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu addSubview(vis.view) } newVisible.append(vis) + visibleItemsHeight += vis.view.frame.height nextOffsetY = vis.view.frame.origin.y } else { let vis: VisibleItem @@ -406,6 +413,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu addSubview(vis.view) } newVisible.append(vis) + visibleItemsHeight += vis.view.frame.height } if abs(nextOffsetY) < contentOffsetY && !allowOneMore { break @@ -435,6 +443,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } offset += vis.view.frame.height newVisible.insert(vis, at: 0) + visibleItemsHeight += vis.view.frame.height if offset >= contentOffsetY + bounds.height { break } @@ -450,11 +459,15 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu prevProcessedOffset = contentOffsetY listState.visibleItems = newVisible - listState.items = items + // bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height). + // For visible items to preserve offset after adding more items having such height is enough + listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView.DEFAULT_ITEM_ID listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0 listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop + // updating the items with the last step in order to call listener with fully updated state + listState.items = items estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged) scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight) From ae24da090c8eda0b572bc59d03a7e5f1183a6839 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 16 Mar 2025 23:48:36 +0700 Subject: [PATCH 140/289] android, desktop: fix crash on very long quoted message (#5751) --- .../chat/simplex/common/views/chat/item/FramedItemView.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 689bf195f5..fd8a32af64 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 @@ -66,7 +66,11 @@ fun FramedItemView( @Composable fun ciQuotedMsgView(qi: CIQuote) { Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + Modifier + // this width limitation prevents crash on calculating constraints that may happen if you post veeeery long message and then quote it. + // Top level layout wants `IntrinsicWidth.Max` and very long layout makes the crash in this case + .widthIn(max = 50000.dp) + .padding(vertical = 6.dp, horizontal = 12.dp), contentAlignment = Alignment.TopStart ) { val sender = qi.sender(membership()) From b8e2e71a60f14128dd8575691253cb031da037a2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 16 Mar 2025 19:30:31 +0000 Subject: [PATCH 141/289] core: exclude CLI modules from client library (#5758) * core: exclude CLI modules from client library * client_library flag in nix builds * use client_library in builds, update iOS library --- Dockerfile | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++----- flake.nix | 7 ++++ scripts/desktop/build-lib-linux.sh | 2 +- scripts/desktop/build-lib-mac.sh | 4 +-- scripts/desktop/build-lib-windows.sh | 2 +- simplex-chat.cabal | 39 ++++++++++++++++------ 7 files changed, 49 insertions(+), 23 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7b9641777a..cdcbc40d7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local # Compile simplex-chat RUN cabal update -RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' +RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' # Strip the binary from debug symbols to reduce size RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \ diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9396845831..76684f15e4 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.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.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 */; }; @@ -531,8 +531,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.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a */, ); path = Libraries; sourceTree = ""; diff --git a/flake.nix b/flake.nix index 1a1043c5f2..9ee0c52020 100644 --- a/flake.nix +++ b/flake.nix @@ -198,6 +198,7 @@ packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ pkgs.pkgsCross.mingwW64.openssl ]; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ pkgs.pkgsCross.mingwW64.openssl @@ -336,6 +337,7 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) @@ -445,6 +447,7 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (androidPkgs.openssl.override { static = true; }) @@ -550,6 +553,7 @@ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ # TODO: have a cross override for iOS, that sets this. @@ -565,6 +569,7 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ ((pkgs.openssl.override { static = true; }).overrideDerivation (old: { CFLAGS = "-mcpu=apple-a7 -march=armv8-a+norcpc" ;})) @@ -583,6 +588,7 @@ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (pkgs.openssl.override { static = true; }) @@ -597,6 +603,7 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ (pkgs.openssl.override { static = true; }) diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index 80ae9fa82e..1db2755926 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -25,7 +25,7 @@ for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done rm -rf $BUILD_DIR -cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts -threaded' --constraint 'simplexmq +client_library' +cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts -threaded' --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' cd $BUILD_DIR/build #patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so #patchelf --add-rpath '$ORIGIN' libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 66af5cbb0c..934d9b8d7d 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -28,10 +28,10 @@ rm -rf $BUILD_DIR if [[ "$DATABASE_BACKEND" == "postgres" ]]; then echo "Building with postgres backend..." - cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' + cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' else echo "Building with sqlite backend..." - cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' + cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' fi cd $BUILD_DIR/build diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index cbb886ccb3..af408d4054 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -51,7 +51,7 @@ echo " ghc-options: -shared -threaded -optl-L$openssl_windows_style_path -opt # Very important! Without it the build fails on linking step since the linker can't find exported symbols. # It looks like GHC bug because with such random path the build ends successfully sed -i "s/ld.lld.exe/abracadabra.exe/" `ghc --print-libdir`/settings -cabal build lib:simplex-chat --constraint 'simplexmq +client_library' +cabal build lib:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f8370e4391..c9dc9f6afe 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -24,6 +24,11 @@ flag swift manual: True default: False +flag client_library + description: Don't build server- and CLI-related code. + manual: True + default: False + flag client_postgres description: Build with PostgreSQL instead of SQLite. manual: True @@ -33,13 +38,9 @@ library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings - Simplex.Chat.Bot - Simplex.Chat.Bot.KnownContacts Simplex.Chat.Call Simplex.Chat.Controller - Simplex.Chat.Core Simplex.Chat.Files - Simplex.Chat.Help Simplex.Chat.Library.Commands Simplex.Chat.Library.Internal Simplex.Chat.Library.Subscriber @@ -78,18 +79,24 @@ library Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared Simplex.Chat.Styled - Simplex.Chat.Terminal - Simplex.Chat.Terminal.Input - Simplex.Chat.Terminal.Main - Simplex.Chat.Terminal.Notification - Simplex.Chat.Terminal.Output Simplex.Chat.Types Simplex.Chat.Types.Preferences Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Types.Util Simplex.Chat.Util - Simplex.Chat.View + if !flag(client_library) + exposed-modules: + Simplex.Chat.Bot + Simplex.Chat.Bot.KnownContacts + Simplex.Chat.Core + Simplex.Chat.Help + Simplex.Chat.Terminal + Simplex.Chat.Terminal.Input + Simplex.Chat.Terminal.Main + Simplex.Chat.Terminal.Notification + Simplex.Chat.Terminal.Output + Simplex.Chat.View if flag(client_postgres) exposed-modules: Simplex.Chat.Options.Postgres @@ -296,6 +303,8 @@ library , text >=1.2.4.0 && <1.3 executable simplex-bot + if flag(client_library) + buildable: False main-is: Main.hs other-modules: Paths_simplex_chat @@ -313,6 +322,8 @@ executable simplex-bot cpp-options: -DdbPostgres executable simplex-bot-advanced + if flag(client_library) + buildable: False main-is: Main.hs other-modules: Paths_simplex_chat @@ -339,6 +350,8 @@ executable simplex-bot-advanced text >=1.2.4.0 && <1.3 executable simplex-broadcast-bot + if flag(client_library) + buildable: False main-is: Main.hs hs-source-dirs: apps/simplex-broadcast-bot @@ -369,6 +382,8 @@ executable simplex-broadcast-bot text >=1.2.4.0 && <1.3 executable simplex-chat + if flag(client_library) + buildable: False main-is: Main.hs other-modules: Server @@ -400,6 +415,8 @@ executable simplex-chat text >=1.2.4.0 && <1.3 executable simplex-directory-service + if flag(client_library) + buildable: False main-is: Main.hs hs-source-dirs: apps/simplex-directory-service @@ -447,6 +464,8 @@ executable simplex-directory-service , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test + if flag(client_library) + buildable: False type: exitcode-stdio-1.0 main-is: Test.hs other-modules: From e58d09ce785f3e6faff32a3e87f402ba5af7f13f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Mar 2025 04:18:58 +0700 Subject: [PATCH 142/289] android, desktop: fix negative content offset on some Android devices (#5752) --- .../simplex/common/views/chat/group/GroupChatInfoView.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 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 a94c787eec..f38cd972f3 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 @@ -379,10 +379,11 @@ fun ModalData.GroupChatInfoLayout( } else { PaddingValues( top = topPaddingToContent(false), - bottom = navBarPadding + - imePadding + - selectedItemsBarHeight + - (if (navBarPadding > 0.dp && imePadding > 0.dp) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp) + bottom = if (imePadding > 0.dp) { + imePadding + selectedItemsBarHeight + } else { + navBarPadding + selectedItemsBarHeight + } ) } ) { From 745372dd7a564c744a3e7182ddb186d62d4c9ad8 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Mar 2025 04:20:11 +0700 Subject: [PATCH 143/289] android, desktop: open links from notes (#5761) --- .../simplex/common/views/chatlist/ChatListNavLinkView.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 89dc84a1fc..5e33117e62 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 @@ -105,13 +105,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ) } is ChatInfo.Local -> { + val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, {}) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, - click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, + click = defaultClickAction, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { NoteFolderMenuItems(chat, showMenu, showMarkRead) From 6556e09a33ac1a61a9f4ba0076adc239bfcc8c75 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 19 Mar 2025 07:16:31 +0000 Subject: [PATCH 144/289] core: update simplexmq to support PostgreSQL servers (#5760) * core: update simplexmq to support postgres * update simplexmq * update ios --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Call.hs | 4 ++-- src/Simplex/Chat/Messages.hs | 3 ++- src/Simplex/Chat/Operators.hs | 3 ++- src/Simplex/Chat/Protocol.hs | 4 ++-- src/Simplex/Chat/Types.hs | 4 ++-- src/Simplex/Chat/Types/Preferences.hs | 3 ++- src/Simplex/Chat/Types/Shared.hs | 2 +- src/Simplex/Chat/Types/UITheme.hs | 3 ++- tests/ChatClient.hs | 13 +++++++------ tests/ChatTests/Direct.hs | 6 ++---- tests/ChatTests/Groups.hs | 6 ++---- tests/ChatTests/Profiles.hs | 3 +-- 15 files changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 76684f15e4..6727643022 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.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.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 */; }; @@ -531,8 +531,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.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3t1toqktTLqKKdcXhaf4EO.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */, ); path = Libraries; sourceTree = ""; diff --git a/cabal.project b/cabal.project index 2e1b43d41f..a40bcc43bc 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: a491a1d8780054432542611f540317a6090b9360 + tag: 5c0adcbbff873bf9e58d8939f8e8178692aec384 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 53ccda920f..317ba843a8 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a491a1d8780054432542611f540317a6090b9360" = "183wmraa25rxcf3b07apimsdvamccc3qx3p5rr726qzvpkvrxpab"; + "https://github.com/simplex-chat/simplexmq.git"."5c0adcbbff873bf9e58d8939f8e8178692aec384" = "0qxyv4fn5kh5dwx03n8d09x72w15vg19s1ikq2q5cgpq404z799x"; "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/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 3b1f28dd27..18398ecb3e 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -23,10 +23,10 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (Contact, ContactId, User) -import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) data Call = Call diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index ae88bc796b..79d416dee5 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -50,10 +50,11 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON) import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 15135d4e9e..5240460c9c 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -50,8 +50,9 @@ import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (User) import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 566cd04003..2281c1aefa 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -29,7 +29,6 @@ import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) @@ -54,11 +53,12 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 5ba7565611..2bc9e725e5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -52,11 +52,11 @@ import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) -import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 2e704c5cf5..63d80657dc 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -32,8 +32,9 @@ import qualified Data.Text as T import GHC.Records.Compat import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Shared +import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) data ChatFeature diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index e22610cfe5..4c6adae4e9 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -7,8 +7,8 @@ import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (blobFieldDecoder) import Simplex.Messaging.Util ((<$?>)) data GroupMemberRole diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index f2512a3a5a..d0f23a7307 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -15,8 +15,9 @@ import Data.Maybe (fromMaybe) import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Util +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) data UITheme = UITheme diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 28f7befebe..c41c155697 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -54,8 +54,8 @@ import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (srvHostnamesSMPClientVersion) import Simplex.Messaging.Server (runSMPServerBlocking) -import Simplex.Messaging.Server.Env.STM (ServerConfig (..), StartOptions (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) -import Simplex.Messaging.Server.MsgStore.Types (AMSType (..), SMSType (..)) +import Simplex.Messaging.Server.Env.STM (AServerStoreCfg (..), ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) +import Simplex.Messaging.Server.MsgStore.Types (SQSType (..), SMSType (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransportServerConfig) import Simplex.Messaging.Version @@ -476,14 +476,12 @@ smpServerCfg = ServerConfig { transports = [(serverPort, transport @TLS, False)], tbqSize = 1, - msgStoreType = AMSType SMSMemory, msgQueueQuota = 16, maxJournalMsgCount = 24, maxJournalStateLines = 4, queueIdBytes = 12, msgIdBytes = 6, - storeLogFile = Nothing, - storeMsgsFile = Nothing, + serverStoreCfg = ASSCfg SQSMemory SMSMemory $ SSCMemory Nothing, storeNtfsFile = Nothing, allowNewQueues = True, -- server password is disabled as otherwise v1 tests fail @@ -518,9 +516,12 @@ smpServerCfg = allowSMPProxy = True, serverClientConcurrency = 16, information = Nothing, - startOptions = StartOptions False False + startOptions = StartOptions {maintenance = False, compactLog = False, skipWarnings = False, confirmMigrations = MCYesUp} } +persistentServerStoreCfg :: FilePath -> AServerStoreCfg +persistentServerStoreCfg tmp = ASSCfg SQSMemory SMSMemory $ SSCMemory $ Just StorePaths {storeLogFile = tmp <> "/smp-server-store.log", storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"} + withSmpServer :: IO () -> IO () withSmpServer = withSmpServer' smpServerCfg diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 998835b867..5c18088b76 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -270,8 +270,7 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes smpServerCfg { transports = [("7003", transport @TLS, False)], msgQueueQuota = 2, - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests cfg' = @@ -329,8 +328,7 @@ testRetryConnectingClientTimeout ps = do smpServerCfg { transports = [("7003", transport @TLS, False)], msgQueueQuota = 2, - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests cfg' = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 2e99de4a82..73a8735f63 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1988,8 +1988,7 @@ testSharedMessageBody ps = serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } opts' = testOpts @@ -2045,8 +2044,7 @@ testSharedBatchBody ps = serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } opts' = testOpts diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 5d2b9f5ba8..72d502392e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -311,8 +311,7 @@ testRetryAcceptingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile b smpServerCfg { transports = [("7003", transport @TLS, False)], msgQueueQuota = 2, - storeLogFile = Just $ tmp <> "/smp-server-store.log", - storeMsgsFile = Just $ tmp <> "/smp-server-messages.log" + serverStoreCfg = persistentServerStoreCfg tmp } fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests cfg' = From 6e7df9c72d848460c9e23cf866976c45366970f3 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:06:08 +0700 Subject: [PATCH 145/289] android, desktop: menu near top floating button (#5764) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c3f4b7749..78e97ffbf2 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 @@ -1754,7 +1754,7 @@ fun BoxScope.FloatingButtons( onLongClick = { showDropDown.value = true } ) - Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) { + Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).align(Alignment.TopEnd)) { val density = LocalDensity.current val width = remember { mutableStateOf(250.dp) } DefaultDropdownMenu( From 6b75f61537de98fd7cdf7fa731ee9016e9a35d92 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:07:05 +0700 Subject: [PATCH 146/289] android, desktop: scrolling improvements (#5753) * android, desktop: scrolling improvements * more changes * fixes * search * fix concurrency --------- Co-authored-by: Evgeny Poberezkin --- .../common/views/chat/ChatItemsLoader.kt | 194 ++++++++++++++- .../simplex/common/views/chat/ChatView.kt | 231 +++++++++--------- .../views/chatlist/ChatListNavLinkView.kt | 2 +- 3 files changed, 305 insertions(+), 122 deletions(-) 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 9050804db4..385bf42397 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 @@ -6,6 +6,7 @@ import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.chatModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.Instant import kotlin.math.min const val TRIM_KEEP_COUNT = 200 @@ -122,18 +123,20 @@ suspend fun processLoadedChat( } } is ChatPagination.Around -> { - val newSplits = if (openAroundItemId == null) { + val newSplits: ArrayList = if (openAroundItemId == null) { newItems.addAll(oldItems) - removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) + ArrayList(removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)) } else { - emptyList() + arrayListOf() } - // currently, items will always be added on top, which is index 0 - newItems.addAll(0, chat.chatItems) + val (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.lastOrNull(), to = newItems, newSplits.toSet()) + //indexToInsertAroundTest() + newItems.addAll(itemIndex, chat.chatItems) + newSplits.add(splitIndex, chat.chatItems.last().id) withChats(contentTag) { chatItems.replaceAll(newItems) - splits.value = listOf(chat.chatItems.last().id) + newSplits + splits.value = newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal unreadTotal.value = chat.chatStats.unreadCount @@ -151,10 +154,12 @@ suspend fun processLoadedChat( } is ChatPagination.Last -> { newItems.addAll(oldItems) + val newSplits = removeDuplicatesAndUnusedSplits(newItems, chat, chatState.splits.value) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) withChats(contentTag) { chatItems.replaceAll(newItems) + chatState.splits.value = newSplits unreadAfterNewestLoaded.value = 0 } } @@ -240,7 +245,15 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination( val indexInSplitRanges = splits.value.indexOf(paginationChatItemId) // Currently, it should always load from split range val loadingFromSplitRange = indexInSplitRanges != -1 - val splitsToMerge = if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) else ArrayList() + val topSplits: List + val splitsToMerge: ArrayList + if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) { + splitsToMerge = ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) + topSplits = splits.value.take(indexInSplitRanges + 1) + } else { + splitsToMerge = ArrayList() + topSplits = emptyList() + } newItems.removeAll { val duplicate = newIds.contains(it.id) if (loadingFromSplitRange && duplicate) { @@ -259,8 +272,8 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination( } var newSplits: List = emptyList() if (firstItemIdBelowAllSplits != null) { - // no splits anymore, all were merged with bottom items - newSplits = emptyList() + // no splits below anymore, all were merged with bottom items + newSplits = topSplits } else { if (splitsToRemove.isNotEmpty()) { val new = ArrayList(splits.value) @@ -323,6 +336,31 @@ private fun removeDuplicatesAndUpperSplits( return newSplits } +private fun removeDuplicatesAndUnusedSplits( + newItems: SnapshotStateList, + chat: Chat, + splits: List +): List { + if (splits.isEmpty()) { + removeDuplicates(newItems, chat) + return splits + } + + val newSplits = splits.toMutableList() + val (newIds, _) = mapItemsToIds(chat.chatItems) + newItems.removeAll { + val duplicate = newIds.contains(it.id) + if (duplicate) { + val firstIndex = newSplits.indexOf(it.id) + if (firstIndex != -1) { + newSplits.removeAt(firstIndex) + } + } + duplicate + } + return newSplits +} + // ids, number of unread items private fun mapItemsToIds(items: List): Pair, Int> { var unreadInLoaded = 0 @@ -343,3 +381,141 @@ private fun removeDuplicates(newItems: SnapshotStateList, chat: Chat) val (newIds, _) = mapItemsToIds(chat.chatItems) newItems.removeAll { newIds.contains(it.id) } } + +private data class SameTimeItem(val index: Int, val item: ChatItem) + +// return (item index, split index) +private fun indexToInsertAround(chatType: ChatType, lastNew: ChatItem?, to: List, splits: Set): Pair { + if (to.size <= 0 || lastNew == null) { + return 0 to 0 + } + // group sorting: item_ts, item_id + // everything else: created_at, item_id + val compareByTimeTs = chatType == ChatType.Group + // in case several items have the same time as another item in the `to` array + var sameTime: ArrayList = arrayListOf() + + // trying to find new split index for item looks difficult but allows to not use one more loop. + // The idea is to memorize how many splits were till any index (map number of splits until index) + // and use resulting itemIndex to decide new split index position. + // Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`. + val splitsTillIndex: ArrayList = arrayListOf() + var splitsPerPrevIndex = 0 + + for (i in to.indices) { + val item = to[i] + + splitsPerPrevIndex = if (splits.contains(item.id)) splitsPerPrevIndex + 1 else splitsPerPrevIndex + splitsTillIndex.add(splitsPerPrevIndex) + val itemIsNewer = (if (compareByTimeTs) item.meta.itemTs > lastNew.meta.itemTs else item.meta.createdAt > lastNew.meta.createdAt) + if (itemIsNewer || i + 1 == to.size) { + val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt + if (same) { + sameTime.add(SameTimeItem(i, item)) + } + // time to stop the loop. Item is newer, or it's the last item in `to` array, taking previous items and checking position inside them + val itemIndex: Int + val first = if (sameTime.size > 1) sameTime.sortedWith { prev, next -> prev.item.meta.itemId.compareTo(next.item.id) }.firstOrNull { same -> same.item.id > lastNew.id } else null + if (sameTime.size > 1 && first != null) { + itemIndex = first.index + } else if (sameTime.size == 1) { + itemIndex = if (sameTime[0].item.id > lastNew.id) sameTime[0].index else sameTime[0].index + 1 + } else { + itemIndex = if (itemIsNewer) i else i + 1 + } + val splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.size - 1)] + val prevItemSplitIndex = if (itemIndex == 0) 0 else splitsTillIndex[min(itemIndex - 1, splitsTillIndex.size - 1)] + return Pair(itemIndex, if (splitIndex == prevItemSplitIndex) splitIndex else prevItemSplitIndex) + } + val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt + if (same) { + sameTime.add(SameTimeItem(index = i, item = item)) + } else { + sameTime = arrayListOf() + } + } + // shouldn't be here + return Pair(to.size, splits.size) +} + +private fun indexToInsertAroundTest() { + fun assert(one: Pair, two: Pair) { + if (one != two) { + throw Exception("$one != $two") + } + } + + val itemsToInsert = listOf(ChatItem.getSampleData(3, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 3), "")) + val items1 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 0), ""), + ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 1), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 2), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items1, setOf(1)), Pair(3, 1)) + + val items2 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(1), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items2, setOf(2)), Pair(3, 1)) + + val items3 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items3, setOf(1)), Pair(3, 1)) + + val items4 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items4, setOf(4)), Pair(1, 0)) + + val items5 = listOf( + ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""), + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items5, setOf(2)), Pair(2, 1)) + + val items6 = listOf( + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items6, setOf(5)), Pair(0, 0)) + + val items7 = listOf( + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, null, to = items7, setOf(6)), Pair(0, 0)) + + val items8 = listOf( + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items8, setOf(2)), Pair(0, 0)) + + val items9 = listOf( + ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items9, setOf(5)), Pair(1, 0)) + + val items10 = listOf( + ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""), + ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "") + ) + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items10, setOf(4)), Pair(0, 0)) + + val items11: List = listOf() + assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items11, emptySet()), Pair(0, 0)) +} 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 78e97ffbf2..46965d07fc 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 @@ -1227,7 +1227,7 @@ fun BoxScope.ChatItemsList( if (reportsState != null) { reportsListState = null reportsState - } else if (index <= 0) { + } else if (index <= 0 || !searchValueIsEmpty.value) { LazyListState(0, 0) } else { LazyListState(index + 1, -maxHeightForList.value) @@ -1242,19 +1242,19 @@ fun BoxScope.ChatItemsList( if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) } - if (!loadingMoreItems.value) { - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> - if (loadingMoreItems.value) return@PreloadItems false + PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false + loadingMoreItems.value = true + withContext(NonCancellable) { try { - loadingMoreItems.value = true loadMessages(chatId, pagination) { visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } finally { loadingMoreItems.value = false } - true } + true } val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) @@ -1275,7 +1275,6 @@ fun BoxScope.ChatItemsList( scrollToItemId.value = null } } } - LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo) SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) @@ -1583,7 +1582,7 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(topPaddingToContent, topPaddingToContentPx, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) + FloatingButtons(reversedChatItems, chatInfoUpdated, topPaddingToContent, topPaddingToContentPx, contentTag, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState, loadMessages) FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) LaunchedEffect(Unit) { @@ -1603,21 +1602,17 @@ fun BoxScope.ChatItemsList( } } -@Composable -private fun LoadLastItems(loadingMoreItems: MutableState, resetListState: State, remoteHostId: Long?, chatInfo: ChatInfo) { - val contentTag = LocalContentTag.current - LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) { - try { - loadingMoreItems.value = true - if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect - delay(500) - withContext(Dispatchers.Default) { - apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, contentTag, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) - } - } finally { - loadingMoreItems.value = false - } - } +private suspend fun loadLastItems(chatId: State, contentTag: MsgContentTag?, listState: State, loadItems: State Boolean>) { + val lastVisible = listState.value.layoutInfo.visibleItemsInfo.lastOrNull() + val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size + if (!itemsCanCoverScreen) return + + val chatState = chatModel.chatStateForContent(contentTag) + val lastItemsLoaded = chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id + if (lastItemsLoaded) return + + delay(500) + loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } // TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message @@ -1680,8 +1675,11 @@ private fun NotifyChatListOnFinishingComposition( @Composable fun BoxScope.FloatingButtons( + reversedChatItems: State>, + chatInfo: State, topPaddingToContent: Dp, topPaddingToContentPx: State, + contentTag: MsgContentTag?, loadingMoreItems: MutableState, animatedScrollingInProgress: MutableState, mergedItems: State, @@ -1690,7 +1688,8 @@ fun BoxScope.FloatingButtons( composeViewHeight: State, searchValue: State, markChatRead: () -> Unit, - listState: State + listState: State, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit ) { val scope = rememberCoroutineScope() val bottomUnreadCount = remember { @@ -1734,7 +1733,9 @@ fun BoxScope.FloatingButtons( // Don't show top FAB if is in search if (searchValue.value.isNotEmpty()) return val fabSize = 56.dp - val topUnreadCount = remember { derivedStateOf { if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 } } + val topUnreadCount = remember { derivedStateOf { + if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 } + } val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( @@ -1742,14 +1743,31 @@ fun BoxScope.FloatingButtons( topUnreadCount, animatedScrollingInProgress, onClick = { - val index = mergedItems.value.items.indexOfLast { it.hasUnread() } - if (index != -1) { - // scroll to the top unread item scope.launch { - animatedScrollingInProgress.value = true - tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) } + tryBlockAndSetLoadingMore(loadingMoreItems) { + if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) { + val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + val oldSize = reversedChatItems.value.size + loadMessages(chatInfo.value.id, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) + } + var repeatsLeft = 100 + while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) { + delay(10) + repeatsLeft-- + } + if (oldSize == reversedChatItems.value.size) { + return@tryBlockAndSetLoadingMore + } + } + val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + if (index != -1) { + // scroll to the top unread item + animatedScrollingInProgress.value = true + listState.value.animateScrollToItem(index + 1, -maxHeight.value) + } + } } - } }, onLongClick = { showDropDown.value = true } ) @@ -1777,6 +1795,8 @@ fun BoxScope.FloatingButtons( fun PreloadItems( chatId: String, ignoreLoadingRequests: MutableSet, + loadingMoreItems: State, + resetListState: State, contentTag: MsgContentTag?, mergedItems: State, listState: State, @@ -1788,13 +1808,32 @@ fun PreloadItems( val chatId = rememberUpdatedState(chatId) val loadItems = rememberUpdatedState(loadItems) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) - PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems) - PreloadItemsAfter(allowLoad, chatId, contentTag, mergedItems, listState, remaining, loadItems) + LaunchedEffect(Unit) { + snapshotFlow { chatId.value } + .distinctUntilChanged() + .filterNotNull() + .collect { + allowLoad.value = false + delay(500) + allowLoad.value = true + } + } + if (allowLoad.value && !loadingMoreItems.value) { + LaunchedEffect(chatId.value, resetListState.value) { + snapshotFlow { listState.value.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { firstVisibleIndex -> + if (!preloadItemsBefore(firstVisibleIndex, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems)) { + preloadItemsAfter(firstVisibleIndex, chatId, contentTag, mergedItems, remaining, loadItems) + } + loadLastItems(chatId, contentTag, listState, loadItems) + } + } + } } -@Composable -private fun PreloadItemsBefore( - allowLoad: State, +private suspend fun preloadItemsBefore( + firstVisibleIndex: Int, chatId: State, ignoreLoadingRequests: State>, contentTag: MsgContentTag?, @@ -1802,83 +1841,47 @@ private fun PreloadItemsBefore( listState: State, remaining: Int, loadItems: State Boolean>, -) { - KeyChangeEffect(allowLoad.value, chatId.value) { - snapshotFlow { listState.value.firstVisibleItemIndex } - .distinctUntilChanged() - .map { firstVisibleIndex -> - val splits = mergedItems.value.splits - val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) - var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = reversedChatItemsStatic(contentTag) - if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { - lastIndexToLoadFrom = items.lastIndex - } - if (allowLoad.value && lastIndexToLoadFrom != null) { - items.getOrNull(lastIndexToLoadFrom)?.id - } else { - null - } - } - .filterNotNull() - .filter { !ignoreLoadingRequests.value.contains(it) } - .collect { loadFromItemId -> - withBGApi { - val items = reversedChatItemsStatic(contentTag) - val sizeWas = items.size - val oldestItemIdWas = items.lastOrNull()?.id - val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - val itemsUpdated = reversedChatItemsStatic(contentTag) - if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { - ignoreLoadingRequests.value.add(loadFromItemId) - } - } - } +): Boolean { + val splits = mergedItems.value.splits + val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) + val items = reversedChatItemsStatic(contentTag) + if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { + lastIndexToLoadFrom = items.lastIndex } + if (lastIndexToLoadFrom != null) { + val loadFromItemId = items.getOrNull(lastIndexToLoadFrom)?.id ?: return false + if (!ignoreLoadingRequests.value.contains(loadFromItemId)) { + val items = reversedChatItemsStatic(contentTag) + val sizeWas = items.size + val oldestItemIdWas = items.lastOrNull()?.id + val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) + val itemsUpdated = reversedChatItemsStatic(contentTag) + if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { + ignoreLoadingRequests.value.add(loadFromItemId) + return false + } + return triedToLoad + } + } + return false } -@Composable -private fun PreloadItemsAfter( - allowLoad: MutableState, +private suspend fun preloadItemsAfter( + firstVisibleIndex: Int, chatId: State, contentTag: MsgContentTag?, mergedItems: State, - listState: State, remaining: Int, loadItems: State Boolean>, ) { - LaunchedEffect(Unit) { - snapshotFlow { chatId.value } - .distinctUntilChanged() - .filterNotNull() - .collect { - allowLoad.value = listState.value.layoutInfo.totalItemsCount == listState.value.layoutInfo.visibleItemsInfo.size - delay(500) - allowLoad.value = true - } - } - LaunchedEffect(chatId.value) { - launch { - snapshotFlow { listState.value.firstVisibleItemIndex } - .distinctUntilChanged() - .map { firstVisibleIndex -> - val items = reversedChatItemsStatic(contentTag) - val splits = mergedItems.value.splits - val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } - // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) - if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { - items.getOrNull(split.indexRangeInReversed.first)?.id - } else { - null - } - } - .filterNotNull() - .collect { loadFromItemId -> - withBGApi { - loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - } - } - } + val items = reversedChatItemsStatic(contentTag) + val splits = mergedItems.value.splits + val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } + // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) + if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { + val loadFromItemId = items.getOrNull(split.indexRangeInReversed.first)?.id ?: return + loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT)) } } @@ -2115,7 +2118,7 @@ private fun DateSeparator(date: Instant) { @Composable private fun MarkItemsReadAfterDelay( - itemKey: String, + itemKey: ChatViewItemKey, itemIds: List, finishedInitialComposition: State, chatId: ChatId, @@ -2153,18 +2156,20 @@ private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State, mergedItems: State, listState: State): ListItem? { val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - return mergedItems.value.items.getOrNull((listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> + val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> item.offset <= lastFullyVisibleOffset - }?.index ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest() + }?.key as? ChatViewItemKey + return mergedItems.value.items.getOrNull((mergedItems.value.indexInParentItems[visibleKey?.first] ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest() } private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State, density: Float, fontSizeSqrtMultiplier: Float, mergedItems: State, listState: State): ChatItem? { val lastFullyVisibleOffsetMinusFloatingHeight = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - 50 * density * fontSizeSqrtMultiplier + val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> + item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0 + }?.key as? ChatViewItemKey + return mergedItems.value.items.getOrNull( - (listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> - item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0 - } - ?.index + (mergedItems.value.indexInParentItems[visibleKey?.first] ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.newest()?.item } @@ -2652,7 +2657,9 @@ fun providerForGallery( } } -private fun keyForItem(item: ChatItem): String = (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() +typealias ChatViewItemKey = Pair + +private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds()) private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { override val longPressTimeoutMillis 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 5e33117e62..8b4b2bb1d2 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 @@ -245,7 +245,7 @@ suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag? withChats(contentTag) { chatItems.clearAndNotify() } - apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search) + apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { From cd20dc0a04336e5ea6e44c890fa9139ee625031a Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:11:53 +0700 Subject: [PATCH 147/289] android, desktop: enhancements to floating buttons (#5763) * android, desktop: enhancements to floating buttons * size * size --- .../simplex/common/views/chat/ChatView.kt | 242 +++++++++++++----- 1 file changed, 178 insertions(+), 64 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 46965d07fc..94a5fd3549 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 @@ -1185,6 +1185,22 @@ fun BoxScope.ChatItemsList( developerTools: Boolean, showViaProxy: Boolean ) { + val loadingTopItems = remember { mutableStateOf(false) } + val loadingBottomItems = remember { mutableStateOf(false) } + // just for changing local var here based on request + val loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit = { chatId, pagination, visibleItemIndexesNonReversed -> + val loadingSide = when (pagination) { + is ChatPagination.Before -> loadingTopItems + is ChatPagination.Last -> loadingBottomItems + is ChatPagination.After, is ChatPagination.Around, is ChatPagination.Initial -> null + } + loadingSide?.value = true + try { + loadMessages(chatId, pagination, visibleItemIndexesNonReversed) + } finally { + loadingSide?.value = false + } + } val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } @@ -1582,7 +1598,25 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(reversedChatItems, chatInfoUpdated, topPaddingToContent, topPaddingToContentPx, contentTag, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState, loadMessages) + FloatingButtons( + reversedChatItems, + chatInfoUpdated, + topPaddingToContent, + topPaddingToContentPx, + contentTag, + 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) { @@ -1607,14 +1641,17 @@ private suspend fun loadLastItems(chatId: State, contentTag: MsgContentT val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size if (!itemsCanCoverScreen) return - val chatState = chatModel.chatStateForContent(contentTag) - val lastItemsLoaded = chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id - if (lastItemsLoaded) return + if (lastItemsLoaded(contentTag)) return delay(500) loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } +private fun lastItemsLoaded(contentTag: MsgContentTag?): Boolean { + val chatState = chatModel.chatStateForContent(contentTag) + return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id +} + // TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message // can be reproduced by forwarding a message to notes that is (ChatPagination.INITIAL_COUNT - 1) away from bottom and going to that message @Composable @@ -1681,6 +1718,8 @@ fun BoxScope.FloatingButtons( topPaddingToContentPx: State, contentTag: MsgContentTag?, loadingMoreItems: MutableState, + loadingTopItems: MutableState, + loadingBottomItems: MutableState, animatedScrollingInProgress: MutableState, mergedItems: State, unreadCount: State, @@ -1692,6 +1731,40 @@ fun BoxScope.FloatingButtons( loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit ) { val scope = rememberCoroutineScope() + fun scrollToBottom() { + scope.launch { + animatedScrollingInProgress.value = true + tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } + } + } + fun scrollToTopUnread() { + scope.launch { + tryBlockAndSetLoadingMore(loadingMoreItems) { + if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) { + val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + val oldSize = reversedChatItems.value.size + loadMessages(chatInfo.value.id, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) + } + var repeatsLeft = 100 + while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) { + delay(10) + repeatsLeft-- + } + if (oldSize == reversedChatItems.value.size) { + return@tryBlockAndSetLoadingMore + } + } + val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + if (index != -1) { + // scroll to the top unread item + animatedScrollingInProgress.value = true + listState.value.animateScrollToItem(index + 1, -maxHeight.value) + } + } + } + } + val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1717,19 +1790,48 @@ fun BoxScope.FloatingButtons( allowToShowBottomWithArrow.value = shouldShow shouldShow && allow } } + + val requestedTopScroll = remember { mutableStateOf(false) } + val requestedBottomScroll = remember { mutableStateOf(false) } + BottomEndFloatingButton( bottomUnreadCount, showBottomButtonWithCounter, showBottomButtonWithArrow, + requestedBottomScroll, animatedScrollingInProgress, composeViewHeight, onClick = { - scope.launch { - animatedScrollingInProgress.value = true - tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } + if (loadingBottomItems.value || !lastItemsLoaded(contentTag)) { + requestedTopScroll.value = false + requestedBottomScroll.value = true + } else { + scrollToBottom() } } ) + LaunchedEffect(Unit) { + launch { + snapshotFlow { loadingTopItems.value } + .drop(1) + .collect { top -> + if (!top && requestedTopScroll.value) { + requestedTopScroll.value = false + scrollToTopUnread() + } + } + } + launch { + snapshotFlow { loadingBottomItems.value } + .drop(1) + .collect { bottom -> + if (!bottom && requestedBottomScroll.value) { + requestedBottomScroll.value = false + scrollToBottom() + } + } + } + } // Don't show top FAB if is in search if (searchValue.value.isNotEmpty()) return val fabSize = 56.dp @@ -1741,33 +1843,15 @@ fun BoxScope.FloatingButtons( TopEndFloatingButton( Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd), topUnreadCount, + requestedTopScroll, animatedScrollingInProgress, onClick = { - scope.launch { - tryBlockAndSetLoadingMore(loadingMoreItems) { - if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) { - val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) - val oldSize = reversedChatItems.value.size - loadMessages(chatInfo.value.id, pagination) { - visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) - } - var repeatsLeft = 100 - while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) { - delay(10) - repeatsLeft-- - } - if (oldSize == reversedChatItems.value.size) { - return@tryBlockAndSetLoadingMore - } - } - val index = mergedItems.value.items.indexOfLast { it.hasUnread() } - if (index != -1) { - // scroll to the top unread item - animatedScrollingInProgress.value = true - listState.value.animateScrollToItem(index + 1, -maxHeight.value) - } - } - } + if (loadingTopItems.value) { + requestedBottomScroll.value = false + requestedTopScroll.value = true + } else { + scrollToTopUnread() + } }, onLongClick = { showDropDown.value = true } ) @@ -1896,6 +1980,7 @@ fun MemberImage(member: GroupMember) { private fun TopEndFloatingButton( modifier: Modifier = Modifier, unreadCount: State, + requestedTopScroll: State, animatedScrollingInProgress: State, onClick: () -> Unit, onLongClick: () -> Unit @@ -1909,11 +1994,15 @@ private fun TopEndFloatingButton( elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp), interactionSource = interactionSource, ) { - Text( - unreadCountStr(unreadCount.value), - color = MaterialTheme.colors.primary, - fontSize = 14.sp, - ) + if (requestedTopScroll.value) { + LoadingProgressIndicator() + } else { + Text( + unreadCountStr(unreadCount.value), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } } } } @@ -2281,39 +2370,50 @@ private fun BoxScope.BottomEndFloatingButton( unreadCount: State, showButtonWithCounter: State, showButtonWithArrow: State, + requestedBottomScroll: State, animatedScrollingInProgress: State, composeViewHeight: State, onClick: () -> Unit -) = when { - showButtonWithCounter.value && !animatedScrollingInProgress.value -> { - FloatingActionButton( - onClick = onClick, - elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), - backgroundColor = MaterialTheme.colors.secondaryVariant, - ) { - Text( - unreadCountStr(unreadCount.value), - color = MaterialTheme.colors.primary, - fontSize = 14.sp, - ) +) { + when { + showButtonWithCounter.value && !animatedScrollingInProgress.value -> { + FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), + backgroundColor = MaterialTheme.colors.secondaryVariant, + ) { + if (requestedBottomScroll.value) { + LoadingProgressIndicator() + } else { + Text( + unreadCountStr(unreadCount.value), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } + } } - } - showButtonWithArrow.value && !animatedScrollingInProgress.value -> { - FloatingActionButton( - onClick = onClick, - elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), - backgroundColor = MaterialTheme.colors.secondaryVariant, - ) { - Icon( - painter = painterResource(MR.images.ic_keyboard_arrow_down), - contentDescription = null, - tint = MaterialTheme.colors.primary - ) + showButtonWithArrow.value && !animatedScrollingInProgress.value -> { + FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), + backgroundColor = MaterialTheme.colors.secondaryVariant, + ) { + if (requestedBottomScroll.value) { + LoadingProgressIndicator() + } else { + Icon( + painter = painterResource(MR.images.ic_keyboard_arrow_down), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + } } + else -> {} } - else -> {} } @Composable @@ -2339,6 +2439,20 @@ fun SelectedListItem( ) } +@Composable +private fun LoadingProgressIndicator() { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.dp + ) + } +} + private fun selectUnselectChatItem( select: Boolean, ci: ChatItem, From 6020c6010d23f87f8abf162fc61859b4bbebeeb8 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 21 Mar 2025 06:06:52 +0700 Subject: [PATCH 148/289] ios: moving content up when setting emoji on the first message (#5766) --- apps/ios/Shared/Views/Chat/EndlessScrollView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 670707c0c1..cc61754b26 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -345,7 +345,11 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu if let visibleIndex { let v = oldVisible.remove(at: visibleIndex) if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue { + let wasHeight = v.view.bounds.height updateCell(v.view, i, items) + if wasHeight < v.view.bounds.height && i == 0 && shouldBeFirstVisible == i { + v.view.frame.origin.y -= v.view.bounds.height - wasHeight + } } visible = v } else { From 15742aee3072091f52b5d65102617c656f46402f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 21 Mar 2025 11:49:59 +0000 Subject: [PATCH 149/289] ios: XCode 16 workaround to prevent stack overflow (#5771) * ios: Workaround for stackoverflow with Xcode 16 - Increased stack size to 4MiB - Fix: https://github.com/simplex-chat/simplex-chat/issues/4837 * Remove Main Thread Stack Size Linker Setting Removed the linker setting for the main thread stack size as the main thread is no longer used. * Set Thread Stack Size to 2MiB Set the thread stack size to 2MiB. In my environment, 992KiB worked fine, so increasing the size to more than double should provide sufficient margin. * ios: moving content up when setting emoji on the first message (#5766) * simplify --------- Co-authored-by: ISHIHARA Kazuto Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/SimpleXChat/API.swift | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 5e5f047611..e439cd337b 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -183,7 +183,9 @@ public func chatResponse(_ s: String) -> ChatResponse { // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) + let r = try callWithLargeStack { + try jsonDecoder.decode(APIResponse.self, from: d) + } return r.resp } catch { logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") @@ -231,6 +233,32 @@ public func chatResponse(_ s: String) -> ChatResponse { return ChatResponse.response(type: type ?? "invalid", json: json ?? s) } +private let largeStackSize: Int = 2 * 1024 * 1024 + +private func callWithLargeStack(_ f: @escaping () throws -> T) throws -> T { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + let thread = Thread { + do { + result = .success(try f()) + } catch { + result = .failure(error) + } + semaphore.signal() + } + + thread.stackSize = largeStackSize + thread.qualityOfService = Thread.current.qualityOfService + thread.start() + + semaphore.wait() + + switch result! { + case let .success(r): return r + case let .failure(e): throw e + } +} + private func decodeUser_(_ jDict: NSDictionary) -> UserRef? { if let user_ = jDict["user_"] { try? decodeObject(user_ as Any) From 9f853e2e84fa48787a12550a4f8221bee9cdc5d5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 22 Mar 2025 14:20:29 +0000 Subject: [PATCH 150/289] core: 6.3.1.0 (simplexmq 6.3.1.0) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index a40bcc43bc..0554d14ec8 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: 5c0adcbbff873bf9e58d8939f8e8178692aec384 + tag: aace3fd2fb146097304b09ef07ff613a8b255f67 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 317ba843a8..41b45e9ddc 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5c0adcbbff873bf9e58d8939f8e8178692aec384" = "0qxyv4fn5kh5dwx03n8d09x72w15vg19s1ikq2q5cgpq404z799x"; + "https://github.com/simplex-chat/simplexmq.git"."aace3fd2fb146097304b09ef07ff613a8b255f67" = "0iqdarkvlakk4xmrqsg62z0vhs3kwm02l8vpr383vf8q2hd7ky75"; "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 c9dc9f6afe..f2b3582722 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.0.8 +version: 6.3.1.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 99dcaa34bac4be3dad9910aecd6bc98e90db3aa5 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Tue, 25 Mar 2025 12:32:12 +0000 Subject: [PATCH 151/289] readme: update SimpleX users group link (#5772) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1830228370..7e6e5d975d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ You must: Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. -You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D) +You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D) There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform: From f8fddb1daf949cfbe84613ae6d984286ff7a4228 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 25 Mar 2025 12:52:49 +0000 Subject: [PATCH 152/289] docs: update server doc about reproducing builds (#5779) --- docs/SERVER.md | 52 +++++++++++++++----------------------------------- 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 3bfd064c4e..4ddfb68e63 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -15,7 +15,7 @@ revision: 12.10.2024 - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) - [docker container](#docker-container) - [Linode marketplace](#linode-marketplace) -- [Verifying server binaries] +- [Verifying server binaries](#verifying-server-binaries) - [Configuration](#configuration) - [Interactively](#interactively) - [Via command line options](#via-command-line-options) @@ -1592,7 +1592,9 @@ To update your smp-server to latest version, choose your installation method and You can locally reproduce server binaries, following these instructions. -You must have: +If you are a security expert or researcher, you can help SimpleX network and users community by signing the release checksums – we will [publish your signature](https://github.com/simplex-chat/simplexmq/releases/tag/v6.3.1). Please reach out to us! + +To reproduce the build you must have: - Linux machine - `x86-64` architecture @@ -1613,52 +1615,28 @@ You must have: 3. Execute the script with the required tag: ```sh - ./reproduce-builds.sh 'v6.3.0' + ./reproduce-builds.sh 'v6.3.1' ``` + The script executes these steps (please review the script to confirm): + + 1) builds all server binaries for the release in docker container. + 2) downloads binaries from the same GitHub release and compares them with the built binaries. + 3) if they all match, generates _sha256sums file with their checksums. + This will take a while. -4. After compilation, you should see the following folders: +4. After compilation, you should see the folder named as the tag (e.g., `v6.3.1`) with two subfolders: ```sh - ls out* + ls v6.3.1 ``` ```sh - out-20.04: - ntf-server smp-server xftp xftp-server - - out-20.04-github: - ntf-server smp-server xftp xftp-server - - out-22.04: - ntf-server smp-server xftp xftp-server - - out-22.04-github: - ntf-server smp-server xftp xftp-server - - out-24.04: - ntf-server smp-server xftp xftp-server - - out-24.04-github: - ntf-server smp-server xftp xftp-server + from-source prebuilt _sha256sums ``` -5. Compare the hashes from github release with locally build binaries: - - ```sh - sha256sum out*-github/* - ``` - - ```sh - sha256sum out*[0-9]/* - ``` - - You can safely delete cloned repository: - - ```sh - cd ../ && rm -rf simplexmq - ``` + The file _sha256sums contains the hashes of all builds - you can compare it with the same file in GitHub release. ## Configuring the app to use the server From 444378647468126bcadd1fdbff0d53fcf4ad8a35 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 28 Mar 2025 15:37:39 +0000 Subject: [PATCH 153/289] ui: move operators selection to sheet on onboarding (#5783) * ios: show updated conditions always on what's new screen * rework onboarding * update text * android whatsnew * android wip * layout * improve what's new layout * remove * fix desktop --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/ContentView.swift | 13 +- .../Onboarding/ChooseServerOperators.swift | 438 ++++++++---------- .../Views/Onboarding/CreateProfile.swift | 2 +- .../Views/Onboarding/OnboardingView.swift | 4 +- .../Shared/Views/Onboarding/SimpleXInfo.swift | 1 + .../Views/Onboarding/WhatsNewView.swift | 13 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +- .../kotlin/chat/simplex/common/App.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 20 +- .../views/onboarding/ChooseServerOperators.kt | 230 ++++----- .../common/views/onboarding/WhatsNewView.kt | 70 +-- .../commonMain/resources/MR/base/strings.xml | 7 +- 12 files changed, 348 insertions(+), 468 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 65631954e5..305ad0a601 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -11,12 +11,10 @@ import SimpleXChat private enum NoticesSheet: Identifiable { case whatsNew(updatedConditions: Bool) - case updatedConditions var id: String { switch self { case .whatsNew: return "whatsNew" - case .updatedConditions: return "updatedConditions" } } } @@ -278,10 +276,8 @@ struct ContentView: View { let showWhatsNew = shouldShowWhatsNew() let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false noticesShown = showWhatsNew || showUpdatedConditions - if showWhatsNew { + if showWhatsNew || showUpdatedConditions { noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions) - } else if showUpdatedConditions { - noticesSheetItem = .updatedConditions } } } @@ -300,13 +296,6 @@ struct ContentView: View { .if(updatedConditions) { v in v.task { await setConditionsNotified_() } } - case .updatedConditions: - UsageConditionsView( - currUserServers: Binding.constant([]), - userServers: Binding.constant([]) - ) - .modifier(ThemedBackground(grouped: true)) - .task { await setConditionsNotified_() } } } if chatModel.setDeliveryReceipts { diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 8523336d2b..45ef186671 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -43,26 +43,23 @@ struct OnboardingButtonStyle: ButtonStyle { } } -private enum ChooseServerOperatorsSheet: Identifiable { - case showInfo +private enum OnboardingConditionsViewSheet: Identifiable { case showConditions + case configureOperators var id: String { switch self { - case .showInfo: return "showInfo" case .showConditions: return "showConditions" + case .configureOperators: return "configureOperators" } } } -struct ChooseServerOperators: View { - @Environment(\.dismiss) var dismiss: DismissAction - @Environment(\.colorScheme) var colorScheme: ColorScheme +struct OnboardingConditionsView: View { @EnvironmentObject var theme: AppTheme - var onboarding: Bool @State private var serverOperators: [ServerOperator] = [] @State private var selectedOperatorIds = Set() - @State private var sheetItem: ChooseServerOperatorsSheet? = nil + @State private var sheetItem: OnboardingConditionsViewSheet? = nil @State private var notificationsModeNavLinkActive = false @State private var justOpened = true @@ -72,16 +69,192 @@ struct ChooseServerOperators: View { GeometryReader { g in ScrollView { VStack(alignment: .leading, spacing: 20) { - let title = Text("Server operators") + Text("Conditions of use") .font(.largeTitle) .bold() .frame(maxWidth: .infinity, alignment: .center) - - if onboarding { - title.padding(.top, 25) - } else { - title + .padding(.top, 25) + + Spacer() + + VStack(alignment: .leading, spacing: 20) { + Text("Private chats, groups and your contacts are not accessible to server operators.") + .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + Text(""" + By using SimpleX Chat you agree to: + - send only legal content in public groups. + - respect other users – no spam. + """) + .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + + Button("Privacy policy and conditions of use.") { + sheetItem = .showConditions + } + .frame(maxWidth: .infinity, alignment: .leading) } + .padding(.horizontal, 4) + + Spacer() + + VStack(spacing: 12) { + acceptConditionsButton() + + Button("Configure server operators") { + sheetItem = .configureOperators + } + .frame(minHeight: 40) + } + } + .frame(minHeight: g.size.height) + } + .onAppear { + if justOpened { + serverOperators = ChatModel.shared.conditions.serverOperators + selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) + justOpened = false + } + } + .sheet(item: $sheetItem) { item in + switch item { + case .showConditions: + SimpleConditionsView() + .modifier(ThemedBackground(grouped: true)) + case .configureOperators: + ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds) + .modifier(ThemedBackground()) + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + .frame(maxHeight: .infinity, alignment: .top) + .padding(25) + } + + private func continueToNextStep() { + onboardingStageDefault.set(.step4_SetNotificationsMode) + notificationsModeNavLinkActive = true + } + + func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View { + ZStack { + button() + + NavigationLink(isActive: $notificationsModeNavLinkActive) { + notificationsModeDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func notificationsModeDestinationView() -> some View { + SetNotificationsMode() + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground()) + } + + private func acceptConditionsButton() -> some View { + notificationsModeNavLinkButton { + Button { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + let operatorIds = acceptForOperators.map { $0.operatorId } + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + await MainActor.run { + ChatModel.shared.conditions = r + } + if let enabledOperators = enabledOperators(r.serverOperators) { + let r2 = try await setServerOperators(operators: enabledOperators) + await MainActor.run { + ChatModel.shared.conditions = r2 + continueToNextStep() + } + } else { + await MainActor.run { + continueToNextStep() + } + } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } label: { + Text("Accept") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + } + } + + private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { + var ops = operators + if !ops.isEmpty { + for i in 0.. + @State private var sheetItem: ChooseServerOperatorsSheet? = nil + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Server operators") + .font(.largeTitle) + .bold() + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 25) infoText() .frame(maxWidth: .infinity, alignment: .center) @@ -101,74 +274,25 @@ struct ChooseServerOperators: View { .padding(.horizontal, 16) Spacer() - - let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed } - let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) - + VStack(spacing: 8) { - if !reviewForOperators.isEmpty { - reviewConditionsButton() - } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty { - setOperatorsButton() - } else { - continueButton() - } - if onboarding { - Group { - if reviewForOperators.isEmpty { - Button("Conditions of use") { - sheetItem = .showConditions - } - } else { - Text("Conditions of use") - .foregroundColor(.clear) - } - } - .font(.system(size: 17, weight: .semibold)) - .frame(minHeight: 40) - } - } - - if !onboarding && !reviewForOperators.isEmpty { - VStack(spacing: 8) { - reviewLaterButton() - ( - Text("Conditions will be accepted for enabled operators after 30 days.") - + textSpace - + Text("You can configure operators in Network & servers settings.") - ) - .multilineTextAlignment(.center) - .font(.footnote) - .padding(.horizontal, 32) - } - .frame(maxWidth: .infinity) - .disabled(!canReviewLater) - .padding(.bottom) + setOperatorsButton() + onboardingButtonPlaceholder() } } .frame(minHeight: g.size.height) } - .onAppear { - if justOpened { - serverOperators = ChatModel.shared.conditions.serverOperators - selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) - justOpened = false - } - } .sheet(item: $sheetItem) { item in switch item { case .showInfo: ChooseServerOperatorsInfoView() - case .showConditions: - SimpleConditionsView() - .modifier(ThemedBackground(grouped: true)) } } .frame(maxHeight: .infinity, alignment: .top) } .frame(maxHeight: .infinity, alignment: .top) - .padding(onboarding ? 25 : 16) + .padding(25) + .interactiveDismissDisabled(selectedOperatorIds.isEmpty) } private func infoText() -> some View { @@ -213,181 +337,15 @@ struct ChooseServerOperators: View { } } - private func reviewConditionsButton() -> some View { - NavigationLink("Review conditions") { - reviewConditionsView() - .navigationTitle("Conditions of use") - .navigationBarTitleDisplayMode(.large) - .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } - .modifier(ThemedBackground(grouped: true)) + private func setOperatorsButton() -> some View { + Button { + dismiss() + } label: { + Text("OK") } .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) .disabled(selectedOperatorIds.isEmpty) } - - private func setOperatorsButton() -> some View { - notificationsModeNavLinkButton { - Button { - Task { - if let enabledOperators = enabledOperators(serverOperators) { - let r = try await setServerOperators(operators: enabledOperators) - await MainActor.run { - ChatModel.shared.conditions = r - continueToNextStep() - } - } else { - await MainActor.run { - continueToNextStep() - } - } - } - } label: { - Text("Update") - } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) - } - } - - private func continueButton() -> some View { - notificationsModeNavLinkButton { - Button { - continueToNextStep() - } label: { - Text("Continue") - } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) - } - } - - private func reviewLaterButton() -> some View { - notificationsModeNavLinkButton { - Button { - continueToNextStep() - } label: { - Text("Review later") - } - .buttonStyle(.borderless) - } - } - - private func continueToNextStep() { - if onboarding { - onboardingStageDefault.set(.step4_SetNotificationsMode) - notificationsModeNavLinkActive = true - } else { - dismiss() - } - } - - func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View { - ZStack { - button() - - NavigationLink(isActive: $notificationsModeNavLinkActive) { - notificationsModeDestinationView() - } label: { - EmptyView() - } - .frame(width: 1, height: 1) - .hidden() - } - } - - private func notificationsModeDestinationView() -> some View { - SetNotificationsMode() - .navigationBarBackButtonHidden(true) - .modifier(ThemedBackground()) - } - - @ViewBuilder private func reviewConditionsView() -> some View { - let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted } - let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - VStack(alignment: .leading, spacing: 20) { - if !operatorsWithConditionsAccepted.isEmpty { - Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.") - Text("The same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") - } else { - Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") - } - ConditionsTextView() - .frame(maxHeight: .infinity) - acceptConditionsButton() - .padding(.bottom) - .padding(.bottom) - } - .padding(.horizontal, 25) - } - - private func acceptConditionsButton() -> some View { - notificationsModeNavLinkButton { - Button { - Task { - do { - let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId - let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - let operatorIds = acceptForOperators.map { $0.operatorId } - let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) - await MainActor.run { - ChatModel.shared.conditions = r - } - if let enabledOperators = enabledOperators(r.serverOperators) { - let r2 = try await setServerOperators(operators: enabledOperators) - await MainActor.run { - ChatModel.shared.conditions = r2 - continueToNextStep() - } - } else { - await MainActor.run { - continueToNextStep() - } - } - } catch let error { - await MainActor.run { - showAlert( - NSLocalizedString("Error accepting conditions", comment: "alert title"), - message: responseError(error) - ) - } - } - } - } label: { - Text("Accept conditions") - } - .buttonStyle(OnboardingButtonStyle()) - } - } - - private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { - var ops = operators - if !ops.isEmpty { - for i in 0.. some View { - ChooseServerOperators(onboarding: true) + OnboardingConditionsView() .navigationBarBackButtonHidden(true) .modifier(ThemedBackground()) } diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index b2b1b8fa68..8f448dc508 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -23,7 +23,7 @@ struct OnboardingView: View { case .step3_CreateSimpleXAddress: // deprecated CreateSimpleXAddress() case .step3_ChooseServerOperators: - ChooseServerOperators(onboarding: true) + OnboardingConditionsView() .navigationBarBackButtonHidden(true) .modifier(ThemedBackground()) case .step4_SetNotificationsMode: @@ -44,7 +44,7 @@ enum OnboardingStage: String, Identifiable { case step1_SimpleXInfo case step2_CreateProfile // deprecated case step3_CreateSimpleXAddress // deprecated - case step3_ChooseServerOperators + case step3_ChooseServerOperators // changed to simplified conditions case step4_SetNotificationsMode case onboardingComplete diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index dbae3e9fb3..e55cc4037a 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -129,6 +129,7 @@ struct SimpleXInfo: View { NavigationLink(isActive: $createProfileNavLinkActive) { CreateFirstProfile() + .modifier(ThemedBackground()) } label: { EmptyView() } diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index f7c7145dcc..f65a21623a 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -594,8 +594,6 @@ func shouldShowWhatsNew() -> Bool { } fileprivate struct NewOperatorsView: View { - @State private var showOperatorsSheet = false - var body: some View { VStack(alignment: .leading) { Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo) @@ -606,16 +604,7 @@ fileprivate struct NewOperatorsView: View { .multilineTextAlignment(.leading) .lineLimit(10) HStack { - Button("Enable Flux") { - showOperatorsSheet = true - } - Text("for better metadata privacy.") - } - } - .sheet(isPresented: $showOperatorsSheet) { - NavigationView { - ChooseServerOperators(onboarding: false) - .modifier(ThemedBackground()) + Text("Enable Flux in Network & servers settings for better metadata privacy.") } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6727643022..b7db01ec2b 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.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.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 */; }; @@ -531,8 +531,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.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 2456463910..600804a763 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -194,7 +194,7 @@ fun MainScreen() { OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) OnboardingStage.Step3_ChooseServerOperators -> { val modalData = remember { ModalData() } - modalData.ChooseServerOperators(true) + modalData.OnboardingConditionsView() if (appPlatform.isDesktop) { ModalManager.fullscreen.showInView() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index a6774c6870..3538d41f01 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -127,31 +127,13 @@ fun ToggleChatListCard() { @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } - val rhId = chatModel.remoteHostId() LaunchedEffect(Unit) { val showWhatsNew = shouldShowWhatsNew(chatModel) val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false - if (showWhatsNew) { + if (showWhatsNew || showUpdatedConditions) { delay(1000L) ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) } - } else if (showUpdatedConditions) { - ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> - LaunchedEffect(Unit) { - val conditionsId = chatModel.conditions.value.currentConditions.conditionsId - try { - setConditionsNotified(rh = rhId, conditionsId = conditionsId) - } catch (e: Exception) { - Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}") - } - } - UsageConditionsView( - userServers = mutableStateOf(emptyList()), - currUserServers = mutableStateOf(emptyList()), - close = close, - rhId = rhId - ) - } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index 80cc977602..a14f163a91 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -7,15 +7,18 @@ import SectionTextFooter import SectionView import TextIconSpaced import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* @@ -27,11 +30,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun ModalData.ChooseServerOperators( - onboarding: Boolean, - close: (() -> Unit) = { ModalManager.fullscreen.closeModals() }, - modalManager: ModalManager = ModalManager.fullscreen -) { +fun ModalData.OnboardingConditionsView() { LaunchedEffect(Unit) { prepareChatBeforeFinishingOnboarding() } @@ -41,6 +40,73 @@ fun ModalData.ChooseServerOperators( val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } } val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } } + ColumnWithScrollBar( + Modifier + .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), + maxIntrinsicSize = true + ) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), bottomPadding = DEFAULT_PADDING) + } + + Spacer(Modifier.weight(1f)) + Column( + (if (appPlatform.isDesktop) Modifier.width(450.dp).align(Alignment.CenterHorizontally) else Modifier) + .fillMaxWidth() + .padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING), + horizontalAlignment = Alignment.Start + ) { + Text( + stringResource(MR.strings.onboarding_conditions_private_chats_not_accessible), + style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp) + ) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.onboarding_conditions_by_using_you_agree), + style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp) + ) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.onboarding_conditions_privacy_policy_and_conditions_of_use), + style = TextStyle(fontSize = 17.sp), + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + ModalManager.fullscreen.showModal(endButtons = { ConditionsLinkButton() }) { + SimpleConditionsView(rhId = null) + } + } + ) + } + Spacer(Modifier.weight(1f)) + + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperators, selectedOperatorIds) + TextButtonBelowOnboardingButton(stringResource(MR.strings.onboarding_conditions_configure_server_operators)) { + ModalManager.fullscreen.showModalCloseable { close -> + ChooseServerOperators(serverOperators, selectedOperatorIds, close) + } + } + } + } + } + } +} + +@Composable +fun ModalData.ChooseServerOperators( + serverOperators: State>, + selectedOperatorIds: MutableState>, + close: (() -> Unit) +) { + LaunchedEffect(Unit) { + prepareChatBeforeFinishingOnboarding() + } + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false) { ColumnWithScrollBar( Modifier .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), @@ -53,7 +119,7 @@ fun ModalData.ChooseServerOperators( Column(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingInformationButton( stringResource(MR.strings.how_it_helps_privacy), - onClick = { modalManager.showModal { ChooseServerOperatorsInfoView(modalManager) } } + onClick = { ModalManager.fullscreen.showModal { ChooseServerOperatorsInfoView() } } ) } @@ -77,37 +143,11 @@ fun ModalData.ChooseServerOperators( } Spacer(Modifier.weight(1f)) - val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } - val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed } - val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() - Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { val enabled = selectedOperatorIds.value.isNotEmpty() - when { - reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager) - selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close) - else -> ContinueButton(enabled, onboarding, close) - } - if (onboarding && reviewForOperators.isEmpty()) { - TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) { - modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> - SimpleConditionsView(rhId = null) - } - } - } else if (onboarding || reviewForOperators.isEmpty()) { - // Reserve space - TextButtonBelowOnboardingButton("", null) - } - if (!onboarding && reviewForOperators.isNotEmpty()) { - ReviewLaterButton(canReviewLater, close) - SectionTextFooter( - annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) + - AnnotatedString(" ") + - annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure), - textAlign = TextAlign.Center - ) - SectionBottomSpacer() - } + SetOperatorsButton(enabled, close) + // Reserve space + TextButtonBelowOnboardingButton("", null) } } } @@ -162,115 +202,36 @@ private fun CircleCheckbox(checked: Boolean) { } @Composable -private fun ReviewConditionsButton( - enabled: Boolean, - onboarding: Boolean, - selectedOperators: State>, - selectedOperatorIds: State>, - modalManager: ModalManager -) { +private fun SetOperatorsButton(enabled: Boolean, close: () -> Unit) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.operator_review_conditions, + labelId = MR.strings.ok, onboarding = null, enabled = enabled, onclick = { - modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> - ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close) - } + close() } ) } -@Composable -private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State>, selectedOperatorIds: State>, close: () -> Unit) { - OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.onboarding_network_operators_update, - onboarding = null, - enabled = enabled, - onclick = { - withBGApi { - val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value) - if (enabledOperators != null) { - val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators) - if (r != null) { - chatModel.conditions.value = r - } - continueToNextStep(onboarding, close) - } - } - } - ) -} - -@Composable -private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) { - OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.onboarding_network_operators_continue, - onboarding = null, - enabled = enabled, - onclick = { - continueToNextStep(onboarding, close) - } - ) -} - -@Composable -private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) { - TextButtonBelowOnboardingButton( - stringResource(MR.strings.onboarding_network_operators_review_later), - onClick = if (!enabled) null else {{ continueToNextStep(false, close) }} - ) -} - -@Composable -private fun ReviewConditionsView( - onboarding: Boolean, - selectedOperators: State>, - selectedOperatorIds: State>, - close: () -> Unit -) { - // remembering both since we don't want to reload the view after the user accepts conditions - val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } } - val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } } - ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = if (onboarding) DEFAULT_ONBOARDING_HORIZONTAL_PADDING else DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false, bottomPadding = DEFAULT_PADDING) - if (operatorsWithConditionsAccepted.isNotEmpty()) { - ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ }) - ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ }) - } else { - ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ }) - } - Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) { - ConditionsTextView(chatModel.remoteHostId()) - } - Column(Modifier.padding(vertical = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { - AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close) - } - } -} - @Composable private fun AcceptConditionsButton( - onboarding: Boolean, + enabled: Boolean, selectedOperators: State>, - selectedOperatorIds: State>, - close: () -> Unit + selectedOperatorIds: State> ) { fun continueOnAccept() { - if (appPlatform.isDesktop || !onboarding) { - if (onboarding) { close() } - continueToNextStep(onboarding, close) + if (appPlatform.isDesktop) { + continueToNextStep() } else { continueToSetNotificationsAfterAccept() } } OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.fillMaxWidth() else Modifier, - labelId = MR.strings.accept_conditions, + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.onboarding_conditions_accept, onboarding = null, + enabled = enabled, onclick = { withBGApi { val conditionsId = chatModel.conditions.value.currentConditions.conditionsId @@ -295,12 +256,8 @@ private fun AcceptConditionsButton( ) } -private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) { - if (onboarding) { +private fun continueToNextStep() { appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete) - } else { - close() - } } private fun continueToSetNotificationsAfterAccept() { @@ -339,9 +296,7 @@ private fun enabledOperators(operators: List, selectedOperatorId } @Composable -private fun ChooseServerOperatorsInfoView( - modalManager: ModalManager -) { +private fun ChooseServerOperatorsInfoView() { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.onboarding_network_operators)) @@ -357,21 +312,20 @@ private fun ChooseServerOperatorsInfoView( SectionView(title = stringResource(MR.strings.onboarding_network_about_operators).uppercase()) { chatModel.conditions.value.serverOperators.forEach { op -> - ServerOperatorRow(op, modalManager) + ServerOperatorRow(op) } } SectionBottomSpacer() } } -@Composable() +@Composable private fun ServerOperatorRow( - operator: ServerOperator, - modalManager: ModalManager + operator: ServerOperator ) { SectionItemView( { - modalManager.showModalCloseable { close -> + ModalManager.fullscreen.showModalCloseable { close -> OperatorInfoView(operator) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index de9f909150..52eea3dd9d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -14,7 +14,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatController.appPrefs @@ -161,10 +161,14 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool } if (updatedConditions) { - Row( + Text( + stringResource(MR.strings.view_updated_conditions), + color = MaterialTheme.colors.primary, modifier = Modifier - .clip(shape = CircleShape) - .clickable { + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { modalManager.showModalCloseable { close -> UsageConditionsView( userServers = mutableStateOf(emptyList()), @@ -174,15 +178,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool ) } } - .padding(horizontal = 6.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - stringResource(MR.strings.view_updated_conditions), - color = MaterialTheme.colors.primary - ) - } + ) } if (!viaSettings) { @@ -190,14 +186,21 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool Box( Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - Text( - generalGetString(MR.strings.ok), - modifier = Modifier.clickable(onClick = { - close() - }), - style = MaterialTheme.typography.h3, - color = MaterialTheme.colors.primary - ) + Box(Modifier.clip(RoundedCornerShape(20.dp))) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clickable { close() } + .padding(8.dp) + ) { + Text( + generalGetString(MR.strings.ok), + style = MaterialTheme.typography.h3, + color = MaterialTheme.colors.primary + ) + } + } } Spacer(Modifier.fillMaxHeight().weight(1f)) } @@ -213,8 +216,17 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool fun ReadMoreButton(url: String) { val uriHandler = LocalUriHandler.current Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = DEFAULT_PADDING.div(4))) { - Text(stringResource(MR.strings.whats_new_read_more), color = MaterialTheme.colors.primary, - modifier = Modifier.clickable { uriHandler.openUriCatching(url) }) + Text( + stringResource(MR.strings.whats_new_read_more), + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + uriHandler.openUriCatching(url) + } + ) Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.whats_new_read_more), tint = MaterialTheme.colors.primary) } } @@ -751,17 +763,7 @@ private val versionDescriptions: List = listOf( val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo Image(painterResource(src), null, modifier = Modifier.height(48.dp)) Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp)) - Row { - Text( - stringResource(MR.strings.v6_2_network_decentralization_enable_flux), - color = MaterialTheme.colors.primary, - modifier = Modifier.clickable { - modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) } - } - ) - Text(" ") - Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason)) - } + Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux)) } } ), 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 5e8d22cb99..d905ab71ea 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1162,6 +1162,11 @@ Use random passphrase + Private chats, groups and your contacts are not accessible to server operators. + By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam. + Privacy policy and conditions of use. + Accept + Configure server operators Server operators Network operators SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. @@ -2291,7 +2296,7 @@ Delete or moderate up to 200 messages. Network decentralization The second preset operator in the app! - Enable flux + Enable Flux in Network & servers settings for better metadata privacy. for better metadata privacy. Improved chat navigation - Open chat on the first unread message.\n- Jump to quoted messages. From 27f2926aed8bfe3869290b33fbf807c5fdd5bae6 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 28 Mar 2025 18:48:54 +0000 Subject: [PATCH 154/289] directory: joining groups with enabled captcha screening and observer role (#5784) * directory: joining groups with enabled captcha screen (test) * fix directory, test * query plans --- .../src/Directory/Service.hs | 22 ++--- src/Simplex/Chat/Store/Profiles.hs | 26 +++--- .../SQLite/Migrations/chat_query_plans.txt | 16 ++-- tests/Bots/DirectoryTests.hs | 89 ++++++++++++++++++- 4 files changed, 122 insertions(+), 31 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 575c7ca738..054f261b4e 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -469,7 +469,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do - gli_ <- join <$> withDB' cc (\db -> getGroupLinkInfo db userId groupId) + gli_ <- join <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) 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 @@ -698,7 +698,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName case acceptance_ of Just a' | a /= a' -> do let d = toCustomData $ DirectoryGroupData a' - withDB' cc (\db -> setGroupCustomData db user g $ Just d) >>= \case + withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case Just () -> sendSettigns n a' " set to" Nothing -> sendReply $ "Error changing spam filter settings for group " <> n _ -> sendSettigns n a "" @@ -977,24 +977,24 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName sendComposedMessage cc ct Nothing $ MCText text getContact' :: ChatController -> User -> ContactId -> IO (Maybe Contact) -getContact' cc user ctId = withDB cc $ \db -> getContact db (vr cc) user ctId +getContact' cc user ctId = withDB "getContact" cc $ \db -> getContact db (vr cc) user ctId getGroup :: ChatController -> User -> GroupId -> IO (Maybe GroupInfo) -getGroup cc user gId = withDB cc $ \db -> getGroupInfo db (vr cc) user gId +getGroup cc user gId = withDB "getGroupInfo" cc $ \db -> getGroupInfo db (vr cc) user gId -withDB' :: ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) -withDB' cc a = withDB cc $ ExceptT . fmap Right . a +withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) +withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a -withDB :: ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) -withDB ChatController {chatStore} action = do +withDB :: Text -> ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) +withDB cxt ChatController {chatStore} action = do r_ :: Either ChatError a <- withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors case r_ of Right r -> pure $ Just r - Left e -> Nothing <$ logError ("Database error: " <> tshow e) + Left e -> Nothing <$ logError ("Database error: " <> cxt <> " " <> tshow e) getGroupAndSummary :: ChatController -> User -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) getGroupAndSummary cc user gId = - withDB cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) + withDB "getGroupAndSummary" cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) vr :: ChatController -> VersionRangeChat vr ChatController {config = ChatConfig {chatVRange}} = chatVRange @@ -1002,7 +1002,7 @@ vr ChatController {config = ChatConfig {chatVRange}} = chatVRange getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, ConnReqContact, GroupMemberRole)) getGroupLinkRole cc user gInfo = - withDB cc $ \db -> getGroupLink db user gInfo + withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe ConnReqContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 22d2a7b1f5..bdd54c3f1e 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -492,15 +492,14 @@ getUserAddress db User {userId} = getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupLinkInfo) getUserContactLinkById db userId userContactLinkId = ExceptT . firstRow (\(ucl :. gli) -> (toUserContactLink ucl, toGroupLinkInfo gli)) SEUserContactLinkNotFound $ - DB.query db (groupLinkInfoQuery <> " AND user_contact_link_id = ?") (userId, userContactLinkId) - -groupLinkInfoQuery :: Query -groupLinkInfoQuery = - [sql| - 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 = ? - |] + DB.query + db + [sql| + 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 = ? + |] + (userId, userContactLinkId) toGroupLinkInfo :: (Maybe GroupId, Maybe GroupMemberRole) -> Maybe GroupLinkInfo toGroupLinkInfo (groupId_, mRole_) = @@ -510,7 +509,14 @@ toGroupLinkInfo (groupId_, mRole_) = getGroupLinkInfo :: DB.Connection -> UserId -> GroupId -> IO (Maybe GroupLinkInfo) getGroupLinkInfo db userId groupId = fmap join $ maybeFirstRow toGroupLinkInfo $ - DB.query db (groupLinkInfoQuery <> " AND group_id = ?") (userId, groupId) + DB.query + db + [sql| + SELECT group_id, group_link_member_role + FROM user_contact_links + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = 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..6f1d243cf3 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -2950,6 +2950,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, @@ -4642,14 +4650,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/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index e9db100e8d..6601032a79 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -20,7 +20,7 @@ import Directory.Service import Directory.Store import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) import Simplex.Chat.Core import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB @@ -64,6 +64,8 @@ directoryServiceTests = do it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval describe "list groups" $ do it "should list user's groups" testListUserGroups + describe "member admission" $ do + it "should ask member to pass captcha screen" testCapthaScreening describe "store log" $ do it "should restore directory service state" testRestoreDirectory describe "captcha" $ do @@ -954,6 +956,88 @@ testListUserGroups ps = groupNotFound cath "anonymity" listGroups superUser bob cath +testCapthaScreening :: HasCallStack => TestParams -> IO () +testCapthaScreening ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + -- check default role + bob #> "@SimpleX-Directory /role 1" + bob <# "SimpleX-Directory> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /role 1 observer to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + -- enable captcha + bob #> "@SimpleX-Directory /filter 1 captcha" + bob <# "SimpleX-Directory> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + 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 <## " Incorrect text, please try again." + captcha <- dropStrPrefix "#privacy 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" + bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + cath #> "#privacy hello" + bob <# "#privacy cath> hello" + cath ##> "/l privacy" + cath <## "#privacy: you left the group" + cath <## "use /d #privacy to delete the group" + bob <## "#privacy: cath left the group" + cath ##> "/d #privacy" + cath <## "#privacy: you deleted the group" + -- change default role to observer + bob #> "@SimpleX-Directory /role 1 observer" + bob <# "SimpleX-Directory> > /role 1 observer" + bob <## " The initial member role for the group privacy is set to observer" + bob <## "" + bob <##. "Please note: it applies only to members joining via this link: https://" + -- connect with captcha screen again, as observer + captcha' <- join cath groupLink + sendCaptcha cath captcha' + -- message from cath that left + pastMember <- dropStrPrefix "#privacy: SimpleX-Directory forwarded a message from an unknown member, creating unknown member record " <$> getTermLine cath + cath <# ("#privacy " <> pastMember <> "> hello [>>]") + cath <#. "#privacy SimpleX-Directory> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: SimpleX-Directory added cath_1 (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath_1 is connected" + cath ##> "#privacy hello" + cath <## "#privacy: you don't have permission to send messages" + (bob "/ms privacy" + cath <## "cath (Catherine): observer, you, connected" + cath <## "SimpleX-Directory: admin, host, connected" + cath <## "bob (Bob): owner, connected" + cath <## (pastMember <> ": author, status unknown") + where + join cath groupLink = do + cath ##> ("/c " <> groupLink) + 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 <## "" + cath <## "Send captcha text to join the group privacy." + dropStrPrefix "#privacy SimpleX-Directory> " . dropTime <$> getTermLine cath + sendCaptcha cath captcha = do + cath #> ("#privacy " <> captcha) + cath <# ("#privacy SimpleX-Directory!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + testRestoreDirectory :: HasCallStack => TestParams -> IO () testRestoreDirectory ps = do testListUserGroups ps @@ -1137,7 +1221,8 @@ runDirectory cfg opts@DirectoryOpts {directoryLog} action = do where bot st = do env <- newServiceState opts - simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts env + let cfg' = cfg {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} + simplexChatCore cfg' (mkChatOpts opts) $ directoryService st opts env registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 From 7c1d900e1f1fe65f3e1148845594b1000dd0baaa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 29 Mar 2025 20:47:15 +0000 Subject: [PATCH 155/289] core: 6.3.1.1 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f2b3582722..f3095e7fce 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.1.0 +version: 6.3.1.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From af56b3fed006b42d0b6740ed8817551fbd9f39fa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 31 Mar 2025 17:27:12 +0100 Subject: [PATCH 156/289] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index b7db01ec2b..060f0effe8 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.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.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 */; }; @@ -531,8 +531,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.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.0-IjBs5IcA9K0E6bK6RlYtK.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */, ); path = Libraries; sourceTree = ""; From a32ed2ec1f794011b659b10576ba054150a7145a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 31 Mar 2025 19:29:45 +0100 Subject: [PATCH 157/289] ui: translations (#5791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Spanish) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2341 of 2341 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% (2055 of 2055 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% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Japanese) Currently translated at 82.8% (1939 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Dutch) Currently translated at 99.3% (2326 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 99.8% (2051 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 96.1% (2252 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2341 of 2341 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% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 96.8% (2267 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 96.8% (2267 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Japanese) Currently translated at 83.2% (1950 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 98.1% (2298 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Japanese) Currently translated at 83.3% (1951 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 98.1% (2298 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2341 of 2341 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% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Czech) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Vietnamese) Currently translated at 99.7% (2334 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2341 of 2341 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% (2055 of 2055 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% (2341 of 2341 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% (2055 of 2055 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% (2055 of 2055 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% (2341 of 2341 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% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2341 of 2341 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% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Indonesian) Currently translated at 99.1% (2321 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Indonesian) Currently translated at 99.7% (2334 of 2341 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% (2341 of 2341 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% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Korean) Currently translated at 21.9% (451 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Arabic) Currently translated at 30.8% (633 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (Italian) Currently translated at 100.0% (2341 of 2341 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% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Russian) Currently translated at 100.0% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2341 of 2341 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% (2055 of 2055 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% (2341 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 99.9% (2340 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Turkish) Currently translated at 93.1% (2181 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Russian) Currently translated at 96.6% (1987 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (French) Currently translated at 98.6% (2309 of 2341 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 84.3% (1734 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 99.7% (2341 of 2346 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% (2346 of 2346 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% (2055 of 2055 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% (2346 of 2346 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% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 99.8% (2343 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Spanish) Currently translated at 99.8% (2343 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2055 of 2055 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Italian) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Russian) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * export/import localizations * add translations for onboarding * translation --------- Co-authored-by: No name Co-authored-by: fran secs Co-authored-by: summoner001 Co-authored-by: k-kozika Co-authored-by: M1K4 Co-authored-by: João Moreira Co-authored-by: Igor Julliano Co-authored-by: Miyu Sakatsuki Co-authored-by: zenobit Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: Rafi Co-authored-by: dtalens Co-authored-by: jaeone Co-authored-by: Muhammad Co-authored-by: Random Co-authored-by: mlanp Co-authored-by: Volkan Yıldırım Co-authored-by: Near Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: Ross Li Co-authored-by: 大王叫我来巡山 Co-authored-by: Mihai Pantazi Co-authored-by: jonnysemon --- .../ar.xcloc/Localized Contents/ar.xliff | 100 ++++- .../bg.xcloc/Localized Contents/bg.xliff | 109 +++--- .../bg.xcloc/contents.json | 4 +- .../cs.xcloc/Localized Contents/cs.xliff | 108 +++--- .../cs.xcloc/contents.json | 4 +- .../de.xcloc/Localized Contents/de.xliff | 154 ++++---- .../de.xcloc/contents.json | 4 +- .../en.xcloc/Localized Contents/en.xliff | 123 +++--- .../en.xcloc/contents.json | 4 +- .../es.xcloc/Localized Contents/es.xliff | 174 ++++----- .../es.xcloc/contents.json | 4 +- .../fi.xcloc/Localized Contents/fi.xliff | 109 +++--- .../fi.xcloc/contents.json | 4 +- .../fr.xcloc/Localized Contents/fr.xliff | 116 +++--- .../fr.xcloc/contents.json | 4 +- .../hu.xcloc/Localized Contents/hu.xliff | 364 +++++++++--------- .../hu.xcloc/contents.json | 4 +- .../it.xcloc/Localized Contents/it.xliff | 124 +++--- .../it.xcloc/contents.json | 4 +- .../ja.xcloc/Localized Contents/ja.xliff | 109 +++--- .../ja.xcloc/contents.json | 4 +- .../ko.xcloc/Localized Contents/ko.xliff | 204 +++++++++- .../nl.xcloc/Localized Contents/nl.xliff | 130 +++---- .../nl.xcloc/contents.json | 4 +- .../pl.xcloc/Localized Contents/pl.xliff | 110 +++--- .../pl.xcloc/contents.json | 4 +- .../ru.xcloc/Localized Contents/ru.xliff | 193 +++------- .../ru.xcloc/contents.json | 4 +- .../th.xcloc/Localized Contents/th.xliff | 108 +++--- .../th.xcloc/contents.json | 4 +- .../tr.xcloc/Localized Contents/tr.xliff | 112 +++--- .../tr.xcloc/contents.json | 4 +- .../uk.xcloc/Localized Contents/uk.xliff | 116 +++--- .../uk.xcloc/contents.json | 4 +- .../Localized Contents/zh-Hans.xliff | 118 +++--- .../zh-Hans.xcloc/contents.json | 4 +- .../SimpleX SE/hu.lproj/Localizable.strings | 4 +- apps/ios/bg.lproj/Localizable.strings | 45 +-- apps/ios/cs.lproj/Localizable.strings | 37 +- apps/ios/de.lproj/Localizable.strings | 110 +++--- .../de.lproj/SimpleX--iOS--InfoPlist.strings | 2 +- apps/ios/es.lproj/Localizable.strings | 132 +++---- apps/ios/fi.lproj/Localizable.strings | 40 +- apps/ios/fr.lproj/Localizable.strings | 66 +--- apps/ios/hu.lproj/Localizable.strings | 316 ++++++++------- apps/ios/it.lproj/Localizable.strings | 82 ++-- apps/ios/ja.lproj/Localizable.strings | 40 +- apps/ios/nl.lproj/Localizable.strings | 90 ++--- apps/ios/pl.lproj/Localizable.strings | 48 +-- apps/ios/ru.lproj/Localizable.strings | 284 ++------------ apps/ios/th.lproj/Localizable.strings | 37 +- apps/ios/tr.lproj/Localizable.strings | 54 +-- apps/ios/uk.lproj/Localizable.strings | 66 +--- apps/ios/zh-Hans.lproj/Localizable.strings | 66 ++-- .../commonMain/resources/MR/ar/strings.xml | 7 +- .../commonMain/resources/MR/ca/strings.xml | 107 ++--- .../commonMain/resources/MR/cs/strings.xml | 8 + .../commonMain/resources/MR/de/strings.xml | 45 ++- .../commonMain/resources/MR/es/strings.xml | 165 ++++---- .../commonMain/resources/MR/fr/strings.xml | 19 + .../commonMain/resources/MR/hu/strings.xml | 275 ++++++------- .../commonMain/resources/MR/in/strings.xml | 23 +- .../commonMain/resources/MR/it/strings.xml | 9 +- .../commonMain/resources/MR/ja/strings.xml | 15 + .../commonMain/resources/MR/nl/strings.xml | 24 +- .../resources/MR/pt-rBR/strings.xml | 196 +++++++++- .../commonMain/resources/MR/ru/strings.xml | 9 +- .../commonMain/resources/MR/tr/strings.xml | 95 ++++- .../commonMain/resources/MR/vi/strings.xml | 105 ++--- .../resources/MR/zh-rCN/strings.xml | 55 +-- 70 files changed, 2712 insertions(+), 2713 deletions(-) diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index aba5e1384e..613a5e08ad 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -553,8 +553,9 @@ يمكنك أنت وجهة اتصالك إرسال رسائل صوتية. No comment provided by engineer. - + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + حسب ملف تعريف الدردشة (افتراضي) أو [حسب الاتصال] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. @@ -582,8 +583,9 @@ إلغاء No comment provided by engineer. - + Cannot access keychain to save database password + لا يمكن الوصول إلى سلسلة المفاتيح لحفظ كلمة مرور قاعدة البيانات No comment provided by engineer. @@ -601,8 +603,9 @@ تغيير عبارة مرور قاعدة البيانات؟ No comment provided by engineer. - + Change member role? + تغيير دور العضو؟ No comment provided by engineer. @@ -669,12 +672,14 @@ تحقق من عنوان الخادم وحاول مرة أخرى. No comment provided by engineer. - + Choose file + اختر الملف No comment provided by engineer. - + Choose from library + اختر من المكتبة No comment provided by engineer. @@ -756,8 +761,9 @@ جارِ الاتصال بالخادم… No comment provided by engineer. - + Connecting to server… (error: %@) + الاتصال بالخادم... (الخطأ: %@) No comment provided by engineer. @@ -5389,6 +5395,88 @@ This is your own one-time link! Enable self-destruct passcode تفعيل رمز التدمير الذاتي + + Can't message member + لا يمكن الاتصال بالعضو + + + Color chats with the new themes. + محادثات ملونة مع السمات الجديدة. + + + All chats will be removed from the list %@, and the list deleted. + ستتم إزالة جميع الدردشات من القائمة %@، وسيتم حذف القائمة. + + + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + البلغارية والفنلندية والتايلاندية والأوكرانية - شكرًا للمستخدمين و[Weblate] (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + + + Choose _Migrate from another device_ on the new device and scan QR code. + اختر _الترحيل من جهاز آخر_ على الجهاز الجديد وامسح رمز الاستجابة السريعة. + + + Conditions will be accepted for the operator(s): **%@**. + سيتم قبول شروط المشغل (المشغلين): **%@**. + + + Conditions will be accepted on: %@. + سيتم قبول الشروط على: %@. + + + Confirmed + تم التأكيد + + + Connection is blocked by server operator: +%@ + تم حظر الاتصال من قبل مشغل الخادم: +%@ + + + Can't call member + لا يمكن الاتصال بالعضو + + + Chat already exists + الدردشة موجودة بالفعل + + + Check messages every 20 min. + تحقق من الرسائل كل 20 دقيقة. + + + Check messages when allowed. + تحقق من الرسائل عندما يُسمح بذلك. + + + Cannot forward message + لا يمكن إعادة توجيه الرسالة + + + Chat preferences were changed. + تم تغيير تفضيلات المحادثة. + + + Conditions are already accepted for these operator(s): **%@**. + الشروط مقبولة بالفعل لهذا المشغل (المشغلين): **%@**. + + + Conditions will be accepted for operator(s): **%@**. + سيتم قبول شروط المشغل (المشغلين): **%@**. + + + Conditions accepted on: %@. + الشروط المقبولة على: %@. + + + Conditions are accepted for the operator(s): **%@**. + يتم قبول شروط المشغل (المشغلين): **%@**. + + + Conditions will be automatically accepted for enabled operators on: %@. + سيتم قبول الشروط تلقائيًا للمشغلين الممكّنين على: %@. + 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 7c118a877f..6fb6729a9b 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -2,7 +2,7 @@
- +
@@ -431,7 +431,7 @@ 1 day 1 ден delete after time - time interval +time interval 1 hour @@ -447,13 +447,13 @@ 1 month 1 месец delete after time - time interval +time interval 1 week 1 седмица delete after time - time interval +time interval 1 year @@ -551,8 +551,8 @@ Accept Приеми accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -573,7 +573,7 @@ Accept incognito Приеми инкогнито accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1300,6 +1300,12 @@ Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Разговорът вече приключи! @@ -1348,7 +1354,7 @@ Cancel Отказ alert action - alert button +alert button Cancel migration @@ -1437,7 +1443,7 @@ Change self-destruct passcode Промени кода за достъп за самоунищожение authentication reason - set passcode view +set passcode view Chat @@ -1654,14 +1660,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1679,6 +1677,10 @@ Конфигурирай ICE сървъри No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Потвърди @@ -2244,7 +2246,7 @@ This is your own one-time link! Delete Изтрий alert action - swipe action +swipe action Delete %lld messages of members? @@ -2711,7 +2713,7 @@ This is your own one-time link! Download Изтегли alert button - chat item action +chat item action Download errors @@ -2783,8 +2785,8 @@ This is your own one-time link! Активиране (запазване на промените) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3337,7 +3339,9 @@ This is your own one-time link! Error: %@ Грешка: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5589,11 +5593,19 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Поверителността преосмислена No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Поверителни имена на файлове @@ -5934,7 +5946,7 @@ Enable in *Network & servers* settings. Reject Отхвърляне reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6153,10 +6165,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke Отзови @@ -6203,7 +6211,7 @@ Enable in *Network & servers* settings. Save Запази alert button - chat item action +chat item action Save (and notify contacts) @@ -6736,7 +6744,7 @@ Enable in *Network & servers* settings. Share Сподели alert action - chat item action +chat item action Share 1-time link @@ -6982,7 +6990,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7330,10 +7338,6 @@ It can happen because of some bug or when the connection is compromised.The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -8246,10 +8250,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8483,11 +8483,6 @@ Repeat connection request? Вашите ICE сървъри No comment provided by engineer. - - Your SMP servers - Вашите SMP сървъри - No comment provided by engineer. - Your SimpleX address Вашият адрес в SimpleX @@ -8718,7 +8713,8 @@ Repeat connection request? blocked by admin блокиран от админ - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8887,7 +8883,7 @@ Repeat connection request? default (%@) по подразбиране (%@) delete after time - pref value +pref value default (no) @@ -9013,19 +9009,10 @@ Repeat connection request? грешка No comment provided by engineer. - - event happened - събитие се случи - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded препратено @@ -9231,8 +9218,8 @@ Repeat connection request? off изключено enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9588,7 +9575,7 @@ last received msg: %2$@
- +
@@ -9625,7 +9612,7 @@ last received msg: %2$@
- +
@@ -9647,7 +9634,7 @@ last received msg: %2$@
- +
@@ -9674,7 +9661,7 @@ last received msg: %2$@
- +
@@ -9693,7 +9680,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/contents.json b/apps/ios/SimpleX Localizations/bg.xcloc/contents.json index 5356e25a2e..66d64e6539 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/bg.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "bg", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 e8ea11fb5f..5fd894b226 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2,7 +2,7 @@
- +
@@ -420,7 +420,7 @@ 1 day 1 den delete after time - time interval +time interval 1 hour @@ -436,13 +436,13 @@ 1 month 1 měsíc delete after time - time interval +time interval 1 week 1 týden delete after time - time interval +time interval 1 year @@ -537,8 +537,8 @@ Accept Přijmout accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -558,7 +558,7 @@ Accept incognito Přijmout inkognito accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1227,6 +1227,12 @@ Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Hovor již skončil! @@ -1271,7 +1277,7 @@ Cancel Zrušit alert action - alert button +alert button Cancel migration @@ -1356,7 +1362,7 @@ Change self-destruct passcode Změnit sebedestrukční heslo authentication reason - set passcode view +set passcode view Chat @@ -1569,14 +1575,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1594,6 +1592,10 @@ Konfigurace serverů ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Potvrdit @@ -2134,7 +2136,7 @@ This is your own one-time link! Delete Smazat alert action - swipe action +swipe action Delete %lld messages of members? @@ -2591,7 +2593,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2660,8 +2662,8 @@ This is your own one-time link! Povolit (zachovat přepsání) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3197,7 +3199,9 @@ This is your own one-time link! Error: %@ Chyba: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5373,11 +5377,19 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Nové vymezení soukromí No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Soukromé názvy souborů @@ -5710,7 +5722,7 @@ Enable in *Network & servers* settings. Reject Odmítnout reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5923,10 +5935,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke Odvolat @@ -5972,7 +5980,7 @@ Enable in *Network & servers* settings. Save Uložit alert button - chat item action +chat item action Save (and notify contacts) @@ -6495,7 +6503,7 @@ Enable in *Network & servers* settings. Share Sdílet alert action - chat item action +chat item action Share 1-time link @@ -6737,7 +6745,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7077,10 +7085,6 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -7945,10 +7949,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8174,11 +8174,6 @@ Repeat connection request? Vaše servery ICE No comment provided by engineer. - - Your SMP servers - Vaše servery SMP - No comment provided by engineer. - Your SimpleX address Vaše SimpleX adresa @@ -8401,7 +8396,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8569,7 +8565,7 @@ Repeat connection request? default (%@) výchozí (%@) delete after time - pref value +pref value default (no) @@ -8694,18 +8690,10 @@ Repeat connection request? chyba No comment provided by engineer. - - event happened - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8909,8 +8897,8 @@ Repeat connection request? off vypnuto enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9250,7 +9238,7 @@ last received msg: %2$@
- +
@@ -9286,7 +9274,7 @@ last received msg: %2$@
- +
@@ -9308,7 +9296,7 @@ last received msg: %2$@
- +
@@ -9335,7 +9323,7 @@ last received msg: %2$@
- +
@@ -9354,7 +9342,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/contents.json b/apps/ios/SimpleX Localizations/cs.xcloc/contents.json index aaa2ed1ee0..9cd5922c24 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/cs.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "cs", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 4c391f97d3..1603a67df1 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -2,7 +2,7 @@
- +
@@ -440,7 +440,7 @@ 1 day Älter als ein Tag delete after time - time interval +time interval 1 hour @@ -456,13 +456,13 @@ 1 month Älter als ein Monat delete after time - time interval +time interval 1 week Älter als eine Woche delete after time - time interval +time interval 1 year @@ -562,8 +562,8 @@ Accept Annehmen accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -584,7 +584,7 @@ Accept incognito Inkognito akzeptieren accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1331,6 +1331,15 @@ Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden: +- nur legale Inhalte in öffentlichen Gruppen zu versenden. +- andere Nutzer zu respektieren - kein Spam. + No comment provided by engineer. + Call already ended! Anruf ist bereits beendet! @@ -1380,7 +1389,7 @@ Cancel Abbrechen alert action - alert button +alert button Cancel migration @@ -1471,7 +1480,7 @@ Change self-destruct passcode Selbstzerstörungs-Zugangscode ändern authentication reason - set passcode view +set passcode view Chat @@ -1713,16 +1722,6 @@ Nutzungsbedingungen No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Die Nutzungsbedingungen der aktivierten Betreiber werden nach 30 Tagen akzeptiert. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**. @@ -1743,6 +1742,11 @@ ICE-Server konfigurieren No comment provided by engineer. + + Configure server operators + Server-Betreiber konfigurieren + No comment provided by engineer. + Confirm Bestätigen @@ -2341,7 +2345,7 @@ Das ist Ihr eigener Einmal-Link! Delete Löschen alert action - swipe action +swipe action Delete %lld messages of members? @@ -2837,7 +2841,7 @@ Das ist Ihr eigener Einmal-Link! Download Herunterladen alert button - chat item action +chat item action Download errors @@ -2914,9 +2918,8 @@ Das ist Ihr eigener Einmal-Link! Aktivieren (vorgenommene Einstellungen bleiben erhalten) No comment provided by engineer. - - Enable Flux - Flux aktivieren + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3321,7 +3324,7 @@ Das ist Ihr eigener Einmal-Link! Error receiving file - Fehler beim Empfangen der Datei + Fehler beim Herunterladen der Datei alert title @@ -3492,7 +3495,9 @@ Das ist Ihr eigener Einmal-Link! Error: %@ Fehler: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3645,12 +3650,12 @@ Das ist Ihr eigener Einmal-Link! File will be received when your contact completes uploading it. - Die Datei wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Die Datei wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. No comment provided by engineer. File will be received when your contact is online, please wait or check later! - Die Datei wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! + Die Datei wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. @@ -4139,12 +4144,12 @@ Fehler: %2$@ Image will be received when your contact completes uploading it. - Das Bild wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Das Bild wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. No comment provided by engineer. Image will be received when your contact is online, please wait or check later! - Das Bild wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! + Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. @@ -5306,7 +5311,7 @@ Das ist Ihr Link für die Gruppe %@! No received or sent files - Keine empfangenen oder gesendeten Dateien + Keine herunter- oder hochgeladene Dateien No comment provided by engineer. @@ -5316,7 +5321,7 @@ Das ist Ihr Link für die Gruppe %@! No servers to receive files. - Keine Server für den Empfang von Dateien. + Keine Server für das Herunterladen von Dateien. servers error @@ -5893,11 +5898,21 @@ Fehler: %@ Schutz der Privatsphäre Ihrer Kunden. No comment provided by engineer. + + Privacy policy and conditions of use. + Datenschutzbestimmungen und Nutzungsbedingungen. + No comment provided by engineer. + Privacy redefined Datenschutz neu definiert No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. + No comment provided by engineer. + Private filenames Neutrale Dateinamen @@ -6172,7 +6187,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Receiving file will be stopped. - Der Empfang der Datei wird beendet. + Das Herunterladen der Datei wird beendet. No comment provided by engineer. @@ -6264,7 +6279,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Reject Ablehnen reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6501,11 +6516,6 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Nutzungsbedingungen einsehen No comment provided by engineer. - - Review later - Später einsehen - No comment provided by engineer. - Revoke Widerrufen @@ -6543,7 +6553,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Safely receive files - Dateien sicher empfangen + Dateien sicher herunterladen No comment provided by engineer. @@ -6555,7 +6565,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Save Speichern alert button - chat item action +chat item action Save (and notify contacts) @@ -7126,7 +7136,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Share Teilen alert action - chat item action +chat item action Share 1-time link @@ -7394,7 +7404,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Spam Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7453,17 +7463,17 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Stop file - Datei beenden + Herunterladen beenden cancel file action Stop receiving file? - Den Empfang der Datei beenden? + Das Herunterladen der Datei beenden? No comment provided by engineer. Stop sending file? - Das Senden der Datei beenden? + Das Hochladen der Datei beenden? No comment provided by engineer. @@ -7763,11 +7773,6 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Dieselben Nutzungsbedingungen gelten auch für den/die Betreiber: **%@**. - No comment provided by engineer. - The second preset operator in the app! Der zweite voreingestellte Netzwerk-Betreiber in der App! @@ -7825,7 +7830,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! + Es werden alle herunter- und hochgeladene Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. @@ -8424,12 +8429,12 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Video will be received when your contact completes uploading it. - Das Video wird empfangen, sobald Ihr Kontakt das Hochladen beendet hat. + Das Video wird heruntergeladen, sobald Ihr Kontakt das Hochladen beendet hat. No comment provided by engineer. Video will be received when your contact is online, please wait or check later! - Das Video wird empfangen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! + Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! No comment provided by engineer. @@ -8734,11 +8739,6 @@ Verbindungsanfrage wiederholen? Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Sie können die Betreiber in den Netzwerk- und Servereinstellungen konfigurieren. - No comment provided by engineer. - You can configure servers via settings. Sie können die Server über die Einstellungen konfigurieren. @@ -8981,11 +8981,6 @@ Verbindungsanfrage wiederholen? Ihre ICE-Server No comment provided by engineer. - - Your SMP servers - Ihre SMP-Server - No comment provided by engineer. - Your SimpleX address Ihre SimpleX-Adresse @@ -9224,7 +9219,8 @@ Verbindungsanfrage wiederholen? blocked by admin wurde vom Administrator blockiert - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9395,7 +9391,7 @@ Verbindungsanfrage wiederholen? default (%@) Default (%@) delete after time - pref value +pref value default (no) @@ -9522,21 +9518,11 @@ Verbindungsanfrage wiederholen? Fehler No comment provided by engineer. - - event happened - event happened - No comment provided by engineer. - expired Abgelaufen No comment provided by engineer. - - for better metadata privacy. - für einen besseren Metadatenschutz. - No comment provided by engineer. - forwarded weitergeleitet @@ -9746,8 +9732,8 @@ Verbindungsanfrage wiederholen? off Aus enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -10117,7 +10103,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -10147,14 +10133,14 @@ Zuletzt empfangene Nachricht: %2$@ SimpleX needs access to Photo Library for saving captured and received media - SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder empfangene Bilder zu speichern + SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder heruntergeladene Bilder zu speichern Privacy - Photo Library Additions Usage Description
- +
@@ -10176,7 +10162,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -10208,7 +10194,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -10230,7 +10216,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/de.xcloc/contents.json b/apps/ios/SimpleX Localizations/de.xcloc/contents.json index 18b517d802..e8d71cf38c 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/de.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "de", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 a205080107..8d040ff99a 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2,7 +2,7 @@
- +
@@ -440,7 +440,7 @@ 1 day 1 day delete after time - time interval +time interval 1 hour @@ -456,13 +456,13 @@ 1 month 1 month delete after time - time interval +time interval 1 week 1 week delete after time - time interval +time interval 1 year @@ -562,8 +562,8 @@ Accept Accept accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -584,7 +584,7 @@ Accept incognito Accept incognito accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1331,6 +1331,15 @@ By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Call already ended! @@ -1380,7 +1389,7 @@ Cancel Cancel alert action - alert button +alert button Cancel migration @@ -1471,7 +1480,7 @@ Change self-destruct passcode Change self-destruct passcode authentication reason - set passcode view +set passcode view Chat @@ -1713,16 +1722,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Conditions will be accepted for the operator(s): **%@**. @@ -1743,6 +1742,11 @@ Configure ICE servers No comment provided by engineer. + + Configure server operators + Configure server operators + No comment provided by engineer. + Confirm Confirm @@ -2341,7 +2345,7 @@ This is your own one-time link! Delete Delete alert action - swipe action +swipe action Delete %lld messages of members? @@ -2837,7 +2841,7 @@ This is your own one-time link! Download Download alert button - chat item action +chat item action Download errors @@ -2914,9 +2918,9 @@ This is your own one-time link! Enable (keep overrides) No comment provided by engineer. - - Enable Flux - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3492,7 +3496,9 @@ This is your own one-time link! Error: %@ Error: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5893,11 +5899,21 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Privacy redefined No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Private filenames @@ -6264,7 +6280,7 @@ Enable in *Network & servers* settings. Reject Reject reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6501,11 +6517,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - Review later - No comment provided by engineer. - Revoke Revoke @@ -6555,7 +6566,7 @@ Enable in *Network & servers* settings. Save Save alert button - chat item action +chat item action Save (and notify contacts) @@ -7126,7 +7137,7 @@ Enable in *Network & servers* settings. Share Share alert action - chat item action +chat item action Share 1-time link @@ -7394,7 +7405,7 @@ Enable in *Network & servers* settings. Spam Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7763,11 +7774,6 @@ It can happen because of some bug or when the connection is compromised.The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! The second preset operator in the app! @@ -8734,11 +8740,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. You can configure servers via settings. @@ -8981,11 +8982,6 @@ Repeat connection request? Your ICE servers No comment provided by engineer. - - Your SMP servers - Your SMP servers - No comment provided by engineer. - Your SimpleX address Your SimpleX address @@ -9224,7 +9220,8 @@ Repeat connection request? blocked by admin blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9395,7 +9392,7 @@ Repeat connection request? default (%@) default (%@) delete after time - pref value +pref value default (no) @@ -9522,21 +9519,11 @@ Repeat connection request? error No comment provided by engineer. - - event happened - event happened - No comment provided by engineer. - expired expired No comment provided by engineer. - - for better metadata privacy. - for better metadata privacy. - No comment provided by engineer. - forwarded forwarded @@ -9746,8 +9733,8 @@ Repeat connection request? off off enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -10117,7 +10104,7 @@ last received msg: %2$@
- +
@@ -10154,7 +10141,7 @@ last received msg: %2$@
- +
@@ -10176,7 +10163,7 @@ last received msg: %2$@
- +
@@ -10208,7 +10195,7 @@ last received msg: %2$@
- +
@@ -10230,7 +10217,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/contents.json b/apps/ios/SimpleX Localizations/en.xcloc/contents.json index 2f39a1f1ee..ec2accf27e 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/en.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "en", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 fd2ae881ce..4f33d923af 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -2,7 +2,7 @@
- +
@@ -440,7 +440,7 @@ 1 day un dia delete after time - time interval +time interval 1 hour @@ -456,13 +456,13 @@ 1 month un mes delete after time - time interval +time interval 1 week una semana delete after time - time interval +time interval 1 year @@ -562,8 +562,8 @@ Accept Aceptar accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -584,7 +584,7 @@ Accept incognito Aceptar incógnito accept contact request via notification - swipe action +swipe action Accepted conditions @@ -743,12 +743,12 @@ All chats and messages will be deleted - this cannot be undone! - Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse! + Se eliminarán todos los chats y mensajes. ¡No puede deshacerse! No comment provided by engineer. All chats will be removed from the list %@, and the list deleted. - Todos los chats serán quitados de la lista %@ y esta será eliminada. + Todos los chats se quitarán de la lista %@ y esta será eliminada. alert message @@ -773,12 +773,12 @@ All messages will be deleted - this cannot be undone! - Todos los mensajes serán borrados. ¡No podrá deshacerse! + Todos los mensajes serán eliminados. ¡No puede deshacerse! No comment provided by engineer. All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. - Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse! + Se eliminarán todos los mensajes SOLO para tí. ¡No puede deshacerse! No comment provided by engineer. @@ -993,7 +993,7 @@ App icon - Icono aplicación + Icono de la aplicación No comment provided by engineer. @@ -1331,6 +1331,15 @@ Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Al usar SimpleX Chat, aceptas: +- enviar únicamente contenido legal en los grupos públicos. +- respetar a los demás usuarios – spam prohibido. + No comment provided by engineer. + Call already ended! ¡La llamada ha terminado! @@ -1380,7 +1389,7 @@ Cancel Cancelar alert action - alert button +alert button Cancel migration @@ -1471,7 +1480,7 @@ Change self-destruct passcode Cambiar código autodestrucción authentication reason - set passcode view +set passcode view Chat @@ -1565,12 +1574,12 @@ Chat will be deleted for all members - this cannot be undone! - El chat será eliminado para todos los miembros. ¡No podrá deshacerse! + El chat será eliminado para todos los miembros. ¡No puede deshacerse! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - El chat será eliminado para tí. ¡No podrá deshacerse! + El chat será eliminado para tí. ¡No puede deshacerse! No comment provided by engineer. @@ -1713,16 +1722,6 @@ Condiciones de uso No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Las condiciones de los operadores habilitados serán aceptadas después de 30 días. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Las condiciones serán aceptadas para el/los operador(es): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Las condiciones serán aceptadas para el/los operador(es): **%@**. @@ -1743,6 +1742,11 @@ Configure servidores ICE No comment provided by engineer. + + Configure server operators + Configurar operadores de servidores + No comment provided by engineer. + Confirm Confirmar @@ -2026,7 +2030,7 @@ This is your own one-time link! Contact will be deleted - this cannot be undone! - El contacto será eliminado. ¡No podrá deshacerse! + El contacto será eliminado. ¡No puede deshacerse! No comment provided by engineer. @@ -2341,7 +2345,7 @@ This is your own one-time link! Delete Eliminar alert action - swipe action +swipe action Delete %lld messages of members? @@ -2515,7 +2519,7 @@ This is your own one-time link! Delete or moderate up to 200 messages. - Borra o modera hasta 200 mensajes a la vez. + Elimina o modera hasta 200 mensajes a la vez. No comment provided by engineer. @@ -2535,7 +2539,7 @@ This is your own one-time link! Delete report - Borrar informe + Eliminar informe No comment provided by engineer. @@ -2837,7 +2841,7 @@ This is your own one-time link! Download Descargar alert button - chat item action +chat item action Download errors @@ -2914,9 +2918,8 @@ This is your own one-time link! Activar (conservar anulaciones) No comment provided by engineer. - - Enable Flux - Habilita Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3492,7 +3495,9 @@ This is your own one-time link! Error: %@ Error: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3620,7 +3625,7 @@ This is your own one-time link! File not found - most likely file was deleted or cancelled. - Archivo no encontrado, probablemente haya sido borrado o cancelado. + Archivo no encontrado, probablemente haya sido eliminado o cancelado. file error text @@ -3994,12 +3999,12 @@ Error: %2$@ Group will be deleted for all members - this cannot be undone! - El grupo será eliminado para todos los miembros. ¡No podrá deshacerse! + El grupo será eliminado para todos los miembros. ¡No puede deshacerse! No comment provided by engineer. Group will be deleted for you - this cannot be undone! - El grupo será eliminado para tí. ¡No podrá deshacerse! + El grupo será eliminado para tí. ¡No puede deshacerse! No comment provided by engineer. @@ -4731,7 +4736,7 @@ This is your link for group %@! Member reports - Informes de miembro + Informes de miembros chat feature @@ -4751,12 +4756,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - El miembro será eliminado del chat. ¡No podrá deshacerse! + El miembro será eliminado del chat. ¡No puede deshacerse! No comment provided by engineer. Member will be removed from group - this cannot be undone! - El miembro será expulsado del grupo. ¡No podrá deshacerse! + El miembro será expulsado del grupo. ¡No puede deshacerse! No comment provided by engineer. @@ -4916,7 +4921,7 @@ This is your link for group %@! Messages in this chat will never be deleted. - Los mensajes de esta conversación nunca se borran. + Los mensajes de esta conversación nunca se eliminan. alert message @@ -4931,7 +4936,7 @@ This is your link for group %@! Messages were deleted after you selected them. - Los mensajes han sido borrados después de seleccionarlos. + Los mensajes han sido eliminados después de seleccionarlos. alert message @@ -5893,11 +5898,21 @@ Error: %@ Privacidad para tus clientes. No comment provided by engineer. + + Privacy policy and conditions of use. + Política de privacidad y condiciones de uso. + No comment provided by engineer. + Privacy redefined Privacidad redefinida No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores. + No comment provided by engineer. + Private filenames Nombres de archivos privados @@ -6082,7 +6097,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reachable chat toolbar - Barra de chat accesible + Barra de menú accesible No comment provided by engineer. @@ -6264,7 +6279,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reject Rechazar reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6501,11 +6516,6 @@ Actívalo en ajustes de *Servidores y Redes*. Revisar condiciones No comment provided by engineer. - - Review later - Revisar más tarde - No comment provided by engineer. - Revoke Revocar @@ -6555,7 +6565,7 @@ Actívalo en ajustes de *Servidores y Redes*. Save Guardar alert button - chat item action +chat item action Save (and notify contacts) @@ -7039,7 +7049,7 @@ Actívalo en ajustes de *Servidores y Redes*. Servers statistics will be reset - this cannot be undone! - Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse! + Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse! No comment provided by engineer. @@ -7126,7 +7136,7 @@ Actívalo en ajustes de *Servidores y Redes*. Share Compartir alert action - chat item action +chat item action Share 1-time link @@ -7280,7 +7290,7 @@ Actívalo en ajustes de *Servidores y Redes*. SimpleX address and 1-time links are safe to share via any messenger. - Compartir los enlaces de un uso y las direcciones SimpleX es seguro a través de cualquier medio. + Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. No comment provided by engineer. @@ -7394,7 +7404,7 @@ Actívalo en ajustes de *Servidores y Redes*. Spam Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7690,7 +7700,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The code you scanned is not a SimpleX link QR code. - El código QR escaneado no es un enlace SimpleX. + El código QR escaneado no es un enlace de SimpleX. No comment provided by engineer. @@ -7763,11 +7773,6 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Las mismas condiciones se aplicarán al operador **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Las mismas condiciones se aplicarán a el/los operador(es) **%@**. - No comment provided by engineer. - The second preset operator in the app! ¡Segundo operador predefinido! @@ -7795,7 +7800,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The text you pasted is not a SimpleX link. - El texto pegado no es un enlace SimpleX. + El texto pegado no es un enlace de SimpleX. No comment provided by engineer. @@ -7835,7 +7840,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. - Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No podrá deshacerse! + Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No puede deshacerse! alert message @@ -7890,7 +7895,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. This message was deleted or not received yet. - El mensaje ha sido borrado o aún no se ha recibido. + El mensaje ha sido eliminado o aún no se ha recibido. No comment provided by engineer. @@ -7947,7 +7952,7 @@ Se te pedirá que completes la autenticación antes de activar esta función.
To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Para proteger tu privacidad, en lugar de los identificadores de usuario que usan el resto de plataformas, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos. + Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos. No comment provided by engineer. @@ -8734,11 +8739,6 @@ Repeat join request? Puedes cambiar la posición de la barra desde el menú Apariencia. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Puedes configurar los operadores desde Servidores y Redes. - No comment provided by engineer. - You can configure servers via settings. Puedes configurar los servidores a través de su configuración. @@ -8981,11 +8981,6 @@ Repeat connection request? Servidores ICE No comment provided by engineer. - - Your SMP servers - Servidores SMP - No comment provided by engineer. - Your SimpleX address Mi dirección SimpleX @@ -9224,7 +9219,8 @@ Repeat connection request? blocked by admin bloqueado por administrador - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9395,7 +9391,7 @@ Repeat connection request? default (%@) predeterminado (%@) delete after time - pref value +pref value default (no) @@ -9522,21 +9518,11 @@ Repeat connection request? error No comment provided by engineer. - - event happened - evento ocurrido - No comment provided by engineer. - expired expirados No comment provided by engineer. - - for better metadata privacy. - para mejorar la privacidad de los metadatos. - No comment provided by engineer. - forwarded reenviado @@ -9746,8 +9732,8 @@ Repeat connection request? off desactivado enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -10117,7 +10103,7 @@ last received msg: %2$@
- +
@@ -10154,7 +10140,7 @@ last received msg: %2$@
- +
@@ -10176,7 +10162,7 @@ last received msg: %2$@
- +
@@ -10208,7 +10194,7 @@ last received msg: %2$@
- +
@@ -10230,7 +10216,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/es.xcloc/contents.json b/apps/ios/SimpleX Localizations/es.xcloc/contents.json index 340591e607..80cffac8d2 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/es.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "es", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 baa5fc7c48..c5393e97a1 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2,7 +2,7 @@
- +
@@ -407,7 +407,7 @@ 1 day 1 päivä delete after time - time interval +time interval 1 hour @@ -423,13 +423,13 @@ 1 month 1 kuukausi delete after time - time interval +time interval 1 week 1 viikko delete after time - time interval +time interval 1 year @@ -524,8 +524,8 @@ Accept Hyväksy accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -545,7 +545,7 @@ Accept incognito Hyväksy tuntematon accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1212,6 +1212,12 @@ Chat-profiilin mukaan (oletus) tai [yhteyden mukaan](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Puhelu on jo päättynyt! @@ -1256,7 +1262,7 @@ Cancel Peruuta alert action - alert button +alert button Cancel migration @@ -1341,7 +1347,7 @@ Change self-destruct passcode Vaihda itsetuhoutuva pääsykoodi authentication reason - set passcode view +set passcode view Chat @@ -1554,14 +1560,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1579,6 +1577,10 @@ Määritä ICE-palvelimet No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Vahvista @@ -2119,7 +2121,7 @@ This is your own one-time link! Delete Poista alert action - swipe action +swipe action Delete %lld messages of members? @@ -2576,7 +2578,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2645,8 +2647,8 @@ This is your own one-time link! Salli (pidä ohitukset) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3179,7 +3181,9 @@ This is your own one-time link! Error: %@ Virhe: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5353,11 +5357,19 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Yksityisyys uudelleen määritettynä No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Yksityiset tiedostonimet @@ -5690,7 +5702,7 @@ Enable in *Network & servers* settings. Reject Hylkää reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5903,10 +5915,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke Peruuta @@ -5952,7 +5960,7 @@ Enable in *Network & servers* settings. Save Tallenna alert button - chat item action +chat item action Save (and notify contacts) @@ -6474,7 +6482,7 @@ Enable in *Network & servers* settings. Share Jaa alert action - chat item action +chat item action Share 1-time link @@ -6715,7 +6723,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7055,10 +7063,6 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -7922,10 +7926,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8151,11 +8151,6 @@ Repeat connection request? ICE-palvelimesi No comment provided by engineer. - - Your SMP servers - SMP-palvelimesi - No comment provided by engineer. - Your SimpleX address SimpleX-osoitteesi @@ -8378,7 +8373,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8545,7 +8541,7 @@ Repeat connection request? default (%@) oletusarvo (%@) delete after time - pref value +pref value default (no) @@ -8670,19 +8666,10 @@ Repeat connection request? virhe No comment provided by engineer. - - event happened - tapahtuma tapahtui - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8886,8 +8873,8 @@ Repeat connection request? off pois enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9226,7 +9213,7 @@ last received msg: %2$@
- +
@@ -9262,7 +9249,7 @@ last received msg: %2$@
- +
@@ -9284,7 +9271,7 @@ last received msg: %2$@
- +
@@ -9311,7 +9298,7 @@ last received msg: %2$@
- +
@@ -9330,7 +9317,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json index 78ce40cec5..11f7a4861c 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "fi", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 d36e7c2bd4..823491dc72 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -2,7 +2,7 @@
- +
@@ -439,7 +439,7 @@ 1 day 1 jour delete after time - time interval +time interval 1 hour @@ -455,13 +455,13 @@ 1 month 1 mois delete after time - time interval +time interval 1 week 1 semaine delete after time - time interval +time interval 1 year @@ -560,8 +560,8 @@ Accept Accepter accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -582,7 +582,7 @@ Accept incognito Accepter en incognito accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1310,6 +1310,12 @@ Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Appel déjà terminé ! @@ -1359,7 +1365,7 @@ Cancel Annuler alert action - alert button +alert button Cancel migration @@ -1449,7 +1455,7 @@ Change self-destruct passcode Modifier le code d'autodestruction authentication reason - set passcode view +set passcode view Chat @@ -1688,16 +1694,6 @@ Conditions d'utilisation No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Les conditions seront acceptées pour les opérateurs activés après 30 jours. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Les conditions seront acceptées pour le(s) opérateur(s) : **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Les conditions seront acceptées pour le(s) opérateur(s) : **%@**. @@ -1718,6 +1714,10 @@ Configurer les serveurs ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Confirmer @@ -2308,7 +2308,7 @@ Il s'agit de votre propre lien unique ! Delete Supprimer alert action - swipe action +swipe action Delete %lld messages of members? @@ -2796,7 +2796,7 @@ Il s'agit de votre propre lien unique ! Download Télécharger alert button - chat item action +chat item action Download errors @@ -2873,9 +2873,8 @@ Il s'agit de votre propre lien unique ! Activer (conserver les remplacements) No comment provided by engineer. - - Enable Flux - Activer Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3443,7 +3442,9 @@ Il s'agit de votre propre lien unique ! Error: %@ Erreur : %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5797,11 +5798,19 @@ Erreur : %@ Respect de la vie privée de vos clients. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined La vie privée redéfinie No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Noms de fichiers privés @@ -6163,7 +6172,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Reject Rejeter reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6390,11 +6399,6 @@ Activez-le dans les paramètres *Réseau et serveurs*. Vérifier les conditions No comment provided by engineer. - - Review later - Vérifier plus tard - No comment provided by engineer. - Revoke Révoquer @@ -6444,7 +6448,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Save Enregistrer alert button - chat item action +chat item action Save (and notify contacts) @@ -7011,7 +7015,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share Partager alert action - chat item action +chat item action Share 1-time link @@ -7278,7 +7282,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7644,11 +7648,6 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Les mêmes conditions s'appliquent à l'opérateur **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Les mêmes conditions s'appliquent à(aux) l'opérateur(s) : **%@**. - No comment provided by engineer. - The second preset operator in the app! Le deuxième opérateur prédéfini de l'application ! @@ -8608,11 +8607,6 @@ Répéter la demande d'adhésion ? Vous pouvez choisir de le modifier dans les paramètres d'apparence. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. - No comment provided by engineer. - You can configure servers via settings. Vous pouvez configurer les serveurs via les paramètres. @@ -8854,11 +8848,6 @@ Répéter la demande de connexion ? Vos serveurs ICE No comment provided by engineer. - - Your SMP servers - Vos serveurs SMP - No comment provided by engineer. - Your SimpleX address Votre adresse SimpleX @@ -9096,7 +9085,8 @@ Répéter la demande de connexion ? blocked by admin bloqué par l'administrateur - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9267,7 +9257,7 @@ Répéter la demande de connexion ? default (%@) défaut (%@) delete after time - pref value +pref value default (no) @@ -9394,21 +9384,11 @@ Répéter la demande de connexion ? erreur No comment provided by engineer. - - event happened - event happened - No comment provided by engineer. - expired expiré No comment provided by engineer. - - for better metadata privacy. - pour une meilleure protection des métadonnées. - No comment provided by engineer. - forwarded transféré @@ -9617,8 +9597,8 @@ Répéter la demande de connexion ? off off enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9985,7 +9965,7 @@ dernier message reçu : %2$@
- +
@@ -10022,7 +10002,7 @@ dernier message reçu : %2$@
- +
@@ -10044,7 +10024,7 @@ dernier message reçu : %2$@
- +
@@ -10076,7 +10056,7 @@ dernier message reçu : %2$@
- +
@@ -10098,7 +10078,7 @@ dernier message reçu : %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/contents.json b/apps/ios/SimpleX Localizations/fr.xcloc/contents.json index 22d271b92e..d026c874ec 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/fr.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "fr", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 7c25a74d3f..76157f29ac 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -2,7 +2,7 @@
- +
@@ -152,7 +152,7 @@ %d file(s) failed to download. - %d fájlt nem sikerült letölteni. + Nem sikerült letölteni %d fájlt. forward confirmation reason @@ -307,7 +307,7 @@ %u messages failed to decrypt. - %u üzenet visszafejtése sikertelen. + Nem sikerült visszafejteni %u üzenetet. No comment provided by engineer. @@ -440,7 +440,7 @@ 1 day 1 nap delete after time - time interval +time interval 1 hour @@ -456,13 +456,13 @@ 1 month 1 hónap delete after time - time interval +time interval 1 week 1 hét delete after time - time interval +time interval 1 year @@ -471,12 +471,12 @@ 1-time link - Egyszer használható meghívási hivatkozás + Egyszer használható meghívó No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. - Az egyszer használható meghívási hivatkozás csak *egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltón keresztül megosztható. + Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható. No comment provided by engineer. @@ -562,8 +562,8 @@ Accept Elfogadás accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -572,19 +572,19 @@ Accept connection request? - Elfogadja a kapcsolódási kérést? + Elfogadja a meghívási kérést? No comment provided by engineer. Accept contact request from %@? - Elfogadja %@ kapcsolódási kérését? + Elfogadja %@ meghívási kérését? notification body Accept incognito Elfogadás inkognitóban accept contact request via notification - swipe action +swipe action Accepted conditions @@ -703,7 +703,7 @@ Address or 1-time link? - Cím vagy egyszer használható meghívási hivatkozás? + Cím vagy egyszer használható meghívó? No comment provided by engineer. @@ -1143,7 +1143,7 @@ Auto-accept contact requests - Kapcsolatkérések automatikus elfogadása + Meghívási kérések automatikus elfogadása No comment provided by engineer. @@ -1331,6 +1331,15 @@ A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + A SimpleX Chat használatával Ön elfogadja, hogy: +- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban. +- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek. + No comment provided by engineer. + Call already ended! A hívás már befejeződött! @@ -1380,7 +1389,7 @@ Cancel Mégse alert action - alert button +alert button Cancel migration @@ -1471,7 +1480,7 @@ Change self-destruct passcode Önmegsemmisítő-jelkód módosítása authentication reason - set passcode view +set passcode view Chat @@ -1713,16 +1722,6 @@ Használati feltételek No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**. @@ -1743,6 +1742,11 @@ ICE-kiszolgálók beállítása No comment provided by engineer. + + Configure server operators + Kiszolgálóüzemeltetők beállítása + No comment provided by engineer. + Confirm Megerősítés @@ -1839,7 +1843,7 @@ Ez az Ön SimpleX-címe! Connect to yourself? This is your own one-time link! Kapcsolódik saját magához? -Ez az Ön egyszer használható meghívási hivatkozása! +Ez az Ön egyszer használható meghívója! No comment provided by engineer. @@ -1854,7 +1858,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Connect via one-time link - Kapcsolódás egyszer használható meghívási hivatkozáson keresztül + Kapcsolódás egyszer használható meghívón keresztül No comment provided by engineer. @@ -1951,7 +1955,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Connection request sent! - Kapcsolatkérés elküldve! + Meghívási kérés elküldve! No comment provided by engineer. @@ -2086,7 +2090,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Create 1-time link - Egyszer használható meghívási hivatkozás létrehozása + Egyszer használható meghívó létrehozása No comment provided by engineer. @@ -2206,7 +2210,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Customizable message shape. - Testre szabható üzenetbuborékok. + Személyre szabható üzenetbuborékok. No comment provided by engineer. @@ -2341,7 +2345,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Delete Törlés alert action - swipe action +swipe action Delete %lld messages of members? @@ -2837,7 +2841,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Download Letöltés alert button - chat item action +chat item action Download errors @@ -2914,9 +2918,8 @@ Ez az Ön egyszer használható meghívási hivatkozása! Engedélyezés (felülírások megtartásával) No comment provided by engineer. - - Enable Flux - Flux engedélyezése + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3051,7 +3054,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Encryption re-negotiation error - Hiba a titkosítás újraegyeztetésekor + Hiba történt a titkosítás újraegyeztetésekor message decrypt error item @@ -3126,282 +3129,282 @@ Ez az Ön egyszer használható meghívási hivatkozása! Error aborting address change - Hiba a cím módosításának megszakításakor + Hiba történt a cím módosításának megszakításakor No comment provided by engineer. Error accepting conditions - Hiba a feltételek elfogadásakor + Hiba történt a feltételek elfogadásakor alert title Error accepting contact request - Hiba történt a kapcsolatkérés elfogadásakor + Hiba történt a meghívási kérés elfogadásakor No comment provided by engineer. Error adding member(s) - Hiba a tag(ok) hozzáadásakor + Hiba történt a tag(ok) hozzáadásakor No comment provided by engineer. Error adding server - Hiba a kiszolgáló hozzáadásakor + Hiba történt a kiszolgáló hozzáadásakor alert title Error changing address - Hiba a cím módosításakor + Hiba történt a cím módosításakor No comment provided by engineer. Error changing connection profile - Hiba a kapcsolati profilra való váltáskor + Hiba történt a kapcsolati profilra való váltáskor No comment provided by engineer. Error changing role - Hiba a szerepkör módosításakor + Hiba történt a szerepkör módosításakor No comment provided by engineer. Error changing setting - Hiba a beállítás módosításakor + Hiba történt a beállítás módosításakor No comment provided by engineer. Error changing to incognito! - Hiba az inkognitóprofilra való váltáskor! + Hiba történt az inkognitóprofilra való váltáskor! No comment provided by engineer. Error checking token status - Hiba a token állapotának ellenőrzésekor + Hiba történt a token állapotának ellenőrzésekor No comment provided by engineer. Error connecting to forwarding server %@. Please try later. - Hiba a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. No comment provided by engineer. Error creating address - Hiba a cím létrehozásakor + Hiba történt a cím létrehozásakor No comment provided by engineer. Error creating group - Hiba a csoport létrehozásakor + Hiba történt a csoport létrehozásakor No comment provided by engineer. Error creating group link - Hiba a csoporthivatkozás létrehozásakor + Hiba történt a csoporthivatkozás létrehozásakor No comment provided by engineer. Error creating list - Hiba a lista létrehozásakor + Hiba történt a lista létrehozásakor alert title Error creating member contact - Hiba a partnerrel történő kapcsolat létrehozásában + Hiba történt a partnerrel történő kapcsolat létrehozásában No comment provided by engineer. Error creating message - Hiba az üzenet létrehozásakor + Hiba történt az üzenet létrehozásakor No comment provided by engineer. Error creating profile! - Hiba a profil létrehozásakor! + Hiba történt a profil létrehozásakor! No comment provided by engineer. Error creating report - Hiba a jelentés létrehozásakor + Hiba történt a jelentés létrehozásakor No comment provided by engineer. Error decrypting file - Hiba a fájl visszafejtésekor + Hiba történt a fájl visszafejtésekor No comment provided by engineer. Error deleting chat database - Hiba a csevegési adatbázis törlésekor + Hiba történt a csevegési adatbázis törlésekor No comment provided by engineer. Error deleting chat! - Hiba a csevegés törlésekor! + Hiba történt a csevegés törlésekor! No comment provided by engineer. Error deleting connection - Hiba a kapcsolat törlésekor + Hiba történt a kapcsolat törlésekor No comment provided by engineer. Error deleting database - Hiba az adatbázis törlésekor + Hiba történt az adatbázis törlésekor No comment provided by engineer. Error deleting old database - Hiba a régi adatbázis törlésekor + Hiba történt a régi adatbázis törlésekor No comment provided by engineer. Error deleting token - Hiba a token törlésekor + Hiba történt a token törlésekor No comment provided by engineer. Error deleting user profile - Hiba a felhasználó-profil törlésekor + Hiba történt a felhasználó-profil törlésekor No comment provided by engineer. Error downloading the archive - Hiba az archívum letöltésekor + Hiba történt az archívum letöltésekor No comment provided by engineer. Error enabling delivery receipts! - Hiba a kézbesítési jelentések engedélyezésekor! + Hiba történt a kézbesítési jelentések engedélyezésekor! No comment provided by engineer. Error enabling notifications - Hiba az értesítések engedélyezésekor + Hiba történt az értesítések engedélyezésekor No comment provided by engineer. Error encrypting database - Hiba az adatbázis titkosításakor + Hiba történt az adatbázis titkosításakor No comment provided by engineer. Error exporting chat database - Hiba a csevegési adatbázis exportálásakor + Hiba történt a csevegési adatbázis exportálásakor No comment provided by engineer. Error exporting theme: %@ - Hiba a téma exportálásakor: %@ + Hiba történt a téma exportálásakor: %@ No comment provided by engineer. Error importing chat database - Hiba a csevegési adatbázis importálásakor + Hiba történt a csevegési adatbázis importálásakor No comment provided by engineer. Error joining group - Hiba a csoporthoz való csatlakozáskor + Hiba történt a csoporthoz való csatlakozáskor No comment provided by engineer. Error loading servers - Hiba a kiszolgálók betöltésekor + Hiba történt a kiszolgálók betöltésekor alert title Error migrating settings - Hiba a beállítások átköltöztetésekor + Hiba történt a beállítások átköltöztetésekor No comment provided by engineer. Error opening chat - Hiba a csevegés megnyitásakor + Hiba történt a csevegés megnyitásakor No comment provided by engineer. Error receiving file - Hiba a fájl fogadásakor + Hiba történt a fájl fogadásakor alert title Error reconnecting server - Hiba a kiszolgálóhoz való újrakapcsolódáskor + Hiba történt a kiszolgálóhoz való újrakapcsolódáskor No comment provided by engineer. Error reconnecting servers - Hiba a kiszolgálókhoz való újrakapcsolódáskor + Hiba történt a kiszolgálókhoz való újrakapcsolódáskor No comment provided by engineer. Error registering for notifications - Hiba az értesítések regisztrálásakor + Hiba történt az értesítések regisztrálásakor alert title Error removing member - Hiba a tag eltávolításakor + Hiba történt a tag eltávolításakor No comment provided by engineer. Error reordering lists - Hiba a listák újrarendezésekor + Hiba történt a listák újrarendezésekor alert title Error resetting statistics - Hiba a statisztikák visszaállításakor + Hiba történt a statisztikák visszaállításakor No comment provided by engineer. Error saving ICE servers - Hiba az ICE-kiszolgálók mentésekor + Hiba történt az ICE-kiszolgálók mentésekor No comment provided by engineer. Error saving chat list - Hiba a csevegési lista mentésekor + Hiba történt a csevegési lista mentésekor alert title Error saving group profile - Hiba a csoportprofil mentésekor + Hiba történt a csoportprofil mentésekor No comment provided by engineer. Error saving passcode - Hiba a jelkód mentésekor + Hiba történt a jelkód mentésekor No comment provided by engineer. Error saving passphrase to keychain - Hiba a jelmondat kulcstartóba történő mentésekor + Hiba történt a jelmondat kulcstartóba történő mentésekor No comment provided by engineer. Error saving servers - Hiba a kiszolgálók mentésekor + Hiba történt a kiszolgálók mentésekor alert title Error saving settings - Hiba a beállítások mentésekor + Hiba történt a beállítások mentésekor when migrating Error saving user password - Hiba a felhasználó jelszavának mentésekor + Hiba történt a felhasználó jelszavának mentésekor No comment provided by engineer. Error scanning code: %@ - Hiba a kód beolvasásakor: %@ + Hiba történt a kód beolvasásakor: %@ No comment provided by engineer. Error sending email - Hiba az e-mail küldésekor + Hiba történt az e-mail elküldésekor No comment provided by engineer. @@ -3411,7 +3414,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Error sending message - Hiba az üzenet küldésekor + Hiba történt az üzenet elküldésekor No comment provided by engineer. @@ -3421,47 +3424,47 @@ Ez az Ön egyszer használható meghívási hivatkozása! Error starting chat - Hiba a csevegés elindításakor + Hiba történt a csevegés elindításakor No comment provided by engineer. Error stopping chat - Hiba a csevegés megállításakor + Hiba történt a csevegés megállításakor No comment provided by engineer. Error switching profile - Hiba a profilváltáskor + Hiba történt a profilváltáskor No comment provided by engineer. Error switching profile! - Hiba a profilváltáskor! + Hiba történt a profilváltáskor! alertTitle Error synchronizing connection - Hiba a kapcsolat szinkronizálásakor + Hiba történt a kapcsolat szinkronizálásakor No comment provided by engineer. Error testing server connection - Hiba a kiszolgáló kapcsolatának tesztelésekor + Hiba történt a kiszolgáló kapcsolatának tesztelésekor No comment provided by engineer. Error updating group link - Hiba a csoporthivatkozás frissítésekor + Hiba történt a csoporthivatkozás frissítésekor No comment provided by engineer. Error updating message - Hiba az üzenet frissítésekor + Hiba történt az üzenet frissítésekor No comment provided by engineer. Error updating server - Hiba a kiszolgáló frissítésekor + Hiba történt a kiszolgáló frissítésekor alert title @@ -3471,17 +3474,17 @@ Ez az Ön egyszer használható meghívási hivatkozása! Error updating user privacy - Hiba a felhasználói adatvédelem frissítésekor + Hiba történt a felhasználói adatvédelem frissítésekor No comment provided by engineer. Error uploading the archive - Hiba az archívum feltöltésekor + Hiba történt az archívum feltöltésekor No comment provided by engineer. Error verifying passphrase: - Hiba a jelmondat hitelesítésekor: + Hiba történt a jelmondat hitelesítésekor: No comment provided by engineer. @@ -3492,7 +3495,9 @@ Ez az Ön egyszer használható meghívási hivatkozása! Error: %@ Hiba: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3825,7 +3830,7 @@ Ez az Ön egyszer használható meghívási hivatkozása! Forwarding server %@ failed to connect to destination server %@. Please try later. - A(z) %@ továbbítókiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később. + A(z) %@ továbbítókiszolgáló nem tudott kapcsolódni a(z) %@ célkiszolgálóhoz. Próbálja meg később. No comment provided by engineer. @@ -4453,7 +4458,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. Az üzenet visszafejtése sikertelen volt, 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 régebbi adatbázis biztonsági mentést használt. 3. A kapcsolat sérült. No comment provided by engineer. @@ -4986,7 +4991,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Migration error: - Átköltöztetés hiba: + Átköltöztetési hiba: No comment provided by engineer. @@ -5141,7 +5146,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! New contact request - Új kapcsolatkérés + Új meghívási kérés notification @@ -5420,7 +5425,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! One-time invitation link - Egyszer használható meghívási hivatkozás + Egyszer használható meghívó No comment provided by engineer. @@ -5893,11 +5898,21 @@ Hiba: %@ Az Ön ügyfeleinek adatvédelme. No comment provided by engineer. + + Privacy policy and conditions of use. + Adatvédelmi szabályzat és felhasználási feltételek. + No comment provided by engineer. + Privacy redefined Adatvédelem újraértelmezve No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára. + No comment provided by engineer. + Private filenames Privát fájlnevek @@ -6264,7 +6279,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Reject Elutasítás reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6273,7 +6288,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Reject contact request - Kapcsolatkérés elutasítása + Meghívási kérés elutasítása No comment provided by engineer. @@ -6333,7 +6348,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Repeat connection request? - Megismétli a kapcsolódási kérést? + Megismétli a meghívási kérést? No comment provided by engineer. @@ -6348,7 +6363,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Repeat join request? - Megismétli a csatlakozási kérést? + Megismétli a meghívási kérést? No comment provided by engineer. @@ -6483,7 +6498,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Restore database error - Hiba az adatbázis visszaállításakor + Hiba történt az adatbázis visszaállításakor No comment provided by engineer. @@ -6501,11 +6516,6 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Feltételek felülvizsgálata No comment provided by engineer. - - Review later - Felülvizsgálat később - No comment provided by engineer. - Revoke Visszavonás @@ -6555,7 +6565,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Save Mentés alert button - chat item action +chat item action Save (and notify contacts) @@ -6849,7 +6859,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Send them from gallery or custom keyboards. - Küldje el őket galériából vagy egyedi billentyűzetekről. + Küldje el őket a galériából vagy az egyéni billentyűzetekről. No comment provided by engineer. @@ -6864,7 +6874,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Sender may have deleted the connection request. - A küldő törölhette a kapcsolatkérést. + A küldője törölhette a meghívási kérést. No comment provided by engineer. @@ -6989,7 +6999,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Server operators - Kiszolgáló-üzemeltetők + Kiszolgálóüzemeltetők No comment provided by engineer. @@ -7126,16 +7136,16 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Share Megosztás alert action - chat item action +chat item action Share 1-time link - Egyszer használható meghívási hivatkozás megosztása + Egyszer használható meghívó megosztása No comment provided by engineer. Share 1-time link with a friend - Egyszer használható meghívási hivatkozás megosztása egy baráttal + Egyszer használható meghívó megosztása egy baráttal No comment provided by engineer. @@ -7175,7 +7185,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Share this 1-time invite link - Ennek az egyszer használható meghívási hivatkozásnak a megosztása + Ennek az egyszer használható meghívónak a megosztása No comment provided by engineer. @@ -7280,12 +7290,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. SimpleX address and 1-time links are safe to share via any messenger. - A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetváltón keresztül. + A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül. No comment provided by engineer. SimpleX address or 1-time link? - SimpleX-cím vagy egyszer használható meghívási hivatkozás? + SimpleX-cím vagy egyszer használható meghívó? No comment provided by engineer. @@ -7320,7 +7330,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. SimpleX one-time invitation - Egyszer használható SimpleX-meghívási hivatkozás + Egyszer használható SimpleX-meghívó simplex link type @@ -7394,7 +7404,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Spam Kéretlen tartalom blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7523,7 +7533,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Switch chat profile for 1-time invitations. - Csevegési profilváltás az egyszer használható meghívási hivatkozásokhoz. + Csevegési profilváltás az egyszer használható meghívókhoz. No comment provided by engineer. @@ -7653,12 +7663,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! - Köszönet a felhasználóknak – [hozzájárulás a Weblate-en keresztül](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Köszönet a felhasználóknak [a Weblate-en való közreműködésért](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. Thanks to the users – contribute via Weblate! - Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! No comment provided by engineer. @@ -7670,7 +7680,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The app can notify you when you receive messages or contact requests - please open settings to enable. - Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – ezt a beállítások menüben engedélyezheti. + Az alkalmazás értesíteni fogja, amikor üzeneteket vagy meghívási kéréseket kap – ezt a beállítások menüben engedélyezheti. No comment provided by engineer. @@ -7763,11 +7773,6 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k) számára is: **%@**. - No comment provided by engineer. - The second preset operator in the app! A második előre beállított üzemeltető az alkalmazásban! @@ -7880,7 +7885,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This is your own one-time link! - Ez az Ön egyszer használható meghívási hivatkozása! + Ez az Ön egyszer használható meghívója! No comment provided by engineer. @@ -8679,7 +8684,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso You are already connecting via this one-time link! - A kapcsolódás már folyamatban van ezen az egyszer használható meghívási hivatkozáson keresztül! + A kapcsolódás már folyamatban van ezen az egyszer használható meghívón keresztül! No comment provided by engineer. @@ -8706,7 +8711,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso You are already joining the group! Repeat join request? A csatlakozás már folyamatban van a csoporthoz! -Megismétli a csatlakozási kérést? +Megismétli a meghívási kérést? No comment provided by engineer. @@ -8734,11 +8739,6 @@ Megismétli a csatlakozási kérést? Ezt a „Megjelenés” menüben módosíthatja. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Az üzemeltetőket a „Hálózat és kiszolgálók” menüben konfigurálhatja. - No comment provided by engineer. - You can configure servers via settings. A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. @@ -8846,14 +8846,14 @@ Megismétli a csatlakozási kérést? You have already requested connection via this address! - Már küldött egy kapcsolatkérést ezen a címen keresztül! + Már küldött egy meghívási kérést ezen a címen keresztül! No comment provided by engineer. You have already requested connection! Repeat connection request? - Ön már küldött egy kapcsolódási kérést! -Megismétli a kapcsolódási kérést? + Ön már küldött egy meghívási kérést! +Megismétli a meghívási kérést? No comment provided by engineer. @@ -8928,7 +8928,7 @@ Megismétli a kapcsolódási kérést? You will be connected when your connection request is accepted, please wait or check later! - Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, ha a meghívási kérése el lesz fogadva, várjon, vagy ellenőrizze később! No comment provided by engineer. @@ -8981,11 +8981,6 @@ Megismétli a kapcsolódási kérést? Saját ICE-kiszolgálók No comment provided by engineer. - - Your SMP servers - Saját SMP-kiszolgálók - No comment provided by engineer. - Your SimpleX address Profil SimpleX-címe @@ -9108,7 +9103,7 @@ Megismétli a kapcsolódási kérést? [Contribute](https://github.com/simplex-chat/simplex-chat#contribute) - [Hozzájárulás](https://github.com/simplex-chat/simplex-chat#contribute) + [Közreműködés](https://github.com/simplex-chat/simplex-chat#contribute) No comment provided by engineer. @@ -9224,7 +9219,8 @@ Megismétli a kapcsolódási kérést? blocked by admin letiltva az adminisztrátor által - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9395,7 +9391,7 @@ Megismétli a kapcsolódási kérést? default (%@) alapértelmezett (%@) delete after time - pref value +pref value default (no) @@ -9522,21 +9518,11 @@ Megismétli a kapcsolódási kérést? hiba No comment provided by engineer. - - event happened - esemény történt - No comment provided by engineer. - expired lejárt No comment provided by engineer. - - for better metadata privacy. - a metaadatok jobb védelme érdekében. - No comment provided by engineer. - forwarded továbbított @@ -9584,7 +9570,7 @@ Megismétli a kapcsolódási kérést? incognito via one-time link - inkognitó egy egyszer használható meghívási hivatkozáson keresztül + inkognitó egy egyszer használható meghívón keresztül chat list item description @@ -9629,7 +9615,7 @@ Megismétli a kapcsolódási kérést? invited to connect - meghívta egy partnerét + Függőben lévő meghívó chat list item title @@ -9746,8 +9732,8 @@ Megismétli a kapcsolódási kérést? off kikapcsolva enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9851,7 +9837,7 @@ Megismétli a kapcsolódási kérést? requested to connect - kérelmezve a kapcsolódáshoz + Függőben lévő meghívási kérelem chat list item title @@ -9990,7 +9976,7 @@ utoljára fogadott üzenet: %2$@ via one-time link - egy egyszer használható meghívási hivatkozáson keresztül + egy egyszer használható meghívón keresztül chat list item description @@ -10090,12 +10076,12 @@ utoljára fogadott üzenet: %2$@ you shared one-time link - Ön egy egyszer használható meghívási hivatkozást osztott meg + Ön egy egyszer használható meghívót osztott meg chat list item description you shared one-time link incognito - Ön egy egyszer használható meghívási hivatkozást osztott meg inkognitóban + Ön egy egyszer használható meghívót osztott meg inkognitóban chat list item description @@ -10117,7 +10103,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -10154,7 +10140,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -10176,7 +10162,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -10208,7 +10194,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -10230,7 +10216,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -10300,12 +10286,12 @@ utoljára fogadott üzenet: %2$@ Error preparing file - Hiba a fájl előkészítésekor + Hiba történt a fájl előkészítésekor No comment provided by engineer. Error preparing message - Hiba az üzenet előkészítésekor + Hiba történt az üzenet előkészítésekor No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/contents.json b/apps/ios/SimpleX Localizations/hu.xcloc/contents.json index 0b16198498..c07ec0f900 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/hu.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "hu", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 18ee99deda..9d6aa9f4be 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -2,7 +2,7 @@
- +
@@ -440,7 +440,7 @@ 1 day 1 giorno delete after time - time interval +time interval 1 hour @@ -456,13 +456,13 @@ 1 month 1 mese delete after time - time interval +time interval 1 week 1 settimana delete after time - time interval +time interval 1 year @@ -562,8 +562,8 @@ Accept Accetta accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -584,7 +584,7 @@ Accept incognito Accetta in incognito accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1331,6 +1331,15 @@ Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Usando SimpleX Chat accetti di: +- inviare solo contenuto legale nei gruppi pubblici. +- rispettare gli altri utenti - niente spam. + No comment provided by engineer. + Call already ended! Chiamata già terminata! @@ -1380,7 +1389,7 @@ Cancel Annulla alert action - alert button +alert button Cancel migration @@ -1471,7 +1480,7 @@ Change self-destruct passcode Cambia codice di autodistruzione authentication reason - set passcode view +set passcode view Chat @@ -1713,16 +1722,6 @@ Condizioni d'uso No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Le condizioni verranno accettate per gli operatori attivati dopo 30 giorni. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Le condizioni verranno accettate per gli operatori: **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Le condizioni verranno accettate per gli operatori: **%@**. @@ -1743,6 +1742,11 @@ Configura server ICE No comment provided by engineer. + + Configure server operators + Configura gli operatori dei server + No comment provided by engineer. + Confirm Conferma @@ -2341,7 +2345,7 @@ Questo è il tuo link una tantum! Delete Elimina alert action - swipe action +swipe action Delete %lld messages of members? @@ -2837,7 +2841,7 @@ Questo è il tuo link una tantum! Download Scarica alert button - chat item action +chat item action Download errors @@ -2914,9 +2918,8 @@ Questo è il tuo link una tantum! Attiva (mantieni sostituzioni) No comment provided by engineer. - - Enable Flux - Attiva Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3492,7 +3495,9 @@ Questo è il tuo link una tantum! Error: %@ Errore: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5893,11 +5898,21 @@ Errore: %@ Privacy per i tuoi clienti. No comment provided by engineer. + + Privacy policy and conditions of use. + Informativa sulla privacy e condizioni d'uso. + No comment provided by engineer. + Privacy redefined Privacy ridefinita No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server. + No comment provided by engineer. + Private filenames Nomi di file privati @@ -6264,7 +6279,7 @@ Attivalo nelle impostazioni *Rete e server*. Reject Rifiuta reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6501,11 +6516,6 @@ Attivalo nelle impostazioni *Rete e server*. Leggi le condizioni No comment provided by engineer. - - Review later - Leggi più tardi - No comment provided by engineer. - Revoke Revoca @@ -6555,7 +6565,7 @@ Attivalo nelle impostazioni *Rete e server*. Save Salva alert button - chat item action +chat item action Save (and notify contacts) @@ -7126,7 +7136,7 @@ Attivalo nelle impostazioni *Rete e server*. Share Condividi alert action - chat item action +chat item action Share 1-time link @@ -7394,7 +7404,7 @@ Attivalo nelle impostazioni *Rete e server*. Spam Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7763,11 +7773,6 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Le stesse condizioni si applicheranno all'operatore **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Le stesse condizioni si applicheranno agli operatori **%@**. - No comment provided by engineer. - The second preset operator in the app! Il secondo operatore preimpostato nell'app! @@ -8734,11 +8739,6 @@ Ripetere la richiesta di ingresso? Puoi cambiarlo nelle impostazioni dell'aspetto. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Puoi configurare gli operatori nelle impostazioni di rete e server. - No comment provided by engineer. - You can configure servers via settings. Puoi configurare i server nelle impostazioni. @@ -8981,11 +8981,6 @@ Ripetere la richiesta di connessione? I tuoi server ICE No comment provided by engineer. - - Your SMP servers - I tuoi server SMP - No comment provided by engineer. - Your SimpleX address Il tuo indirizzo SimpleX @@ -9224,7 +9219,8 @@ Ripetere la richiesta di connessione? blocked by admin bloccato dall'amministratore - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9395,7 +9391,7 @@ Ripetere la richiesta di connessione? default (%@) predefinito (%@) delete after time - pref value +pref value default (no) @@ -9522,21 +9518,11 @@ Ripetere la richiesta di connessione? errore No comment provided by engineer. - - event happened - evento accaduto - No comment provided by engineer. - expired scaduto No comment provided by engineer. - - for better metadata privacy. - per una migliore privacy dei metadati. - No comment provided by engineer. - forwarded inoltrato @@ -9664,7 +9650,7 @@ Ripetere la richiesta di connessione? member %1$@ changed to %2$@ - membro %1$@ cambiato in %2$@ + il membro %1$@ è diventato %2$@ profile update event chat item @@ -9746,8 +9732,8 @@ Ripetere la richiesta di connessione? off off enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -10117,7 +10103,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -10154,7 +10140,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -10176,7 +10162,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -10208,7 +10194,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -10230,7 +10216,7 @@ ultimo msg ricevuto: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/it.xcloc/contents.json b/apps/ios/SimpleX Localizations/it.xcloc/contents.json index 13870ab8dd..a42f254bd9 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/it.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "it", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 fdca2d5aca..8e7c0ae206 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2,7 +2,7 @@
- +
@@ -439,7 +439,7 @@ 1 day 1日 delete after time - time interval +time interval 1 hour @@ -455,13 +455,13 @@ 1 month 1ヶ月 delete after time - time interval +time interval 1 week 1週間 delete after time - time interval +time interval 1 year @@ -558,8 +558,8 @@ Accept 承諾 accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -579,7 +579,7 @@ Accept incognito シークレットモードで承諾 accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1262,6 +1262,12 @@ チャット プロファイル経由 (デフォルト) または [接続経由](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! 通話は既に終了してます! @@ -1306,7 +1312,7 @@ Cancel 中止 alert action - alert button +alert button Cancel migration @@ -1391,7 +1397,7 @@ Change self-destruct passcode 自己破壊パスコードを変更する authentication reason - set passcode view +set passcode view Chat @@ -1610,14 +1616,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1635,6 +1633,10 @@ ICEサーバを設定 No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm 確認 @@ -2189,7 +2191,7 @@ This is your own one-time link! Delete 削除 alert action - swipe action +swipe action Delete %lld messages of members? @@ -2648,7 +2650,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2717,8 +2719,8 @@ This is your own one-time link! 有効にする(設定の優先を維持) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3252,7 +3254,9 @@ This is your own one-time link! Error: %@ エラー : %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5430,11 +5434,19 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined プライバシーの基準を新境地に No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames プライベートなファイル名 @@ -5767,7 +5779,7 @@ Enable in *Network & servers* settings. Reject 拒否 reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5980,10 +5992,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke 取り消す @@ -6029,7 +6037,7 @@ Enable in *Network & servers* settings. Save 保存 alert button - chat item action +chat item action Save (and notify contacts) @@ -6544,7 +6552,7 @@ Enable in *Network & servers* settings. Share 共有する alert action - chat item action +chat item action Share 1-time link @@ -6786,7 +6794,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7126,10 +7134,6 @@ It can happen because of some bug or when the connection is compromised.The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -7992,10 +7996,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8222,11 +8222,6 @@ Repeat connection request? あなたのICEサーバ No comment provided by engineer. - - Your SMP servers - あなたのSMPサーバ - No comment provided by engineer. - Your SimpleX address あなたのSimpleXアドレス @@ -8449,7 +8444,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8616,7 +8612,7 @@ Repeat connection request? default (%@) デフォルト (%@) delete after time - pref value +pref value default (no) @@ -8741,19 +8737,10 @@ Repeat connection request? エラー No comment provided by engineer. - - event happened - イベント発生 - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8957,8 +8944,8 @@ Repeat connection request? off オフ enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9297,7 +9284,7 @@ last received msg: %2$@
- +
@@ -9333,7 +9320,7 @@ last received msg: %2$@
- +
@@ -9355,7 +9342,7 @@ last received msg: %2$@
- +
@@ -9382,7 +9369,7 @@ last received msg: %2$@
- +
@@ -9401,7 +9388,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/contents.json b/apps/ios/SimpleX Localizations/ja.xcloc/contents.json index 604a21be97..ce6052fc44 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ja.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ja", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index d643600bc9..e35732f046 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -36,7 +36,7 @@ !1 colored! - !1 색상! + !1 색상 적용됨! No comment provided by engineer. @@ -166,6 +166,7 @@ %llds + No comment provided by engineer. @@ -188,7 +189,7 @@ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. - **비공개**: 매 20분마다 새 메시지를 확인합니다. 푸시 서버에는 장치 토큰만 공유됩니다. 연락처 수나 메세지 메타데이터가 표시되지 않습니다. + **비공개**: 20분마다 새로운 메시지를 확인합니다. 푸시 서버에는 장치 토큰만 공유됩니다. 연락처 수나 메세지 메타데이터가 표시되지 않습니다. No comment provided by engineer. @@ -231,6 +232,7 @@ \*bold* + No comment provided by engineer. @@ -923,46 +925,58 @@ 현재 지원되는 최대 파일 크기는 %@입니다. No comment provided by engineer. - + Dark + 다크 No comment provided by engineer. - + Database ID + 데이터베이스 아이디 No comment provided by engineer. - + Database encrypted! + 데이터베이스 암호화됨! No comment provided by engineer. - + Database encryption passphrase will be updated and stored in the keychain. + 데이터베이스 암호화 키가 키체인에 저장됩니다. + No comment provided by engineer. - + Database encryption passphrase will be updated. + 데이터베이스 암호화 키가 업데이트됩니다. + No comment provided by engineer. - + Database error + 데이터베이스 오류 No comment provided by engineer. - + Database is encrypted using a random passphrase, you can change it. + 데이터베이스는 임의의 암호를 사용하여 암호화되므로 변경할 수 있습니다. No comment provided by engineer. - + Database is encrypted using a random passphrase. Please change it before exporting. + 데이터베이스는 임의의 암호를 사용하여 암호화됩니다. 내보내기 전에 변경하십시오. No comment provided by engineer. - + Database passphrase + 데이터베이스 암호화 키 No comment provided by engineer. - + Database passphrase & export + 데이터베이스 암호화 키 & 내보내기 No comment provided by engineer. @@ -4941,6 +4955,172 @@ This is your own SimpleX address! Customizable message shape. 사용자 지정 가능한 메세지 형태. + + %d seconds(s) + %d 초 + + + 1 year + 1년 + + + Add list + 리스트 추가 + + + Add to list + 리스트에 추가 + + + All + 모두 + + + Allow to report messsages to moderators. + 메시지를 신고하는것을 허용합니다. + + + Another reason + 다른 이유 + + + App group: + 앱 그룹: + + + Archive + 아카이브 + + + Archive report + 신고 아카이브 + + + Archive report? + 신고를 아카이브할까요? + + + Archive reports + 신고 아카이브 + + + Ask + 묻기 + + + Clear group? + 그룹을 비울까요? + + + Clear or delete group? + 그룹을 비우거나 삭제할까요? + + + Community guidelines violation + 커뮤니티 지침 위반 + + + Connection blocked + 연결 차단됨 + + + Connection is blocked by server operator: +%@ + 서버 관리자에 의해 연결이 차단되었습니다: +%@ + + + Connection not ready. + 연결 준비되지 않음. + + + Connection requires encryption renegotiation. + 연결에는 암호화 재협상이 필요합니다. + + + Content violates conditions of use + 내용은 사용 규정을 위반합니다 + + + Create list + 리스트 추가 + + + Database ID: %d + 데이터베이스 아이디: %d + + + Database IDs and Transport isolation option. + 데이터베이스 ID 및 전송 격리 옵션. + + + Database downgrade + 데이터베이스 다운그레이드 + + + Better groups performance + 더 나은 그룹 성능 + + + Confirmed + 확인함 + + + Active + 활성화됨 + + + Archive all reports? + 모든 신고를 아카이브할까요? + + + Businesses + 비즈니스 + + + Better privacy and security + 더 나은 프라이버시 및 보안 + + + Change automatic message deletion? + 자동 메시지 삭제를 변경할까요? + + + All chats will be removed from the list %@, and the list deleted. + 모든 채팅은 %@ 리스트에서 제거되고 리스트는 삭제됩니다. + + + All reports will be archived for you. + 모든 보고서는 사용자를 위해 보관됩니다. + + + Accent + 강조 + + + Archive %lld reports? + %lld 신고를 아카이브할까요? + + + - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! +- delivery receipts (up to 20 members). +- faster and more stable. + - [경로 서비스](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA) 에 연결중! +- 전달 확인 (최대 20 명의 멤버). +- 더 빠르고 안정적입니다. + + + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + 모든 메시지와 파일은 **종간단 암호화 (E2EE)**되며, 개인 메시지는 양자 보안이 적용됩니다. + + + Customize theme + 테마 사용자 지정 + + + Dark mode colors + 다크 모드 색상들 +
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 d8d0b3c712..2ab0c75ff1 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -2,7 +2,7 @@
- +
@@ -440,7 +440,7 @@ 1 day 1 dag delete after time - time interval +time interval 1 hour @@ -456,13 +456,13 @@ 1 month 1 maand delete after time - time interval +time interval 1 week 1 week delete after time - time interval +time interval 1 year @@ -562,8 +562,8 @@ Accept Accepteer accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -584,7 +584,7 @@ Accept incognito Accepteer incognito accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1331,6 +1331,12 @@ Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Oproep al beëindigd! @@ -1380,7 +1386,7 @@ Cancel Annuleren alert action - alert button +alert button Cancel migration @@ -1471,7 +1477,7 @@ Change self-destruct passcode Zelfvernietigings code wijzigen authentication reason - set passcode view +set passcode view Chat @@ -1713,16 +1719,6 @@ Gebruiksvoorwaarden No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Voor ingeschakelde operators worden de voorwaarden na 30 dagen geaccepteerd. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Voorwaarden worden geaccepteerd voor operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Voorwaarden worden geaccepteerd voor de operator(s): **%@**. @@ -1743,6 +1739,10 @@ ICE servers configureren No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Bevestigen @@ -2341,7 +2341,7 @@ Dit is uw eigen eenmalige link! Delete Verwijderen alert action - swipe action +swipe action Delete %lld messages of members? @@ -2815,7 +2815,7 @@ Dit is uw eigen eenmalige link! Don't miss important messages. - ‐Mis geen belangrijke berichten. + Mis geen belangrijke berichten. No comment provided by engineer. @@ -2837,7 +2837,7 @@ Dit is uw eigen eenmalige link! Download Downloaden alert button - chat item action +chat item action Download errors @@ -2914,9 +2914,8 @@ Dit is uw eigen eenmalige link! Inschakelen (overschrijvingen behouden) No comment provided by engineer. - - Enable Flux - Flux inschakelen + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3221,7 +3220,7 @@ Dit is uw eigen eenmalige link! Error creating report - Fout bij maken van rapport + Fout bij het rapporteren No comment provided by engineer. @@ -3492,7 +3491,9 @@ Dit is uw eigen eenmalige link! Error: %@ Fout: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5893,11 +5894,19 @@ Fout: %@ Privacy voor uw klanten. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Privacy opnieuw gedefinieerd No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Privé bestandsnamen @@ -6264,7 +6273,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Reject Afwijzen reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6363,7 +6372,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Report - Rapport + rapporteren chat item action @@ -6398,7 +6407,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Report: %@ - Rapport: %@ + rapporteer: %@ report in notification @@ -6501,11 +6510,6 @@ Schakel dit in in *Netwerk en servers*-instellingen. Voorwaarden bekijken No comment provided by engineer. - - Review later - Later beoordelen - No comment provided by engineer. - Revoke Intrekken @@ -6555,7 +6559,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Save Opslaan alert button - chat item action +chat item action Save (and notify contacts) @@ -6834,7 +6838,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Send private reports - Verstuur rapporten privé + Rapporteer privé No comment provided by engineer. @@ -7126,7 +7130,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Share Deel alert action - chat item action +chat item action Share 1-time link @@ -7394,7 +7398,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Spam Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7763,11 +7767,6 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Dezelfde voorwaarden gelden voor operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Dezelfde voorwaarden gelden voor operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! De tweede vooraf ingestelde operator in de app! @@ -8209,6 +8208,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Updated conditions + Bijgewerkte voorwaarden No comment provided by engineer. @@ -8733,11 +8733,6 @@ Deelnameverzoek herhalen? U kunt dit wijzigen in de instellingen onder uiterlijk. No comment provided by engineer. - - You can configure operators in Network & servers settings. - U kunt operators configureren in Netwerk- en serverinstellingen. - No comment provided by engineer. - You can configure servers via settings. U kunt servers configureren via instellingen. @@ -8980,11 +8975,6 @@ Verbindingsverzoek herhalen? Uw ICE servers No comment provided by engineer. - - Your SMP servers - Uw SMP servers - No comment provided by engineer. - Your SimpleX address Uw SimpleX adres @@ -9223,7 +9213,8 @@ Verbindingsverzoek herhalen? blocked by admin geblokkeerd door beheerder - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9394,7 +9385,7 @@ Verbindingsverzoek herhalen? default (%@) standaard (%@) delete after time - pref value +pref value default (no) @@ -9521,21 +9512,11 @@ Verbindingsverzoek herhalen? fout No comment provided by engineer. - - event happened - gebeurtenis gebeurd - No comment provided by engineer. - expired verlopen No comment provided by engineer. - - for better metadata privacy. - voor betere privacy van metagegevens. - No comment provided by engineer. - forwarded doorgestuurd @@ -9745,8 +9726,8 @@ Verbindingsverzoek herhalen? off uit enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9790,10 +9771,12 @@ Verbindingsverzoek herhalen? pending + In behandeling No comment provided by engineer. pending approval + in afwachting van goedkeuring No comment provided by engineer. @@ -9813,6 +9796,7 @@ Verbindingsverzoek herhalen? rejected + afgewezen No comment provided by engineer. @@ -10113,7 +10097,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -10150,7 +10134,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -10172,7 +10156,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -10204,7 +10188,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -10226,7 +10210,7 @@ laatst ontvangen bericht: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/contents.json b/apps/ios/SimpleX Localizations/nl.xcloc/contents.json index 4c631c367e..4b8d468de2 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/nl.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "nl", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 6a2d6e0d83..dce57ccc9e 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -2,7 +2,7 @@
- +
@@ -440,7 +440,7 @@ 1 day 1 dzień delete after time - time interval +time interval 1 hour @@ -456,13 +456,13 @@ 1 month 1 miesiąc delete after time - time interval +time interval 1 week 1 tydzień delete after time - time interval +time interval 1 year @@ -562,8 +562,8 @@ Accept Akceptuj accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -584,7 +584,7 @@ Accept incognito Akceptuj incognito accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1329,6 +1329,12 @@ Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Połączenie już zakończone! @@ -1378,7 +1384,7 @@ Cancel Anuluj alert action - alert button +alert button Cancel migration @@ -1468,7 +1474,7 @@ Change self-destruct passcode Zmień pin samozniszczenia authentication reason - set passcode view +set passcode view Chat @@ -1707,15 +1713,6 @@ Warunki użytkowania No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Warunki zostaną zaakceptowane dla aktywowanych operatorów po 30 dniach. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1733,6 +1730,10 @@ Skonfiguruj serwery ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Potwierdź @@ -2319,7 +2320,7 @@ To jest twój jednorazowy link! Delete Usuń alert action - swipe action +swipe action Delete %lld messages of members? @@ -2802,7 +2803,7 @@ To jest twój jednorazowy link! Download Pobierz alert button - chat item action +chat item action Download errors @@ -2878,8 +2879,8 @@ To jest twój jednorazowy link! Włącz (zachowaj nadpisania) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3442,7 +3443,9 @@ To jest twój jednorazowy link! Error: %@ Błąd: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5760,11 +5763,19 @@ Błąd: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Redefinicja prywatności No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Prywatne nazwy plików @@ -6126,7 +6137,7 @@ Włącz w ustawianiach *Sieć i serwery* . Reject Odrzuć reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6352,10 +6363,6 @@ Włącz w ustawianiach *Sieć i serwery* . Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke Odwołaj @@ -6405,7 +6412,7 @@ Włącz w ustawianiach *Sieć i serwery* . Save Zapisz alert button - chat item action +chat item action Save (and notify contacts) @@ -6968,7 +6975,7 @@ Włącz w ustawianiach *Sieć i serwery* . Share Udostępnij alert action - chat item action +chat item action Share 1-time link @@ -7226,7 +7233,7 @@ Włącz w ustawianiach *Sieć i serwery* . Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7586,10 +7593,6 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -8533,10 +8536,6 @@ Powtórzyć prośbę dołączenia? Możesz to zmienić w ustawieniach wyglądu. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8775,11 +8774,6 @@ Powtórzyć prośbę połączenia? Twoje serwery ICE No comment provided by engineer. - - Your SMP servers - Twoje serwery SMP - No comment provided by engineer. - Your SimpleX address Twój adres SimpleX @@ -9016,7 +9010,8 @@ Powtórzyć prośbę połączenia? blocked by admin zablokowany przez admina - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9187,7 +9182,7 @@ Powtórzyć prośbę połączenia? default (%@) domyślne (%@) delete after time - pref value +pref value default (no) @@ -9314,20 +9309,11 @@ Powtórzyć prośbę połączenia? błąd No comment provided by engineer. - - event happened - nowe wydarzenie - No comment provided by engineer. - expired wygasły No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded przekazane dalej @@ -9536,8 +9522,8 @@ Powtórzyć prośbę połączenia? off wyłączony enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9903,7 +9889,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -9940,7 +9926,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -9962,7 +9948,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -9990,7 +9976,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -10012,7 +9998,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/contents.json b/apps/ios/SimpleX Localizations/pl.xcloc/contents.json index 0074d85662..c79fba1c1e 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/pl.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "pl", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 0e14a85c19..09e0400ec9 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2,7 +2,7 @@
- +
@@ -127,12 +127,10 @@ %@, %@ and %lld members - %@, %@ и %lld членов группы No comment provided by engineer. %@, %@ and %lld other members connected - %@, %@ и %lld других членов соединены No comment provided by engineer. @@ -232,7 +230,6 @@ %lld members - Членов группы: %lld No comment provided by engineer. @@ -440,7 +437,7 @@ 1 day 1 день delete after time - time interval +time interval 1 hour @@ -456,13 +453,13 @@ 1 month 1 месяц delete after time - time interval +time interval 1 week 1 неделю delete after time - time interval +time interval 1 year @@ -524,8 +521,6 @@ A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. - Отдельное TCP-соединение (и авторизация SOCKS) будет использоваться **для каждого контакта и члена группы**. -**Обратите внимание**: если у Вас много контактов, потребление батареи и трафика может быть значительно выше, и некоторые соединения могут не работать. No comment provided by engineer. @@ -562,8 +557,8 @@ Accept Принять accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -584,7 +579,7 @@ Accept incognito Принять инкогнито accept contact request via notification - swipe action +swipe action Accepted conditions @@ -713,7 +708,6 @@ Admins can block a member for all. - Админы могут заблокировать члена группы. No comment provided by engineer. @@ -763,7 +757,6 @@ All group members will remain connected. - Все члены группы, которые соединились через эту ссылку, останутся в группе. No comment provided by engineer. @@ -853,7 +846,6 @@ Allow sending direct messages to members. - Разрешить посылать прямые сообщения членам группы. No comment provided by engineer. @@ -1248,22 +1240,18 @@ Block group members - Блокируйте членов группы No comment provided by engineer. Block member - Заблокировать члена группы No comment provided by engineer. Block member for all? - Заблокировать члена для всех? No comment provided by engineer. Block member? - Заблокировать члена группы? No comment provided by engineer. @@ -1331,6 +1319,15 @@ По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + Используя SimpleX Chat, Вы согласны: +- отправлять только законные сообщения в публичных группах. +- уважать других пользователей – не отправлять спам. + No comment provided by engineer. + Call already ended! Звонок уже завершен! @@ -1358,7 +1355,6 @@ Can't call member - Не удается позвонить члену группы No comment provided by engineer. @@ -1373,14 +1369,13 @@ Can't message member - Не удается написать члену группы No comment provided by engineer. Cancel Отменить alert action - alert button +alert button Cancel migration @@ -1439,7 +1434,6 @@ Change member role? - Поменять роль члена группы? No comment provided by engineer. @@ -1471,7 +1465,7 @@ Change self-destruct passcode Изменить код самоуничтожения authentication reason - set passcode view +set passcode view Chat @@ -1713,16 +1707,6 @@ Условия использования No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Условия будут приняты для включенных операторов через 30 дней. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Условия будут приняты для оператора(ов): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Условия будут приняты для оператора(ов): **%@**. @@ -1743,6 +1727,11 @@ Настройка ICE серверов No comment provided by engineer. + + Configure server operators + Настроить операторов серверов + No comment provided by engineer. + Confirm Подтвердить @@ -2341,11 +2330,10 @@ This is your own one-time link! Delete Удалить alert action - swipe action +swipe action Delete %lld messages of members? - Удалить %lld сообщений членов группы? No comment provided by engineer. @@ -2680,12 +2668,10 @@ This is your own one-time link! 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. @@ -2790,7 +2776,6 @@ This is your own one-time link! Do not send history to new members. - Не отправлять историю новым членам. No comment provided by engineer. @@ -2837,7 +2822,7 @@ This is your own one-time link! Download Загрузить alert button - chat item action +chat item action Download errors @@ -2914,9 +2899,8 @@ This is your own one-time link! Включить (кроме исключений) No comment provided by engineer. - - Enable Flux - Включить Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3141,7 +3125,6 @@ This is your own one-time link! Error adding member(s) - Ошибка при добавлении членов группы No comment provided by engineer. @@ -3206,7 +3189,6 @@ This is your own one-time link! Error creating member contact - Ошибка создания контакта с членом группы No comment provided by engineer. @@ -3341,7 +3323,6 @@ This is your own one-time link! Error removing member - Ошибка при удалении члена группы No comment provided by engineer. @@ -3406,7 +3387,6 @@ This is your own one-time link! Error sending member contact invitation - Ошибка отправки приглашения члену группы No comment provided by engineer. @@ -3492,7 +3472,9 @@ This is your own one-time link! Error: %@ Ошибка: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -3740,7 +3722,6 @@ This is your own one-time link! Fix not supported by group member - Починка не поддерживается членом группы No comment provided by engineer. @@ -3874,7 +3855,6 @@ Error: %2$@ Fully decentralized – visible only to members. - Группа полностью децентрализована – она видна только членам. No comment provided by engineer. @@ -3984,7 +3964,6 @@ Error: %2$@ Group profile is stored on members' devices, not on the servers. - Профиль группы хранится на устройствах членов, а не на серверах. No comment provided by engineer. @@ -3994,7 +3973,6 @@ Error: %2$@ Group will be deleted for all members - this cannot be undone! - Группа будет удалена для всех членов - это действие нельзя отменить! No comment provided by engineer. @@ -4059,7 +4037,6 @@ Error: %2$@ History is not sent to new members. - История не отправляется новым членам. No comment provided by engineer. @@ -4407,7 +4384,6 @@ More improvements are coming soon! Invite members - Пригласить членов группы No comment provided by engineer. @@ -4720,12 +4696,10 @@ This is your link for group %@! Member - Член группы No comment provided by engineer. Member inactive - Член неактивен item status text @@ -4740,67 +4714,54 @@ 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. 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. @@ -4835,7 +4796,6 @@ This is your link for group %@! Message may be delivered later if member becomes active. - Сообщение может быть доставлено позже, если член группы станет активным. item status description @@ -5175,7 +5135,6 @@ This is your link for group %@! New member role - Роль члена группы No comment provided by engineer. @@ -5392,9 +5351,6 @@ This is your link for group %@! Now admins can: - delete members' messages. - disable members ("observer" role) - Теперь админы могут: -- удалять сообщения членов. -- приостанавливать членов (роль "наблюдатель") No comment provided by engineer. @@ -5695,7 +5651,6 @@ Requires compatible VPN. Past member %@ - Бывший член %@ past/unknown group member @@ -5892,11 +5847,21 @@ Error: %@ Конфиденциальность для ваших покупателей. No comment provided by engineer. + + Privacy policy and conditions of use. + Политика конфиденциальности и условия использования. + No comment provided by engineer. + Privacy redefined Более конфиденциальный No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. + No comment provided by engineer. + Private filenames Защищенные имена файлов @@ -5994,7 +5959,6 @@ Error: %@ Prohibit sending direct messages to members. - Запретить посылать прямые сообщения членам группы. No comment provided by engineer. @@ -6263,7 +6227,7 @@ Enable in *Network & servers* settings. Reject Отклонить reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6302,12 +6266,10 @@ Enable in *Network & servers* settings. Remove member - Удалить члена группы No comment provided by engineer. Remove member? - Удалить члена группы? No comment provided by engineer. @@ -6500,11 +6462,6 @@ Enable in *Network & servers* settings. Посмотреть условия No comment provided by engineer. - - Review later - Посмотреть позже - No comment provided by engineer. - Revoke Отозвать @@ -6554,7 +6511,7 @@ Enable in *Network & servers* settings. Save Сохранить alert button - chat item action +chat item action Save (and notify contacts) @@ -6568,7 +6525,6 @@ Enable in *Network & servers* settings. Save and notify group members - Сохранить и уведомить членов группы No comment provided by engineer. @@ -6853,7 +6809,6 @@ Enable in *Network & servers* settings. Send up to 100 last messages to new members. - Отправить до 100 последних сообщений новым членам. No comment provided by engineer. @@ -7098,7 +7053,6 @@ Enable in *Network & servers* settings. Set the message shown to new members! - Установить сообщение для новых членов группы! No comment provided by engineer. @@ -7125,7 +7079,7 @@ Enable in *Network & servers* settings. Share Поделиться alert action - chat item action +chat item action Share 1-time link @@ -7393,7 +7347,7 @@ Enable in *Network & servers* settings. Spam Спам blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7729,22 +7683,18 @@ 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. @@ -7762,11 +7712,6 @@ It can happen because of some bug or when the connection is compromised.Те же самые условия будут приняты для оператора **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Те же самые условия будут приняты для оператора(ов): **%@**. - No comment provided by engineer. - The second preset operator in the app! Второй оператор серверов в приложении! @@ -7864,7 +7809,6 @@ 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. @@ -8071,17 +8015,14 @@ 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. @@ -8183,7 +8124,6 @@ To connect, please ask your contact to create another connection link and check Up to 100 last messages are sent to new members. - До 100 последних сообщений отправляются новым членам. No comment provided by engineer. @@ -8733,11 +8673,6 @@ Repeat join request? Вы можете изменить это в настройках Интерфейса. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Вы можете настроить операторов в настройках Сети и серверов. - No comment provided by engineer. - You can configure servers via settings. Вы можете настроить серверы позже. @@ -8795,7 +8730,6 @@ 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. @@ -8872,7 +8806,6 @@ Repeat connection request? You joined this group. Connecting to inviting group member. - Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы. No comment provided by engineer. @@ -8942,7 +8875,6 @@ Repeat connection request? You will connect to all group members. - Вы соединитесь со всеми членами группы. No comment provided by engineer. @@ -8980,11 +8912,6 @@ Repeat connection request? Ваши ICE серверы No comment provided by engineer. - - Your SMP servers - Ваши SMP серверы - No comment provided by engineer. - Your SimpleX address Ваш адрес SimpleX @@ -9167,7 +9094,6 @@ Repeat connection request? all members - все члены feature role @@ -9223,7 +9149,8 @@ Repeat connection request? blocked by admin заблокировано администратором - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9394,7 +9321,7 @@ Repeat connection request? default (%@) по умолчанию (%@) delete after time - pref value +pref value default (no) @@ -9521,21 +9448,11 @@ Repeat connection request? ошибка No comment provided by engineer. - - event happened - событие произошло - No comment provided by engineer. - expired истекло No comment provided by engineer. - - for better metadata privacy. - для лучшей конфиденциальности метаданных. - No comment provided by engineer. - forwarded переслано @@ -9658,12 +9575,10 @@ Repeat connection request? member - член группы member role member %1$@ changed to %2$@ - член %1$@ изменился на %2$@ profile update event chat item @@ -9745,8 +9660,8 @@ Repeat connection request? off нет enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -10116,7 +10031,7 @@ last received msg: %2$@
- +
@@ -10153,7 +10068,7 @@ last received msg: %2$@
- +
@@ -10175,7 +10090,7 @@ last received msg: %2$@
- +
@@ -10207,7 +10122,7 @@ last received msg: %2$@
- +
@@ -10229,7 +10144,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json index a28b0ed489..b49b25d653 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ru", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 74bb020cd6..a3162e0bec 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2,7 +2,7 @@
- +
@@ -401,7 +401,7 @@ 1 day 1 วัน delete after time - time interval +time interval 1 hour @@ -417,13 +417,13 @@ 1 month 1 เดือน delete after time - time interval +time interval 1 week 1 สัปดาห์ delete after time - time interval +time interval 1 year @@ -517,8 +517,8 @@ Accept รับ accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -537,7 +537,7 @@ Accept incognito ยอมรับโหมดไม่ระบุตัวตน accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1204,6 +1204,12 @@ ตามโปรไฟล์แชท (ค่าเริ่มต้น) หรือ [โดยการเชื่อมต่อ](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (เบต้า) No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! สิ้นสุดการโทรแล้ว! @@ -1248,7 +1254,7 @@ Cancel ยกเลิก alert action - alert button +alert button Cancel migration @@ -1333,7 +1339,7 @@ Change self-destruct passcode เปลี่ยนรหัสผ่านแบบทำลายตัวเอง authentication reason - set passcode view +set passcode view Chat @@ -1546,14 +1552,6 @@ Conditions of use No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. No comment provided by engineer. @@ -1571,6 +1569,10 @@ กำหนดค่าเซิร์ฟเวอร์ ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm ยืนยัน @@ -2108,7 +2110,7 @@ This is your own one-time link! Delete ลบ alert action - swipe action +swipe action Delete %lld messages of members? @@ -2563,7 +2565,7 @@ This is your own one-time link! Download alert button - chat item action +chat item action Download errors @@ -2632,8 +2634,8 @@ This is your own one-time link! เปิดใช้งาน (เก็บการแทนที่) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3164,7 +3166,9 @@ This is your own one-time link! Error: %@ ข้อผิดพลาด: % @ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5332,11 +5336,19 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined นิยามความเป็นส่วนตัวใหม่ No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames ชื่อไฟล์ส่วนตัว @@ -5668,7 +5680,7 @@ Enable in *Network & servers* settings. Reject ปฏิเสธ reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -5880,10 +5892,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke ถอน @@ -5929,7 +5937,7 @@ Enable in *Network & servers* settings. Save บันทึก alert button - chat item action +chat item action Save (and notify contacts) @@ -6449,7 +6457,7 @@ Enable in *Network & servers* settings. Share แชร์ alert action - chat item action +chat item action Share 1-time link @@ -6688,7 +6696,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7029,10 +7037,6 @@ It can happen because of some bug or when the connection is compromised.The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -7892,10 +7896,6 @@ Repeat join request? You can change it in Appearance settings. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8120,11 +8120,6 @@ Repeat connection request? เซิร์ฟเวอร์ ICE ของคุณ No comment provided by engineer. - - Your SMP servers - เซิร์ฟเวอร์ SMP ของคุณ - No comment provided by engineer. - Your SimpleX address ที่อยู่ SimpleX ของคุณ @@ -8346,7 +8341,8 @@ Repeat connection request? blocked by admin - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -8513,7 +8509,7 @@ Repeat connection request? default (%@) ค่าเริ่มต้น (%@) delete after time - pref value +pref value default (no) @@ -8637,18 +8633,10 @@ Repeat connection request? ผิดพลาด No comment provided by engineer. - - event happened - No comment provided by engineer. - expired No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded No comment provided by engineer. @@ -8852,8 +8840,8 @@ Repeat connection request? off ปิด enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9192,7 +9180,7 @@ last received msg: %2$@
- +
@@ -9228,7 +9216,7 @@ last received msg: %2$@
- +
@@ -9250,7 +9238,7 @@ last received msg: %2$@
- +
@@ -9277,7 +9265,7 @@ last received msg: %2$@
- +
@@ -9296,7 +9284,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/th.xcloc/contents.json b/apps/ios/SimpleX Localizations/th.xcloc/contents.json index 4562ab8385..ee6ee63ea9 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/th.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "th", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 d3d81c2674..bce3cdde08 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -2,7 +2,7 @@
- +
@@ -439,7 +439,7 @@ 1 day 1 gün delete after time - time interval +time interval 1 hour @@ -455,13 +455,13 @@ 1 month 1 ay delete after time - time interval +time interval 1 week 1 hafta delete after time - time interval +time interval 1 year @@ -560,8 +560,8 @@ Accept Kabul et accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -582,7 +582,7 @@ Accept incognito Takma adla kabul et accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1310,6 +1310,12 @@ Sohbet profiline göre (varsayılan) veya [bağlantıya göre](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Arama çoktan bitti! @@ -1359,7 +1365,7 @@ Cancel İptal et alert action - alert button +alert button Cancel migration @@ -1449,7 +1455,7 @@ Change self-destruct passcode Kendini yok eden parolayı değiştir authentication reason - set passcode view +set passcode view Chat @@ -1688,16 +1694,6 @@ Kullanım koşulları No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Koşullar 30 gün sonra etkin operatörler tarafından kabul edilecektir. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Koşullar operatör(ler) için kabul edilecektir: **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Koşullar bu operatör(ler) için kabul edilecektir: **%@**. @@ -1718,6 +1714,10 @@ ICE sunucularını ayarla No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Onayla @@ -2308,7 +2308,7 @@ Bu senin kendi tek kullanımlık bağlantın! Delete Sil alert action - swipe action +swipe action Delete %lld messages of members? @@ -2795,7 +2795,7 @@ Bu senin kendi tek kullanımlık bağlantın! Download İndir alert button - chat item action +chat item action Download errors @@ -2872,9 +2872,8 @@ Bu senin kendi tek kullanımlık bağlantın! Etkinleştir (geçersiz kılmaları koru) No comment provided by engineer. - - Enable Flux - Flux'u Etkinleştir + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3442,7 +3441,9 @@ Bu senin kendi tek kullanımlık bağlantın! Error: %@ Hata: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5774,11 +5775,19 @@ Hata: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Gizlilik yeniden tanımlandı No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Gizli dosya adları @@ -6140,7 +6149,7 @@ Enable in *Network & servers* settings. Reject Reddet reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6366,10 +6375,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke İptal et @@ -6419,7 +6424,7 @@ Enable in *Network & servers* settings. Save Kaydet alert button - chat item action +chat item action Save (and notify contacts) @@ -6982,7 +6987,7 @@ Enable in *Network & servers* settings. Share Paylaş alert action - chat item action +chat item action Share 1-time link @@ -7241,7 +7246,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7603,10 +7608,6 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -8550,10 +8551,6 @@ Katılma isteği tekrarlansın mı? Görünüm ayarlarından değiştirebilirsiniz. No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8792,11 +8789,6 @@ Bağlantı isteği tekrarlansın mı? ICE sunucularınız No comment provided by engineer. - - Your SMP servers - SMP sunucularınız - No comment provided by engineer. - Your SimpleX address SimpleX adresin @@ -9032,7 +9024,8 @@ Bağlantı isteği tekrarlansın mı? blocked by admin yönetici tarafından engellendi - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9203,7 +9196,7 @@ Bağlantı isteği tekrarlansın mı? default (%@) varsayılan (%@) delete after time - pref value +pref value default (no) @@ -9330,20 +9323,11 @@ Bağlantı isteği tekrarlansın mı? hata No comment provided by engineer. - - event happened - etkinlik yaşandı - No comment provided by engineer. - expired Süresi dolmuş No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded iletildi @@ -9552,8 +9536,8 @@ Bağlantı isteği tekrarlansın mı? off kapalı enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9919,7 +9903,7 @@ son alınan msj: %2$@
- +
@@ -9956,7 +9940,7 @@ son alınan msj: %2$@
- +
@@ -9978,7 +9962,7 @@ son alınan msj: %2$@
- +
@@ -10005,7 +9989,7 @@ son alınan msj: %2$@
- +
@@ -10027,7 +10011,7 @@ son alınan msj: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/contents.json b/apps/ios/SimpleX Localizations/tr.xcloc/contents.json index 6f74640a6b..2e32ea2080 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/tr.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "tr", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 767867685d..c95be26929 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -2,7 +2,7 @@
- +
@@ -439,7 +439,7 @@ 1 day 1 день delete after time - time interval +time interval 1 hour @@ -455,13 +455,13 @@ 1 month 1 місяць delete after time - time interval +time interval 1 week 1 тиждень delete after time - time interval +time interval 1 year @@ -560,8 +560,8 @@ Accept Прийняти accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -582,7 +582,7 @@ Accept incognito Прийняти інкогніто accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1310,6 +1310,12 @@ Через профіль чату (за замовчуванням) або [за з'єднанням](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + No comment provided by engineer. + Call already ended! Дзвінок вже закінчився! @@ -1359,7 +1365,7 @@ Cancel Скасувати alert action - alert button +alert button Cancel migration @@ -1449,7 +1455,7 @@ Change self-destruct passcode Змінити пароль самознищення authentication reason - set passcode view +set passcode view Chat @@ -1688,16 +1694,6 @@ Умови використання No comment provided by engineer. - - Conditions will be accepted for enabled operators after 30 days. - Умови будуть прийняті для ввімкнених операторів через 30 днів. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. - Умови приймаються для оператора(ів): **%@**. - No comment provided by engineer. - Conditions will be accepted for the operator(s): **%@**. Для оператора(ів) приймаються умови: **%@**. @@ -1718,6 +1714,10 @@ Налаштування серверів ICE No comment provided by engineer. + + Configure server operators + No comment provided by engineer. + Confirm Підтвердити @@ -2308,7 +2308,7 @@ This is your own one-time link! Delete Видалити alert action - swipe action +swipe action Delete %lld messages of members? @@ -2796,7 +2796,7 @@ This is your own one-time link! Download Завантажити alert button - chat item action +chat item action Download errors @@ -2873,9 +2873,8 @@ This is your own one-time link! Увімкнути (зберегти перевизначення) No comment provided by engineer. - - Enable Flux - Увімкнути Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3443,7 +3442,9 @@ This is your own one-time link! Error: %@ Помилка: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5797,11 +5798,19 @@ Error: %@ Конфіденційність для ваших клієнтів. No comment provided by engineer. + + Privacy policy and conditions of use. + No comment provided by engineer. + Privacy redefined Конфіденційність переглянута No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + No comment provided by engineer. + Private filenames Приватні імена файлів @@ -6163,7 +6172,7 @@ Enable in *Network & servers* settings. Reject Відхилити reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6390,11 +6399,6 @@ Enable in *Network & servers* settings. Умови перегляду No comment provided by engineer. - - Review later - Перегляньте пізніше - No comment provided by engineer. - Revoke Відкликати @@ -6444,7 +6448,7 @@ Enable in *Network & servers* settings. Save Зберегти alert button - chat item action +chat item action Save (and notify contacts) @@ -7011,7 +7015,7 @@ Enable in *Network & servers* settings. Share Поділіться alert action - chat item action +chat item action Share 1-time link @@ -7278,7 +7282,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7644,11 +7648,6 @@ It can happen because of some bug or when the connection is compromised.Такі ж умови діятимуть і для оператора **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - Такі ж умови будуть застосовуватися до оператора(ів): **%@**. - No comment provided by engineer. - The second preset operator in the app! Другий попередньо встановлений оператор у застосунку! @@ -8608,11 +8607,6 @@ Repeat join request? Ви можете змінити його в налаштуваннях зовнішнього вигляду. No comment provided by engineer. - - You can configure operators in Network & servers settings. - Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. - No comment provided by engineer. - You can configure servers via settings. Ви можете налаштувати сервери за допомогою налаштувань. @@ -8854,11 +8848,6 @@ Repeat connection request? Ваші сервери ICE No comment provided by engineer. - - Your SMP servers - Ваші SMP-сервери - No comment provided by engineer. - Your SimpleX address Ваша адреса SimpleX @@ -9096,7 +9085,8 @@ Repeat connection request? blocked by admin заблоковано адміністратором - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9267,7 +9257,7 @@ Repeat connection request? default (%@) за замовчуванням (%@) delete after time - pref value +pref value default (no) @@ -9394,21 +9384,11 @@ Repeat connection request? помилка No comment provided by engineer. - - event happened - відбулася подія - No comment provided by engineer. - expired закінчився No comment provided by engineer. - - for better metadata privacy. - для кращої конфіденційності метаданих. - No comment provided by engineer. - forwarded переслано @@ -9617,8 +9597,8 @@ Repeat connection request? off вимкнено enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9985,7 +9965,7 @@ last received msg: %2$@
- +
@@ -10022,7 +10002,7 @@ last received msg: %2$@
- +
@@ -10044,7 +10024,7 @@ last received msg: %2$@
- +
@@ -10076,7 +10056,7 @@ last received msg: %2$@
- +
@@ -10098,7 +10078,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json index 38238e7802..a93c702952 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "uk", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file 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 d464b2f0ed..1e4c1a72f6 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 @@ -2,7 +2,7 @@
- +
@@ -102,10 +102,12 @@ %@ server + 服务器 No comment provided by engineer. %@ servers + 服务器 No comment provided by engineer. @@ -430,7 +432,7 @@ 1 day 1天 delete after time - time interval +time interval 1 hour @@ -446,13 +448,13 @@ 1 month 1月 delete after time - time interval +time interval 1 week 1周 delete after time - time interval +time interval 1 year @@ -548,8 +550,8 @@ Accept 接受 accept contact request via notification - accept incoming call via notification - swipe action +accept incoming call via notification +swipe action Accept conditions @@ -569,7 +571,7 @@ Accept incognito 接受隐身聊天 accept contact request via notification - swipe action +swipe action Accepted conditions @@ -1279,6 +1281,15 @@ 通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。 No comment provided by engineer. + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + 使用 SimpleX Chat 代表您同意: +- 在公开群中只发送合法内容 +- 尊重其他用户 – 没有垃圾信息。 + No comment provided by engineer. + Call already ended! 通话已结束! @@ -1328,7 +1339,7 @@ Cancel 取消 alert action - alert button +alert button Cancel migration @@ -1417,7 +1428,7 @@ Change self-destruct passcode 更改自毁密码 authentication reason - set passcode view +set passcode view Chat @@ -1642,14 +1653,7 @@ Conditions of use - No comment provided by engineer. - - - Conditions will be accepted for enabled operators after 30 days. - No comment provided by engineer. - - - Conditions will be accepted for operator(s): **%@**. + 使用条款 No comment provided by engineer. @@ -1669,6 +1673,11 @@ 配置 ICE 服务器 No comment provided by engineer. + + Configure server operators + 配置服务器运营方 + No comment provided by engineer. + Confirm 确认 @@ -2254,7 +2263,7 @@ This is your own one-time link! Delete 删除 alert action - swipe action +swipe action Delete %lld messages of members? @@ -2736,7 +2745,7 @@ This is your own one-time link! Download 下载 alert button - chat item action +chat item action Download errors @@ -2811,8 +2820,8 @@ This is your own one-time link! 启用(保持覆盖) No comment provided by engineer. - - Enable Flux + + Enable Flux in Network & servers settings for better metadata privacy. No comment provided by engineer. @@ -3371,7 +3380,9 @@ This is your own one-time link! Error: %@ 错误: %@ - alert message + alert message +file error text +snd error text Error: URL is invalid @@ -5671,11 +5682,21 @@ Error: %@ Privacy for your customers. No comment provided by engineer. + + Privacy policy and conditions of use. + 隐私政策和使用条款。 + No comment provided by engineer. + Privacy redefined 重新定义隐私 No comment provided by engineer. + + Private chats, groups and your contacts are not accessible to server operators. + 服务器运营方无法访问私密聊天、群组和你的联系人。 + No comment provided by engineer. + Private filenames 私密文件名 @@ -6036,7 +6057,7 @@ Enable in *Network & servers* settings. Reject 拒绝 reject incoming call via notification - swipe action +swipe action Reject (sender NOT notified) @@ -6261,10 +6282,6 @@ Enable in *Network & servers* settings. Review conditions No comment provided by engineer. - - Review later - No comment provided by engineer. - Revoke 撤销 @@ -6313,7 +6330,7 @@ Enable in *Network & servers* settings. Save 保存 alert button - chat item action +chat item action Save (and notify contacts) @@ -6871,7 +6888,7 @@ Enable in *Network & servers* settings. Share 分享 alert action - chat item action +chat item action Share 1-time link @@ -7127,7 +7144,7 @@ Enable in *Network & servers* settings. Spam blocking reason - report reason +report reason Square, circle, or anything in between. @@ -7486,10 +7503,6 @@ It can happen because of some bug or when the connection is compromised.The same conditions will apply to operator **%@**. No comment provided by engineer. - - The same conditions will apply to operator(s): **%@**. - No comment provided by engineer. - The second preset operator in the app! No comment provided by engineer. @@ -8428,10 +8441,6 @@ Repeat join request? 您可以在外观设置中更改它。 No comment provided by engineer. - - You can configure operators in Network & servers settings. - No comment provided by engineer. - You can configure servers via settings. No comment provided by engineer. @@ -8670,11 +8679,6 @@ Repeat connection request? 您的 ICE 服务器 No comment provided by engineer. - - Your SMP servers - 您的 SMP 服务器 - No comment provided by engineer. - Your SimpleX address 您的 SimpleX 地址 @@ -8906,7 +8910,8 @@ Repeat connection request? blocked by admin 由管理员封禁 - marked deleted chat item preview text + blocked chat item +marked deleted chat item preview text bold @@ -9077,7 +9082,7 @@ Repeat connection request? default (%@) 默认 (%@) delete after time - pref value +pref value default (no) @@ -9204,20 +9209,11 @@ Repeat connection request? 错误 No comment provided by engineer. - - event happened - 发生的事 - No comment provided by engineer. - expired 过期 No comment provided by engineer. - - for better metadata privacy. - No comment provided by engineer. - forwarded 已转发 @@ -9426,8 +9422,8 @@ Repeat connection request? off 关闭 enabled status - group pref value - time to disappear +group pref value +time to disappear offered %@ @@ -9793,7 +9789,7 @@ last received msg: %2$@
- +
@@ -9830,7 +9826,7 @@ last received msg: %2$@
- +
@@ -9852,7 +9848,7 @@ last received msg: %2$@
- +
@@ -9879,7 +9875,7 @@ last received msg: %2$@
- +
@@ -9901,7 +9897,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json index 6416a2d8fa..91977b0744 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "zh-Hans", "toolInfo" : { - "toolBuildNumber" : "15F31d", + "toolBuildNumber" : "16C5032a", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "15.4" + "toolVersion" : "16.2" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 2ba56bb2af..2fedf0e6f1 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -38,10 +38,10 @@ "Database upgrade required" = "Adatbázis fejlesztése szükséges"; /* No comment provided by engineer. */ -"Error preparing file" = "Hiba a fájl előkészítésekor"; +"Error preparing file" = "Hiba történt a fájl előkészítésekor"; /* No comment provided by engineer. */ -"Error preparing message" = "Hiba az üzenet előkészítésekor"; +"Error preparing message" = "Hiba történt az üzenet előkészítésekor"; /* No comment provided by engineer. */ "Error: %@" = "Hiba: %@"; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 631e0e7628..432bc75894 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -242,7 +242,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 ден"; /* time interval */ @@ -252,11 +252,11 @@ "1 minute" = "1 минута"; /* delete after time - time interval */ +time interval */ "1 month" = "1 месец"; /* delete after time - time interval */ +time interval */ "1 week" = "1 седмица"; /* No comment provided by engineer. */ @@ -308,8 +308,8 @@ "Accent" = "Акцент"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Приеми"; /* No comment provided by engineer. */ @@ -322,7 +322,7 @@ "Accept contact request from %@?" = "Приемане на заявка за контакт от %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Приеми инкогнито"; /* call status */ @@ -718,7 +718,8 @@ /* rcv group event chat item */ "blocked %@" = "блокиран %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "блокиран от админ"; /* No comment provided by engineer. */ @@ -794,7 +795,7 @@ "Can't invite contacts!" = "Не може да поканят контактите!"; /* alert action - alert button */ +alert button */ "Cancel" = "Отказ"; /* No comment provided by engineer. */ @@ -846,7 +847,7 @@ "Change self-destruct mode" = "Промени режима на самоунищожение"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Промени кода за достъп за самоунищожение"; /* chat item text */ @@ -1252,7 +1253,7 @@ "Decryption error" = "Грешка при декриптиране"; /* delete after time - pref value */ +pref value */ "default (%@)" = "по подразбиране (%@)"; /* No comment provided by engineer. */ @@ -1262,7 +1263,7 @@ "default (yes)" = "по подразбиране (да)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Изтрий"; /* No comment provided by engineer. */ @@ -1491,7 +1492,7 @@ "Downgrade and open chat" = "Понижи версията и отвори чата"; /* alert button - chat item action */ +chat item action */ "Download" = "Изтегли"; /* No comment provided by engineer. */ @@ -1845,7 +1846,9 @@ /* No comment provided by engineer. */ "Error: " = "Грешка: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Грешка: %@"; /* No comment provided by engineer. */ @@ -1857,9 +1860,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Дори когато е деактивиран в разговора."; -/* No comment provided by engineer. */ -"event happened" = "събитие се случи"; - /* No comment provided by engineer. */ "Exit without saving" = "Изход без запазване"; @@ -2746,8 +2746,8 @@ "observer" = "наблюдател"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "изключено"; /* blur media */ @@ -3132,7 +3132,7 @@ "Reduced battery usage" = "Намалена консумация на батерията"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Отхвърляне"; /* No comment provided by engineer. */ @@ -3259,7 +3259,7 @@ "Safer groups" = "По-безопасни групи"; /* alert button - chat item action */ +chat item action */ "Save" = "Запази"; /* alert button */ @@ -3515,7 +3515,7 @@ "Shape profile images" = "Променете формата на профилните изображения"; /* alert action - chat item action */ +chat item action */ "Share" = "Сподели"; /* No comment provided by engineer. */ @@ -4463,6 +4463,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Вашият адрес в SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Вашите SMP сървъри"; - diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index b6baf5e951..25fb66aa12 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -215,7 +215,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 den"; /* time interval */ @@ -225,11 +225,11 @@ "1 minute" = "1 minutu"; /* delete after time - time interval */ +time interval */ "1 month" = "1 měsíc"; /* delete after time - time interval */ +time interval */ "1 week" = "1 týden"; /* No comment provided by engineer. */ @@ -272,8 +272,8 @@ "above, then choose:" = "výše, pak vyberte:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Přijmout"; /* No comment provided by engineer. */ @@ -283,7 +283,7 @@ "Accept contact request from %@?" = "Přijmout žádost o kontakt od %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Přijmout inkognito"; /* call status */ @@ -548,7 +548,7 @@ "Can't invite contacts!" = "Nelze pozvat kontakty!"; /* alert action - alert button */ +alert button */ "Cancel" = "Zrušit"; /* feature offered item */ @@ -588,7 +588,7 @@ "Change self-destruct mode" = "Změnit režim sebedestrukce"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Změnit sebedestrukční heslo"; /* chat item text */ @@ -910,7 +910,7 @@ "Decryption error" = "Chyba dešifrování"; /* delete after time - pref value */ +pref value */ "default (%@)" = "výchozí (%@)"; /* No comment provided by engineer. */ @@ -920,7 +920,7 @@ "default (yes)" = "výchozí (ano)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Smazat"; /* No comment provided by engineer. */ @@ -1409,7 +1409,9 @@ /* No comment provided by engineer. */ "Error: " = "Chyba: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Chyba: %@"; /* No comment provided by engineer. */ @@ -2127,8 +2129,8 @@ "observer" = "pozorovatel"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "vypnuto"; /* blur media */ @@ -2435,7 +2437,7 @@ "Reduced battery usage" = "Snížení spotřeby baterie"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Odmítnout"; /* No comment provided by engineer. */ @@ -2535,7 +2537,7 @@ "Run chat" = "Spustit chat"; /* alert button - chat item action */ +chat item action */ "Save" = "Uložit"; /* alert button */ @@ -2749,7 +2751,7 @@ "Settings" = "Nastavení"; /* alert action - chat item action */ +chat item action */ "Share" = "Sdílet"; /* No comment provided by engineer. */ @@ -3472,6 +3474,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Vaše SimpleX adresa"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Vaše servery SMP"; - diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index a85506f2e3..d92ad24117 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -269,7 +269,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "Älter als ein Tag"; /* time interval */ @@ -279,11 +279,11 @@ "1 minute" = "1 Minute"; /* delete after time - time interval */ +time interval */ "1 month" = "Älter als ein Monat"; /* delete after time - time interval */ +time interval */ "1 week" = "Älter als eine Woche"; /* delete after time */ @@ -341,8 +341,8 @@ "Accent" = "Akzent"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Annehmen"; /* No comment provided by engineer. */ @@ -355,7 +355,7 @@ "Accept contact request from %@?" = "Die Kontaktanfrage von %@ annehmen?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Inkognito akzeptieren"; /* call status */ @@ -817,7 +817,8 @@ /* rcv group event chat item */ "blocked %@" = "%@ wurde blockiert"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "wurde vom Administrator blockiert"; /* No comment provided by engineer. */ @@ -862,6 +863,9 @@ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam."; + /* No comment provided by engineer. */ "call" = "Anrufen"; @@ -902,7 +906,7 @@ "Can't message member" = "Mitglied kann nicht benachrichtigt werden"; /* alert action - alert button */ +alert button */ "Cancel" = "Abbrechen"; /* No comment provided by engineer. */ @@ -960,7 +964,7 @@ "Change self-destruct mode" = "Selbstzerstörungs-Modus ändern"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Selbstzerstörungs-Zugangscode ändern"; /* chat item text */ @@ -1128,12 +1132,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Nutzungsbedingungen"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Die Nutzungsbedingungen der aktivierten Betreiber werden nach 30 Tagen akzeptiert."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**."; @@ -1146,6 +1144,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-Server konfigurieren"; +/* No comment provided by engineer. */ +"Configure server operators" = "Server-Betreiber konfigurieren"; + /* No comment provided by engineer. */ "Confirm" = "Bestätigen"; @@ -1555,7 +1556,7 @@ "decryption errors" = "Entschlüsselungs-Fehler"; /* delete after time - pref value */ +pref value */ "default (%@)" = "Default (%@)"; /* No comment provided by engineer. */ @@ -1565,7 +1566,7 @@ "default (yes)" = "Voreinstellung (Ja)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Löschen"; /* No comment provided by engineer. */ @@ -1881,7 +1882,7 @@ "Downgrade and open chat" = "Datenbank herabstufen und den Chat öffnen"; /* alert button - chat item action */ +chat item action */ "Download" = "Herunterladen"; /* No comment provided by engineer. */ @@ -1944,9 +1945,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Kamera-Zugriff aktivieren"; -/* No comment provided by engineer. */ -"Enable Flux" = "Flux aktivieren"; - /* No comment provided by engineer. */ "Enable for all" = "Für Alle aktivieren"; @@ -2224,7 +2222,7 @@ "Error opening chat" = "Fehler beim Öffnen des Chats"; /* alert title */ -"Error receiving file" = "Fehler beim Empfangen der Datei"; +"Error receiving file" = "Fehler beim Herunterladen der Datei"; /* No comment provided by engineer. */ "Error reconnecting server" = "Fehler beim Wiederherstellen der Verbindung zum Server"; @@ -2325,7 +2323,9 @@ /* No comment provided by engineer. */ "Error: " = "Fehler: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Fehler: %@"; /* No comment provided by engineer. */ @@ -2343,9 +2343,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Auch wenn sie im Chat deaktiviert sind."; -/* No comment provided by engineer. */ -"event happened" = "event happened"; - /* No comment provided by engineer. */ "Exit without saving" = "Beenden ohne Speichern"; @@ -2422,10 +2419,10 @@ "File will be deleted from servers." = "Die Datei wird von den Servern gelöscht."; /* No comment provided by engineer. */ -"File will be received when your contact completes uploading it." = "Die Datei wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; +"File will be received when your contact completes uploading it." = "Die Datei wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; /* No comment provided by engineer. */ -"File will be received when your contact is online, please wait or check later!" = "Die Datei wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; +"File will be received when your contact is online, please wait or check later!" = "Die Datei wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; /* No comment provided by engineer. */ "File: %@" = "Datei: %@"; @@ -2484,9 +2481,6 @@ /* No comment provided by engineer. */ "For all moderators" = "Für alle Moderatoren"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "für einen besseren Metadatenschutz."; - /* servers error */ "For chat profile %@:" = "Für das Chat-Profil %@:"; @@ -2728,10 +2722,10 @@ "Ignore" = "Ignorieren"; /* No comment provided by engineer. */ -"Image will be received when your contact completes uploading it." = "Das Bild wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; +"Image will be received when your contact completes uploading it." = "Das Bild wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist."; /* No comment provided by engineer. */ -"Image will be received when your contact is online, please wait or check later!" = "Das Bild wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; +"Image will be received when your contact is online, please wait or check later!" = "Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; /* No comment provided by engineer. */ "Immediately" = "Sofort"; @@ -3529,13 +3523,13 @@ "No push server" = "Lokal"; /* No comment provided by engineer. */ -"No received or sent files" = "Keine empfangenen oder gesendeten Dateien"; +"No received or sent files" = "Keine herunter- oder hochgeladene Dateien"; /* servers error */ "No servers for private message routing." = "Keine Server für privates Nachrichten-Routing."; /* servers error */ -"No servers to receive files." = "Keine Server für den Empfang von Dateien."; +"No servers to receive files." = "Keine Server für das Herunterladen von Dateien."; /* servers error */ "No servers to receive messages." = "Keine Server für den Empfang von Nachrichten."; @@ -3589,8 +3583,8 @@ "observer" = "Beobachter"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "Aus"; /* blur media */ @@ -3914,9 +3908,15 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Schutz der Privatsphäre Ihrer Kunden."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Datenschutzbestimmungen und Nutzungsbedingungen."; + /* No comment provided by engineer. */ "Privacy redefined" = "Datenschutz neu definiert"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich."; + /* No comment provided by engineer. */ "Private filenames" = "Neutrale Dateinamen"; @@ -4089,7 +4089,7 @@ "Receiving address will be changed to a different server. Address change will complete after sender comes online." = "Die Empfängeradresse wird auf einen anderen Server geändert. Der Adresswechsel wird abgeschlossen, wenn der Absender wieder online ist."; /* No comment provided by engineer. */ -"Receiving file will be stopped." = "Der Empfang der Datei wird beendet."; +"Receiving file will be stopped." = "Das Herunterladen der Datei wird beendet."; /* No comment provided by engineer. */ "Receiving via" = "Empfangen über"; @@ -4143,7 +4143,7 @@ "Registered" = "Registriert"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Ablehnen"; /* No comment provided by engineer. */ @@ -4311,9 +4311,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Nutzungsbedingungen einsehen"; -/* No comment provided by engineer. */ -"Review later" = "Später einsehen"; - /* No comment provided by engineer. */ "Revoke" = "Widerrufen"; @@ -4330,13 +4327,13 @@ "Run chat" = "Chat starten"; /* No comment provided by engineer. */ -"Safely receive files" = "Dateien sicher empfangen"; +"Safely receive files" = "Dateien sicher herunterladen"; /* No comment provided by engineer. */ "Safer groups" = "Sicherere Gruppen"; /* alert button - chat item action */ +chat item action */ "Save" = "Speichern"; /* alert button */ @@ -4712,7 +4709,7 @@ "Shape profile images" = "Form der Profil-Bilder"; /* alert action - chat item action */ +chat item action */ "Share" = "Teilen"; /* No comment provided by engineer. */ @@ -4878,7 +4875,7 @@ "Somebody" = "Jemand"; /* blocking reason - report reason */ +report reason */ "Spam" = "Spam"; /* No comment provided by engineer. */ @@ -4918,13 +4915,13 @@ "Stop chat?" = "Chat beenden?"; /* cancel file action */ -"Stop file" = "Datei beenden"; +"Stop file" = "Herunterladen beenden"; /* No comment provided by engineer. */ -"Stop receiving file?" = "Den Empfang der Datei beenden?"; +"Stop receiving file?" = "Das Herunterladen der Datei beenden?"; /* No comment provided by engineer. */ -"Stop sending file?" = "Das Senden der Datei beenden?"; +"Stop sending file?" = "Das Hochladen der Datei beenden?"; /* alert action */ "Stop sharing" = "Teilen beenden"; @@ -5109,9 +5106,6 @@ /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den/die Betreiber: **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Der zweite voreingestellte Netzwerk-Betreiber in der App!"; @@ -5146,7 +5140,7 @@ "They can be overridden in contact and group settings." = "Sie können in den Kontakteinstellungen überschrieben werden."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden!"; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Es werden alle herunter- und hochgeladene Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden!"; @@ -5551,10 +5545,10 @@ "video call (not e2e encrypted)" = "Videoanruf (nicht E2E verschlüsselt)"; /* No comment provided by engineer. */ -"Video will be received when your contact completes uploading it." = "Das Video wird empfangen, sobald Ihr Kontakt das Hochladen beendet hat."; +"Video will be received when your contact completes uploading it." = "Das Video wird heruntergeladen, sobald Ihr Kontakt das Hochladen beendet hat."; /* No comment provided by engineer. */ -"Video will be received when your contact is online, please wait or check later!" = "Das Video wird empfangen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!"; +"Video will be received when your contact is online, please wait or check later!" = "Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!"; /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videos und Dateien bis zu 1GB"; @@ -5766,9 +5760,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Sie können die Betreiber in den Netzwerk- und Servereinstellungen konfigurieren."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Sie können die Server über die Einstellungen konfigurieren."; @@ -6015,6 +6006,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Ihre SimpleX-Adresse"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Ihre SMP-Server"; - diff --git a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings index 0dee85ad95..e0554c9fb6 100644 --- a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings @@ -14,5 +14,5 @@ "NSMicrophoneUsageDescription" = "SimpleX benötigt Zugriff auf das Mikrofon, um Audio- und Videoanrufe und die Aufnahme von Sprachnachrichten zu ermöglichen."; /* Privacy - Photo Library Additions Usage Description */ -"NSPhotoLibraryAddUsageDescription" = "SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder empfangene Bilder zu speichern"; +"NSPhotoLibraryAddUsageDescription" = "SimpleX benötigt Zugriff auf das Fotoalbum, um selbst gemachte oder heruntergeladene Bilder zu speichern"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 09e7ea5b8e..b6580836e7 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -269,7 +269,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "un dia"; /* time interval */ @@ -279,11 +279,11 @@ "1 minute" = "1 minuto"; /* delete after time - time interval */ +time interval */ "1 month" = "un mes"; /* delete after time - time interval */ +time interval */ "1 week" = "una semana"; /* delete after time */ @@ -341,8 +341,8 @@ "Accent" = "Color"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Aceptar"; /* No comment provided by engineer. */ @@ -355,7 +355,7 @@ "Accept contact request from %@?" = "¿Aceptar solicitud de contacto de %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Aceptar incógnito"; /* call status */ @@ -470,10 +470,10 @@ "All app data is deleted." = "Todos los datos de la aplicación se eliminarán."; /* No comment provided by engineer. */ -"All chats and messages will be deleted - this cannot be undone!" = "Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse!"; +"All chats and messages will be deleted - this cannot be undone!" = "Se eliminarán todos los chats y mensajes. ¡No puede deshacerse!"; /* alert message */ -"All chats will be removed from the list %@, and the list deleted." = "Todos los chats serán quitados de la lista %@ y esta será eliminada."; +"All chats will be removed from the list %@, and the list deleted." = "Todos los chats se quitarán de la lista %@ y esta será eliminada."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Al introducirlo todos los datos son eliminados."; @@ -491,10 +491,10 @@ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos."; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone!" = "Todos los mensajes serán borrados. ¡No podrá deshacerse!"; +"All messages will be deleted - this cannot be undone!" = "Todos los mensajes serán eliminados. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse!"; +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Se eliminarán todos los mensajes SOLO para tí. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "All new messages from %@ will be hidden!" = "¡Los mensajes nuevos de %@ estarán ocultos!"; @@ -629,7 +629,7 @@ "App group:" = "Grupo app:"; /* No comment provided by engineer. */ -"App icon" = "Icono aplicación"; +"App icon" = "Icono de la aplicación"; /* No comment provided by engineer. */ "App passcode" = "Código de acceso de la aplicación"; @@ -817,7 +817,8 @@ /* rcv group event chat item */ "blocked %@" = "ha bloqueado a %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "bloqueado por administrador"; /* No comment provided by engineer. */ @@ -862,6 +863,9 @@ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Al usar SimpleX Chat, aceptas:\n- enviar únicamente contenido legal en los grupos públicos.\n- respetar a los demás usuarios – spam prohibido."; + /* No comment provided by engineer. */ "call" = "llamada"; @@ -902,7 +906,7 @@ "Can't message member" = "No se pueden enviar mensajes al miembro"; /* alert action - alert button */ +alert button */ "Cancel" = "Cancelar"; /* No comment provided by engineer. */ @@ -960,7 +964,7 @@ "Change self-destruct mode" = "Cambiar el modo de autodestrucción"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Cambiar código autodestrucción"; /* chat item text */ @@ -1033,10 +1037,10 @@ "Chat theme" = "Tema de chat"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "El chat será eliminado para todos los miembros. ¡No podrá deshacerse!"; +"Chat will be deleted for all members - this cannot be undone!" = "El chat será eliminado para todos los miembros. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "El chat será eliminado para tí. ¡No podrá deshacerse!"; +"Chat will be deleted for you - this cannot be undone!" = "El chat será eliminado para tí. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Chats" = "Chats"; @@ -1128,12 +1132,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Condiciones de uso"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Las condiciones de los operadores habilitados serán aceptadas después de 30 días."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Las condiciones serán aceptadas para el/los operador(es): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Las condiciones serán aceptadas para el/los operador(es): **%@**."; @@ -1146,6 +1144,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "Configure servidores ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Configurar operadores de servidores"; + /* No comment provided by engineer. */ "Confirm" = "Confirmar"; @@ -1357,7 +1358,7 @@ "Contact preferences" = "Preferencias de contacto"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "El contacto será eliminado. ¡No podrá deshacerse!"; +"Contact will be deleted - this cannot be undone!" = "El contacto será eliminado. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Contacts" = "Contactos"; @@ -1555,7 +1556,7 @@ "decryption errors" = "errores de descifrado"; /* delete after time - pref value */ +pref value */ "default (%@)" = "predeterminado (%@)"; /* No comment provided by engineer. */ @@ -1565,7 +1566,7 @@ "default (yes)" = "predeterminado (sí)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Eliminar"; /* No comment provided by engineer. */ @@ -1671,7 +1672,7 @@ "Delete old database?" = "¿Eliminar base de datos antigua?"; /* No comment provided by engineer. */ -"Delete or moderate up to 200 messages." = "Borra o modera hasta 200 mensajes a la vez."; +"Delete or moderate up to 200 messages." = "Elimina o modera hasta 200 mensajes a la vez."; /* No comment provided by engineer. */ "Delete pending connection?" = "¿Eliminar conexión pendiente?"; @@ -1683,7 +1684,7 @@ "Delete queue" = "Eliminar cola"; /* No comment provided by engineer. */ -"Delete report" = "Borrar informe"; +"Delete report" = "Eliminar informe"; /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Elimina hasta 20 mensajes a la vez."; @@ -1881,7 +1882,7 @@ "Downgrade and open chat" = "Degradar y abrir Chat"; /* alert button - chat item action */ +chat item action */ "Download" = "Descargar"; /* No comment provided by engineer. */ @@ -1944,9 +1945,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Permitir acceso a la cámara"; -/* No comment provided by engineer. */ -"Enable Flux" = "Habilita Flux"; - /* No comment provided by engineer. */ "Enable for all" = "Activar para todos"; @@ -2325,7 +2323,9 @@ /* No comment provided by engineer. */ "Error: " = "Error: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Error: %@"; /* No comment provided by engineer. */ @@ -2343,9 +2343,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Incluso si está desactivado para la conversación."; -/* No comment provided by engineer. */ -"event happened" = "evento ocurrido"; - /* No comment provided by engineer. */ "Exit without saving" = "Salir sin guardar"; @@ -2407,7 +2404,7 @@ "File is blocked by server operator:\n%@." = "Archivo bloqueado por el operador del servidor\n%@."; /* file error text */ -"File not found - most likely file was deleted or cancelled." = "Archivo no encontrado, probablemente haya sido borrado o cancelado."; +"File not found - most likely file was deleted or cancelled." = "Archivo no encontrado, probablemente haya sido eliminado o cancelado."; /* file error text */ "File server error: %@" = "Error del servidor de archivos: %@"; @@ -2484,9 +2481,6 @@ /* No comment provided by engineer. */ "For all moderators" = "Para todos los moderadores"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "para mejorar la privacidad de los metadatos."; - /* servers error */ "For chat profile %@:" = "Para el perfil de chat %@:"; @@ -2641,10 +2635,10 @@ "Group welcome message" = "Mensaje de bienvenida en grupos"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "El grupo será eliminado para todos los miembros. ¡No podrá deshacerse!"; +"Group will be deleted for all members - this cannot be undone!" = "El grupo será eliminado para todos los miembros. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"Group will be deleted for you - this cannot be undone!" = "El grupo será eliminado para tí. ¡No podrá deshacerse!"; +"Group will be deleted for you - this cannot be undone!" = "El grupo será eliminado para tí. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Groups" = "Grupos"; @@ -3148,7 +3142,7 @@ "Member inactive" = "Miembro inactivo"; /* chat feature */ -"Member reports" = "Informes de miembro"; +"Member reports" = "Informes de miembros"; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "El rol del miembro cambiará a \"%@\" y todos serán notificados."; @@ -3160,10 +3154,10 @@ "Member role will be changed to \"%@\". The member will receive a new invitation." = "El rol del miembro cambiará a \"%@\" y recibirá una invitación nueva."; /* No comment provided by engineer. */ -"Member will be removed from chat - this cannot be undone!" = "El miembro será eliminado del chat. ¡No podrá deshacerse!"; +"Member will be removed from chat - this cannot be undone!" = "El miembro será eliminado del chat. ¡No puede deshacerse!"; /* No comment provided by engineer. */ -"Member will be removed from group - this cannot be undone!" = "El miembro será expulsado del grupo. ¡No podrá deshacerse!"; +"Member will be removed from group - this cannot be undone!" = "El miembro será expulsado del grupo. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Members can add message reactions." = "Los miembros pueden añadir reacciones a los mensajes."; @@ -3265,7 +3259,7 @@ "Messages from %@ will be shown!" = "¡Los mensajes de %@ serán mostrados!"; /* alert message */ -"Messages in this chat will never be deleted." = "Los mensajes de esta conversación nunca se borran."; +"Messages in this chat will never be deleted." = "Los mensajes de esta conversación nunca se eliminan."; /* No comment provided by engineer. */ "Messages received" = "Mensajes recibidos"; @@ -3274,7 +3268,7 @@ "Messages sent" = "Mensajes enviados"; /* alert message */ -"Messages were deleted after you selected them." = "Los mensajes han sido borrados después de seleccionarlos."; +"Messages were deleted after you selected them." = "Los mensajes han sido eliminados después de seleccionarlos."; /* 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." = "Los mensajes, archivos y llamadas están protegidos mediante **cifrado de extremo a extremo** con secreto perfecto hacía adelante, repudio y recuperación tras ataque."; @@ -3589,8 +3583,8 @@ "observer" = "observador"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "desactivado"; /* blur media */ @@ -3914,9 +3908,15 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Privacidad para tus clientes."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Política de privacidad y condiciones de uso."; + /* No comment provided by engineer. */ "Privacy redefined" = "Privacidad redefinida"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores."; + /* No comment provided by engineer. */ "Private filenames" = "Nombres de archivos privados"; @@ -4029,7 +4029,7 @@ "Rate the app" = "Valora la aplicación"; /* No comment provided by engineer. */ -"Reachable chat toolbar" = "Barra de chat accesible"; +"Reachable chat toolbar" = "Barra de menú accesible"; /* chat item menu */ "React…" = "Reacciona…"; @@ -4143,7 +4143,7 @@ "Registered" = "Registrado"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Rechazar"; /* No comment provided by engineer. */ @@ -4311,9 +4311,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Revisar condiciones"; -/* No comment provided by engineer. */ -"Review later" = "Revisar más tarde"; - /* No comment provided by engineer. */ "Revoke" = "Revocar"; @@ -4336,7 +4333,7 @@ "Safer groups" = "Grupos más seguros"; /* alert button - chat item action */ +chat item action */ "Save" = "Guardar"; /* alert button */ @@ -4655,7 +4652,7 @@ "Servers info" = "Info servidores"; /* No comment provided by engineer. */ -"Servers statistics will be reset - this cannot be undone!" = "Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse!"; +"Servers statistics will be reset - this cannot be undone!" = "Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "Session code" = "Código de sesión"; @@ -4712,7 +4709,7 @@ "Shape profile images" = "Dar forma a las imágenes de perfil"; /* alert action - chat item action */ +chat item action */ "Share" = "Compartir"; /* No comment provided by engineer. */ @@ -4788,7 +4785,7 @@ "SimpleX Address" = "Dirección SimpleX"; /* No comment provided by engineer. */ -"SimpleX address and 1-time links are safe to share via any messenger." = "Compartir los enlaces de un uso y las direcciones SimpleX es seguro a través de cualquier medio."; +"SimpleX address and 1-time links are safe to share via any messenger." = "Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio."; /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "¿Dirección SimpleX o enlace de un uso?"; @@ -4878,7 +4875,7 @@ "Somebody" = "Alguien"; /* blocking reason - report reason */ +report reason */ "Spam" = "Spam"; /* No comment provided by engineer. */ @@ -5062,7 +5059,7 @@ "The attempt to change database passphrase was not completed." = "El intento de cambiar la contraseña de la base de datos no se ha completado."; /* No comment provided by engineer. */ -"The code you scanned is not a SimpleX link QR code." = "El código QR escaneado no es un enlace SimpleX."; +"The code you scanned is not a SimpleX link QR code." = "El código QR escaneado no es un enlace de SimpleX."; /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado."; @@ -5109,9 +5106,6 @@ /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Las mismas condiciones se aplicarán al operador **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Las mismas condiciones se aplicarán a el/los operador(es) **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "¡Segundo operador predefinido!"; @@ -5128,7 +5122,7 @@ "The servers for new files of your current chat profile **%@**." = "Servidores para enviar archivos en tu perfil **%@**."; /* No comment provided by engineer. */ -"The text you pasted is not a SimpleX link." = "El texto pegado no es un enlace SimpleX."; +"The text you pasted is not a SimpleX link." = "El texto pegado no es un enlace de SimpleX."; /* No comment provided by engineer. */ "The uploaded database archive will be permanently removed from the servers." = "El archivo de bases de datos subido será eliminado permanentemente de los servidores."; @@ -5152,7 +5146,7 @@ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Esta acción es irreversible. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Podría tardar varios minutos."; /* alert message */ -"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No podrá deshacerse!"; +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No puede deshacerse!"; /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente."; @@ -5188,7 +5182,7 @@ "This link was used with another mobile device, please create a new link on the desktop." = "Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador."; /* No comment provided by engineer. */ -"This message was deleted or not received yet." = "El mensaje ha sido borrado o aún no se ha recibido."; +"This message was deleted or not received yet." = "El mensaje ha sido eliminado o aún no se ha recibido."; /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Esta configuración se aplica a los mensajes del perfil actual **%@**."; @@ -5221,7 +5215,7 @@ "To protect your IP address, private routing uses your SMP servers to deliver messages." = "Para proteger tu dirección IP, el enrutamiento privado usa tu lista de servidores SMP para enviar mensajes."; /* No comment provided by engineer. */ -"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Para proteger tu privacidad, en lugar de los identificadores de usuario que usan el resto de plataformas, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos."; +"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos."; /* No comment provided by engineer. */ "To receive" = "Para recibir"; @@ -5766,9 +5760,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Puedes cambiar la posición de la barra desde el menú Apariencia."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Puedes configurar los operadores desde Servidores y Redes."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Puedes configurar los servidores a través de su configuración."; @@ -6015,6 +6006,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Mi dirección SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Servidores SMP"; - diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 33a2acff78..c4031adf9a 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -182,7 +182,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 päivä"; /* time interval */ @@ -192,11 +192,11 @@ "1 minute" = "1 minuutti"; /* delete after time - time interval */ +time interval */ "1 month" = "1 kuukausi"; /* delete after time - time interval */ +time interval */ "1 week" = "1 viikko"; /* No comment provided by engineer. */ @@ -239,8 +239,8 @@ "above, then choose:" = "edellä, valitse sitten:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Hyväksy"; /* No comment provided by engineer. */ @@ -250,7 +250,7 @@ "Accept contact request from %@?" = "Hyväksy kontaktipyyntö %@:ltä?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Hyväksy tuntematon"; /* call status */ @@ -509,7 +509,7 @@ "Can't invite contacts!" = "Kontakteja ei voi kutsua!"; /* alert action - alert button */ +alert button */ "Cancel" = "Peruuta"; /* feature offered item */ @@ -549,7 +549,7 @@ "Change self-destruct mode" = "Vaihda itsetuhotilaa"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Vaihda itsetuhoutuva pääsykoodi"; /* chat item text */ @@ -868,7 +868,7 @@ "Decryption error" = "Salauksen purkuvirhe"; /* delete after time - pref value */ +pref value */ "default (%@)" = "oletusarvo (%@)"; /* No comment provided by engineer. */ @@ -878,7 +878,7 @@ "default (yes)" = "oletusarvo (kyllä)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Poista"; /* No comment provided by engineer. */ @@ -1358,7 +1358,9 @@ /* No comment provided by engineer. */ "Error: " = "Virhe: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Virhe: %@"; /* No comment provided by engineer. */ @@ -1370,9 +1372,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Jopa kun ei käytössä keskustelussa."; -/* No comment provided by engineer. */ -"event happened" = "tapahtuma tapahtui"; - /* No comment provided by engineer. */ "Exit without saving" = "Poistu tallentamatta"; @@ -2076,8 +2075,8 @@ "observer" = "tarkkailija"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "pois"; /* blur media */ @@ -2381,7 +2380,7 @@ "Reduced battery usage" = "Pienempi akun käyttö"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Hylkää"; /* No comment provided by engineer. */ @@ -2481,7 +2480,7 @@ "Run chat" = "Käynnistä chat"; /* alert button - chat item action */ +chat item action */ "Save" = "Tallenna"; /* alert button */ @@ -2689,7 +2688,7 @@ "Settings" = "Asetukset"; /* alert action - chat item action */ +chat item action */ "Share" = "Jaa"; /* No comment provided by engineer. */ @@ -3406,6 +3405,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "SimpleX-osoitteesi"; -/* No comment provided by engineer. */ -"Your SMP servers" = "SMP-palvelimesi"; - diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index ad33963572..9990bd4a3e 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -266,7 +266,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 jour"; /* time interval */ @@ -276,11 +276,11 @@ "1 minute" = "1 minute"; /* delete after time - time interval */ +time interval */ "1 month" = "1 mois"; /* delete after time - time interval */ +time interval */ "1 week" = "1 semaine"; /* No comment provided by engineer. */ @@ -335,8 +335,8 @@ "Accent" = "Principale"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Accepter"; /* No comment provided by engineer. */ @@ -349,7 +349,7 @@ "Accept contact request from %@?" = "Accepter la demande de contact de %@ ?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Accepter en incognito"; /* call status */ @@ -754,7 +754,8 @@ /* rcv group event chat item */ "blocked %@" = "%@ bloqué"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "bloqué par l'administrateur"; /* No comment provided by engineer. */ @@ -836,7 +837,7 @@ "Can't message member" = "Impossible d'envoyer un message à ce membre"; /* alert action - alert button */ +alert button */ "Cancel" = "Annuler"; /* No comment provided by engineer. */ @@ -891,7 +892,7 @@ "Change self-destruct mode" = "Modifier le mode d'autodestruction"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Modifier le code d'autodestruction"; /* chat item text */ @@ -1050,12 +1051,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Conditions d'utilisation"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Les conditions seront acceptées pour les opérateurs activés après 30 jours."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Les conditions seront acceptées pour le(s) opérateur(s) : **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Les conditions seront acceptées pour le(s) opérateur(s) : **%@**."; @@ -1456,7 +1451,7 @@ "decryption errors" = "Erreurs de déchiffrement"; /* delete after time - pref value */ +pref value */ "default (%@)" = "défaut (%@)"; /* No comment provided by engineer. */ @@ -1466,7 +1461,7 @@ "default (yes)" = "par défaut (oui)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Supprimer"; /* No comment provided by engineer. */ @@ -1758,7 +1753,7 @@ "Downgrade and open chat" = "Rétrograder et ouvrir le chat"; /* alert button - chat item action */ +chat item action */ "Download" = "Télécharger"; /* No comment provided by engineer. */ @@ -1821,9 +1816,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Autoriser l'accès à la caméra"; -/* No comment provided by engineer. */ -"Enable Flux" = "Activer Flux"; - /* No comment provided by engineer. */ "Enable for all" = "Activer pour tous"; @@ -2178,7 +2170,9 @@ /* No comment provided by engineer. */ "Error: " = "Erreur : "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Erreur : %@"; /* No comment provided by engineer. */ @@ -2196,9 +2190,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Même s'il est désactivé dans la conversation."; -/* No comment provided by engineer. */ -"event happened" = "event happened"; - /* No comment provided by engineer. */ "Exit without saving" = "Quitter sans enregistrer"; @@ -2319,9 +2310,6 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Correction non prise en charge par un membre du groupe"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "pour une meilleure protection des métadonnées."; - /* servers error */ "For chat profile %@:" = "Pour le profil de discussion %@ :"; @@ -3328,8 +3316,8 @@ "observer" = "observateur"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "off"; /* blur media */ @@ -3834,7 +3822,7 @@ "Reduced battery usage" = "Réduction de la consommation de batterie"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Rejeter"; /* No comment provided by engineer. */ @@ -3969,9 +3957,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Vérifier les conditions"; -/* No comment provided by engineer. */ -"Review later" = "Vérifier plus tard"; - /* No comment provided by engineer. */ "Revoke" = "Révoquer"; @@ -3994,7 +3979,7 @@ "Safer groups" = "Groupes plus sûrs"; /* alert button - chat item action */ +chat item action */ "Save" = "Enregistrer"; /* alert button */ @@ -4358,7 +4343,7 @@ "Shape profile images" = "Images de profil modelable"; /* alert action - chat item action */ +chat item action */ "Share" = "Partager"; /* No comment provided by engineer. */ @@ -4742,9 +4727,6 @@ /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Les mêmes conditions s'appliquent à l'opérateur **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Les mêmes conditions s'appliquent à(aux) l'opérateur(s) : **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Le deuxième opérateur prédéfini de l'application !"; @@ -5378,9 +5360,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Vous pouvez choisir de le modifier dans les paramètres d'apparence."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Vous pouvez configurer les serveurs via les paramètres."; @@ -5624,6 +5603,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Votre adresse SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Vos serveurs SMP"; - diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 64b087aa59..f21ba8f6b3 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -26,7 +26,7 @@ "(this device v%@)" = "(ez az eszköz: v%@)"; /* No comment provided by engineer. */ -"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Hozzájárulás](https://github.com/simplex-chat/simplex-chat#contribute)"; +"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Közreműködés](https://github.com/simplex-chat/simplex-chat#contribute)"; /* No comment provided by engineer. */ "[Send us email](mailto:chat@simplex.chat)" = "[Küldjön nekünk e-mailt](mailto:chat@simplex.chat)"; @@ -155,7 +155,7 @@ "%d file(s) are still being downloaded." = "%d fájl letöltése még folyamatban van."; /* forward confirmation reason */ -"%d file(s) failed to download." = "%d fájlt nem sikerült letölteni."; +"%d file(s) failed to download." = "Nem sikerült letölteni %d fájlt."; /* forward confirmation reason */ "%d file(s) were deleted." = "%d fájl törölve lett."; @@ -248,7 +248,7 @@ "%lldw" = "%lldhét"; /* No comment provided by engineer. */ -"%u messages failed to decrypt." = "%u üzenet visszafejtése sikertelen."; +"%u messages failed to decrypt." = "Nem sikerült visszafejteni %u üzenetet."; /* No comment provided by engineer. */ "%u messages skipped." = "%u üzenet kihagyva."; @@ -269,7 +269,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 nap"; /* time interval */ @@ -279,21 +279,21 @@ "1 minute" = "1 perc"; /* delete after time - time interval */ +time interval */ "1 month" = "1 hónap"; /* delete after time - time interval */ +time interval */ "1 week" = "1 hét"; /* delete after time */ "1 year" = "1 év"; /* No comment provided by engineer. */ -"1-time link" = "Egyszer használható meghívási hivatkozás"; +"1-time link" = "Egyszer használható meghívó"; /* No comment provided by engineer. */ -"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívási hivatkozás csak *egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltón keresztül megosztható."; +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó egy hivatkozás és *csak egyetlen partnerrel használható* – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható."; /* No comment provided by engineer. */ "5 minutes" = "5 perc"; @@ -341,21 +341,21 @@ "Accent" = "Kiemelőszín"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Elfogadás"; /* No comment provided by engineer. */ "Accept conditions" = "Feltételek elfogadása"; /* No comment provided by engineer. */ -"Accept connection request?" = "Elfogadja a kapcsolódási kérést?"; +"Accept connection request?" = "Elfogadja a meghívási kérést?"; /* notification body */ -"Accept contact request from %@?" = "Elfogadja %@ kapcsolódási kérését?"; +"Accept contact request from %@?" = "Elfogadja %@ meghívási kérését?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Elfogadás inkognitóban"; /* call status */ @@ -434,7 +434,7 @@ "Address change will be aborted. Old receiving address will be used." = "A cím módosítása meg fog szakadni. A régi fogadási cím lesz használva."; /* No comment provided by engineer. */ -"Address or 1-time link?" = "Cím vagy egyszer használható meghívási hivatkozás?"; +"Address or 1-time link?" = "Cím vagy egyszer használható meghívó?"; /* No comment provided by engineer. */ "Address settings" = "Címbeállítások"; @@ -731,7 +731,7 @@ "Auto-accept" = "Automatikus elfogadás"; /* No comment provided by engineer. */ -"Auto-accept contact requests" = "Kapcsolatkérések automatikus elfogadása"; +"Auto-accept contact requests" = "Meghívási kérések automatikus elfogadása"; /* No comment provided by engineer. */ "Auto-accept images" = "Képek automatikus elfogadása"; @@ -817,7 +817,8 @@ /* rcv group event chat item */ "blocked %@" = "letiltotta őt: %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "letiltva az adminisztrátor által"; /* No comment provided by engineer. */ @@ -862,6 +863,9 @@ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "A SimpleX Chat használatával Ön elfogadja, hogy:\n- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban.\n- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek."; + /* No comment provided by engineer. */ "call" = "hívás"; @@ -902,7 +906,7 @@ "Can't message member" = "Nem lehet üzenetet küldeni a tagnak"; /* alert action - alert button */ +alert button */ "Cancel" = "Mégse"; /* No comment provided by engineer. */ @@ -960,7 +964,7 @@ "Change self-destruct mode" = "Önmegsemmisítő-mód módosítása"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Önmegsemmisítő-jelkód módosítása"; /* chat item text */ @@ -1128,12 +1132,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Használati feltételek"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "A feltételek el lesznek fogadva a következő üzemeltető(k) számára: **%@**."; @@ -1146,6 +1144,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-kiszolgálók beállítása"; +/* No comment provided by engineer. */ +"Configure server operators" = "Kiszolgálóüzemeltetők beállítása"; + /* No comment provided by engineer. */ "Confirm" = "Megerősítés"; @@ -1201,7 +1202,7 @@ "Connect to yourself?" = "Kapcsolódik saját magához?"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódik saját magához?\nEz az Ön egyszer használható meghívási hivatkozása!"; +"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódik saját magához?\nEz az Ön egyszer használható meghívója!"; /* No comment provided by engineer. */ "Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódik saját magához?\nEz az Ön SimpleX-címe!"; @@ -1213,7 +1214,7 @@ "Connect via link" = "Kapcsolódás egy hivatkozáson keresztül"; /* No comment provided by engineer. */ -"Connect via one-time link" = "Kapcsolódás egyszer használható meghívási hivatkozáson keresztül"; +"Connect via one-time link" = "Kapcsolódás egyszer használható meghívón keresztül"; /* No comment provided by engineer. */ "Connect with %@" = "Kapcsolódás a következővel: %@"; @@ -1300,7 +1301,7 @@ "Connection notifications" = "Kapcsolódási értesítések"; /* No comment provided by engineer. */ -"Connection request sent!" = "Kapcsolatkérés elküldve!"; +"Connection request sent!" = "Meghívási kérés elküldve!"; /* No comment provided by engineer. */ "Connection requires encryption renegotiation." = "A kapcsolat titkosítása újraegyeztetést igényel."; @@ -1393,7 +1394,7 @@ "Create" = "Létrehozás"; /* No comment provided by engineer. */ -"Create 1-time link" = "Egyszer használható meghívási hivatkozás létrehozása"; +"Create 1-time link" = "Egyszer használható meghívó létrehozása"; /* No comment provided by engineer. */ "Create a group using a random profile." = "Csoport létrehozása véletlenszerű profillal."; @@ -1471,7 +1472,7 @@ "Custom time" = "Egyéni időköz"; /* No comment provided by engineer. */ -"Customizable message shape." = "Testre szabható üzenetbuborékok."; +"Customizable message shape." = "Személyre szabható üzenetbuborékok."; /* No comment provided by engineer. */ "Customize theme" = "Téma személyre szabása"; @@ -1555,7 +1556,7 @@ "decryption errors" = "visszafejtési hibák"; /* delete after time - pref value */ +pref value */ "default (%@)" = "alapértelmezett (%@)"; /* No comment provided by engineer. */ @@ -1565,7 +1566,7 @@ "default (yes)" = "alapértelmezett (igen)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Törlés"; /* No comment provided by engineer. */ @@ -1881,7 +1882,7 @@ "Downgrade and open chat" = "Visszafejlesztés és a csevegés megnyitása"; /* alert button - chat item action */ +chat item action */ "Download" = "Letöltés"; /* No comment provided by engineer. */ @@ -1944,9 +1945,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Kamera hozzáférés engedélyezése"; -/* No comment provided by engineer. */ -"Enable Flux" = "Flux engedélyezése"; - /* No comment provided by engineer. */ "Enable for all" = "Engedélyezés az összes tag számára"; @@ -2047,7 +2045,7 @@ "encryption re-negotiation allowed for %@" = "a titkosítás újraegyeztetése engedélyezve van %@ számára"; /* message decrypt error item */ -"Encryption re-negotiation error" = "Hiba a titkosítás újraegyeztetésekor"; +"Encryption re-negotiation error" = "Hiba történt a titkosítás újraegyeztetésekor"; /* No comment provided by engineer. */ "Encryption re-negotiation failed." = "Nem sikerült a titkosítást újraegyeztetni."; @@ -2107,225 +2105,227 @@ "Error" = "Hiba"; /* No comment provided by engineer. */ -"Error aborting address change" = "Hiba a cím módosításának megszakításakor"; +"Error aborting address change" = "Hiba történt a cím módosításának megszakításakor"; /* alert title */ -"Error accepting conditions" = "Hiba a feltételek elfogadásakor"; +"Error accepting conditions" = "Hiba történt a feltételek elfogadásakor"; /* No comment provided by engineer. */ -"Error accepting contact request" = "Hiba történt a kapcsolatkérés elfogadásakor"; +"Error accepting contact request" = "Hiba történt a meghívási kérés elfogadásakor"; /* No comment provided by engineer. */ -"Error adding member(s)" = "Hiba a tag(ok) hozzáadásakor"; +"Error adding member(s)" = "Hiba történt a tag(ok) hozzáadásakor"; /* alert title */ -"Error adding server" = "Hiba a kiszolgáló hozzáadásakor"; +"Error adding server" = "Hiba történt a kiszolgáló hozzáadásakor"; /* No comment provided by engineer. */ -"Error changing address" = "Hiba a cím módosításakor"; +"Error changing address" = "Hiba történt a cím módosításakor"; /* No comment provided by engineer. */ -"Error changing connection profile" = "Hiba a kapcsolati profilra való váltáskor"; +"Error changing connection profile" = "Hiba történt a kapcsolati profilra való váltáskor"; /* No comment provided by engineer. */ -"Error changing role" = "Hiba a szerepkör módosításakor"; +"Error changing role" = "Hiba történt a szerepkör módosításakor"; /* No comment provided by engineer. */ -"Error changing setting" = "Hiba a beállítás módosításakor"; +"Error changing setting" = "Hiba történt a beállítás módosításakor"; /* No comment provided by engineer. */ -"Error changing to incognito!" = "Hiba az inkognitóprofilra való váltáskor!"; +"Error changing to incognito!" = "Hiba történt az inkognitóprofilra való váltáskor!"; /* No comment provided by engineer. */ -"Error checking token status" = "Hiba a token állapotának ellenőrzésekor"; +"Error checking token status" = "Hiba történt a token állapotának ellenőrzésekor"; /* No comment provided by engineer. */ -"Error connecting to forwarding server %@. Please try later." = "Hiba a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; +"Error connecting to forwarding server %@. Please try later." = "Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; /* No comment provided by engineer. */ -"Error creating address" = "Hiba a cím létrehozásakor"; +"Error creating address" = "Hiba történt a cím létrehozásakor"; /* No comment provided by engineer. */ -"Error creating group" = "Hiba a csoport létrehozásakor"; +"Error creating group" = "Hiba történt a csoport létrehozásakor"; /* No comment provided by engineer. */ -"Error creating group link" = "Hiba a csoporthivatkozás létrehozásakor"; +"Error creating group link" = "Hiba történt a csoporthivatkozás létrehozásakor"; /* alert title */ -"Error creating list" = "Hiba a lista létrehozásakor"; +"Error creating list" = "Hiba történt a lista létrehozásakor"; /* No comment provided by engineer. */ -"Error creating member contact" = "Hiba a partnerrel történő kapcsolat létrehozásában"; +"Error creating member contact" = "Hiba történt a partnerrel történő kapcsolat létrehozásában"; /* No comment provided by engineer. */ -"Error creating message" = "Hiba az üzenet létrehozásakor"; +"Error creating message" = "Hiba történt az üzenet létrehozásakor"; /* No comment provided by engineer. */ -"Error creating profile!" = "Hiba a profil létrehozásakor!"; +"Error creating profile!" = "Hiba történt a profil létrehozásakor!"; /* No comment provided by engineer. */ -"Error creating report" = "Hiba a jelentés létrehozásakor"; +"Error creating report" = "Hiba történt a jelentés létrehozásakor"; /* No comment provided by engineer. */ -"Error decrypting file" = "Hiba a fájl visszafejtésekor"; +"Error decrypting file" = "Hiba történt a fájl visszafejtésekor"; /* No comment provided by engineer. */ -"Error deleting chat database" = "Hiba a csevegési adatbázis törlésekor"; +"Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; /* No comment provided by engineer. */ -"Error deleting chat!" = "Hiba a csevegés törlésekor!"; +"Error deleting chat!" = "Hiba történt a csevegés törlésekor!"; /* No comment provided by engineer. */ -"Error deleting connection" = "Hiba a kapcsolat törlésekor"; +"Error deleting connection" = "Hiba történt a kapcsolat törlésekor"; /* No comment provided by engineer. */ -"Error deleting database" = "Hiba az adatbázis törlésekor"; +"Error deleting database" = "Hiba történt az adatbázis törlésekor"; /* No comment provided by engineer. */ -"Error deleting old database" = "Hiba a régi adatbázis törlésekor"; +"Error deleting old database" = "Hiba történt a régi adatbázis törlésekor"; /* No comment provided by engineer. */ -"Error deleting token" = "Hiba a token törlésekor"; +"Error deleting token" = "Hiba történt a token törlésekor"; /* No comment provided by engineer. */ -"Error deleting user profile" = "Hiba a felhasználó-profil törlésekor"; +"Error deleting user profile" = "Hiba történt a felhasználó-profil törlésekor"; /* No comment provided by engineer. */ -"Error downloading the archive" = "Hiba az archívum letöltésekor"; +"Error downloading the archive" = "Hiba történt az archívum letöltésekor"; /* No comment provided by engineer. */ -"Error enabling delivery receipts!" = "Hiba a kézbesítési jelentések engedélyezésekor!"; +"Error enabling delivery receipts!" = "Hiba történt a kézbesítési jelentések engedélyezésekor!"; /* No comment provided by engineer. */ -"Error enabling notifications" = "Hiba az értesítések engedélyezésekor"; +"Error enabling notifications" = "Hiba történt az értesítések engedélyezésekor"; /* No comment provided by engineer. */ -"Error encrypting database" = "Hiba az adatbázis titkosításakor"; +"Error encrypting database" = "Hiba történt az adatbázis titkosításakor"; /* No comment provided by engineer. */ -"Error exporting chat database" = "Hiba a csevegési adatbázis exportálásakor"; +"Error exporting chat database" = "Hiba történt a csevegési adatbázis exportálásakor"; /* No comment provided by engineer. */ -"Error exporting theme: %@" = "Hiba a téma exportálásakor: %@"; +"Error exporting theme: %@" = "Hiba történt a téma exportálásakor: %@"; /* No comment provided by engineer. */ -"Error importing chat database" = "Hiba a csevegési adatbázis importálásakor"; +"Error importing chat database" = "Hiba történt a csevegési adatbázis importálásakor"; /* No comment provided by engineer. */ -"Error joining group" = "Hiba a csoporthoz való csatlakozáskor"; +"Error joining group" = "Hiba történt a csoporthoz való csatlakozáskor"; /* alert title */ -"Error loading servers" = "Hiba a kiszolgálók betöltésekor"; +"Error loading servers" = "Hiba történt a kiszolgálók betöltésekor"; /* No comment provided by engineer. */ -"Error migrating settings" = "Hiba a beállítások átköltöztetésekor"; +"Error migrating settings" = "Hiba történt a beállítások átköltöztetésekor"; /* No comment provided by engineer. */ -"Error opening chat" = "Hiba a csevegés megnyitásakor"; +"Error opening chat" = "Hiba történt a csevegés megnyitásakor"; /* alert title */ -"Error receiving file" = "Hiba a fájl fogadásakor"; +"Error receiving file" = "Hiba történt a fájl fogadásakor"; /* No comment provided by engineer. */ -"Error reconnecting server" = "Hiba a kiszolgálóhoz való újrakapcsolódáskor"; +"Error reconnecting server" = "Hiba történt a kiszolgálóhoz való újrakapcsolódáskor"; /* No comment provided by engineer. */ -"Error reconnecting servers" = "Hiba a kiszolgálókhoz való újrakapcsolódáskor"; +"Error reconnecting servers" = "Hiba történt a kiszolgálókhoz való újrakapcsolódáskor"; /* alert title */ -"Error registering for notifications" = "Hiba az értesítések regisztrálásakor"; +"Error registering for notifications" = "Hiba történt az értesítések regisztrálásakor"; /* No comment provided by engineer. */ -"Error removing member" = "Hiba a tag eltávolításakor"; +"Error removing member" = "Hiba történt a tag eltávolításakor"; /* alert title */ -"Error reordering lists" = "Hiba a listák újrarendezésekor"; +"Error reordering lists" = "Hiba történt a listák újrarendezésekor"; /* No comment provided by engineer. */ -"Error resetting statistics" = "Hiba a statisztikák visszaállításakor"; +"Error resetting statistics" = "Hiba történt a statisztikák visszaállításakor"; /* alert title */ -"Error saving chat list" = "Hiba a csevegési lista mentésekor"; +"Error saving chat list" = "Hiba történt a csevegési lista mentésekor"; /* No comment provided by engineer. */ -"Error saving group profile" = "Hiba a csoportprofil mentésekor"; +"Error saving group profile" = "Hiba történt a csoportprofil mentésekor"; /* No comment provided by engineer. */ -"Error saving ICE servers" = "Hiba az ICE-kiszolgálók mentésekor"; +"Error saving ICE servers" = "Hiba történt az ICE-kiszolgálók mentésekor"; /* No comment provided by engineer. */ -"Error saving passcode" = "Hiba a jelkód mentésekor"; +"Error saving passcode" = "Hiba történt a jelkód mentésekor"; /* No comment provided by engineer. */ -"Error saving passphrase to keychain" = "Hiba a jelmondat kulcstartóba történő mentésekor"; +"Error saving passphrase to keychain" = "Hiba történt a jelmondat kulcstartóba történő mentésekor"; /* alert title */ -"Error saving servers" = "Hiba a kiszolgálók mentésekor"; +"Error saving servers" = "Hiba történt a kiszolgálók mentésekor"; /* when migrating */ -"Error saving settings" = "Hiba a beállítások mentésekor"; +"Error saving settings" = "Hiba történt a beállítások mentésekor"; /* No comment provided by engineer. */ -"Error saving user password" = "Hiba a felhasználó jelszavának mentésekor"; +"Error saving user password" = "Hiba történt a felhasználó jelszavának mentésekor"; /* No comment provided by engineer. */ -"Error scanning code: %@" = "Hiba a kód beolvasásakor: %@"; +"Error scanning code: %@" = "Hiba történt a kód beolvasásakor: %@"; /* No comment provided by engineer. */ -"Error sending email" = "Hiba az e-mail küldésekor"; +"Error sending email" = "Hiba történt az e-mail elküldésekor"; /* No comment provided by engineer. */ "Error sending member contact invitation" = "Hiba történt a tag kapcsolatfelvételi meghívójának elküldésekor"; /* No comment provided by engineer. */ -"Error sending message" = "Hiba az üzenet küldésekor"; +"Error sending message" = "Hiba történt az üzenet elküldésekor"; /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Hiba történt a kézbesítési jelentések beállításakor!"; /* No comment provided by engineer. */ -"Error starting chat" = "Hiba a csevegés elindításakor"; +"Error starting chat" = "Hiba történt a csevegés elindításakor"; /* No comment provided by engineer. */ -"Error stopping chat" = "Hiba a csevegés megállításakor"; +"Error stopping chat" = "Hiba történt a csevegés megállításakor"; /* No comment provided by engineer. */ -"Error switching profile" = "Hiba a profilváltáskor"; +"Error switching profile" = "Hiba történt a profilváltáskor"; /* alertTitle */ -"Error switching profile!" = "Hiba a profilváltáskor!"; +"Error switching profile!" = "Hiba történt a profilváltáskor!"; /* No comment provided by engineer. */ -"Error synchronizing connection" = "Hiba a kapcsolat szinkronizálásakor"; +"Error synchronizing connection" = "Hiba történt a kapcsolat szinkronizálásakor"; /* No comment provided by engineer. */ -"Error testing server connection" = "Hiba a kiszolgáló kapcsolatának tesztelésekor"; +"Error testing server connection" = "Hiba történt a kiszolgáló kapcsolatának tesztelésekor"; /* No comment provided by engineer. */ -"Error updating group link" = "Hiba a csoporthivatkozás frissítésekor"; +"Error updating group link" = "Hiba történt a csoporthivatkozás frissítésekor"; /* No comment provided by engineer. */ -"Error updating message" = "Hiba az üzenet frissítésekor"; +"Error updating message" = "Hiba történt az üzenet frissítésekor"; /* alert title */ -"Error updating server" = "Hiba a kiszolgáló frissítésekor"; +"Error updating server" = "Hiba történt a kiszolgáló frissítésekor"; /* No comment provided by engineer. */ "Error updating settings" = "Hiba történt a beállítások frissítésekor"; /* No comment provided by engineer. */ -"Error updating user privacy" = "Hiba a felhasználói adatvédelem frissítésekor"; +"Error updating user privacy" = "Hiba történt a felhasználói adatvédelem frissítésekor"; /* No comment provided by engineer. */ -"Error uploading the archive" = "Hiba az archívum feltöltésekor"; +"Error uploading the archive" = "Hiba történt az archívum feltöltésekor"; /* No comment provided by engineer. */ -"Error verifying passphrase:" = "Hiba a jelmondat hitelesítésekor:"; +"Error verifying passphrase:" = "Hiba történt a jelmondat hitelesítésekor:"; /* No comment provided by engineer. */ "Error: " = "Hiba: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Hiba: %@"; /* No comment provided by engineer. */ @@ -2343,9 +2343,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Akkor is, ha le van tiltva a beszélgetésben."; -/* No comment provided by engineer. */ -"event happened" = "esemény történt"; - /* No comment provided by engineer. */ "Exit without saving" = "Kilépés mentés nélkül"; @@ -2484,9 +2481,6 @@ /* No comment provided by engineer. */ "For all moderators" = "Az összes moderátor számára"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "a metaadatok jobb védelme érdekében."; - /* servers error */ "For chat profile %@:" = "A(z) %@ nevű csevegési profilhoz:"; @@ -2536,7 +2530,7 @@ "Forwarding %lld messages" = "%lld üzenet továbbítása"; /* No comment provided by engineer. */ -"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %@ továbbítókiszolgáló nem tudott csatlakozni a(z) %@ célkiszolgálóhoz. Próbálja meg később."; +"Forwarding server %@ failed to connect to destination server %@. Please try later." = "A(z) %@ továbbítókiszolgáló nem tudott kapcsolódni a(z) %@ célkiszolgálóhoz. Próbálja meg később."; /* No comment provided by engineer. */ "Forwarding server address is incompatible with network settings: %@." = "A továbbítókiszolgáló címe nem kompatibilis a hálózati beállításokkal: %@."; @@ -2806,7 +2800,7 @@ "incognito via group link" = "inkognitó a csoporthivatkozáson keresztül"; /* chat list item description */ -"incognito via one-time link" = "inkognitó egy egyszer használható meghívási hivatkozáson keresztül"; +"incognito via one-time link" = "inkognitó egy egyszer használható meghívón keresztül"; /* notification */ "Incoming audio call" = "Bejövő hanghívás"; @@ -2932,7 +2926,7 @@ "invited %@" = "meghívta őt: %@"; /* chat list item title */ -"invited to connect" = "meghívta egy partnerét"; +"invited to connect" = "Függőben lévő meghívó"; /* rcv group event chat item */ "invited via your group link" = "meghíva az Ön csoporthivatkozásán keresztül"; @@ -2962,7 +2956,7 @@ "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."; /* 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. Az üzenet visszafejtése sikertelen volt, 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 régebbi adatbázis biztonsági mentést használt.\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."; @@ -3307,7 +3301,7 @@ "Migration complete" = "Átköltöztetés befejezve"; /* No comment provided by engineer. */ -"Migration error:" = "Átköltöztetés hiba:"; +"Migration error:" = "Átköltöztetési hiba:"; /* No comment provided by engineer. */ "Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; @@ -3412,7 +3406,7 @@ "New chat experience 🎉" = "Új csevegési élmény 🎉"; /* notification */ -"New contact request" = "Új kapcsolatkérés"; +"New contact request" = "Új meghívási kérés"; /* notification */ "New contact:" = "Új kapcsolat:"; @@ -3589,8 +3583,8 @@ "observer" = "megfigyelő"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "kikapcsolva"; /* blur media */ @@ -3615,7 +3609,7 @@ "on" = "bekapcsolva"; /* No comment provided by engineer. */ -"One-time invitation link" = "Egyszer használható meghívási hivatkozás"; +"One-time invitation link" = "Egyszer használható meghívó"; /* No comment provided by engineer. */ "Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Onion-kiszolgálók **szükségesek** a kapcsolódáshoz.\nKompatibilis VPN szükséges."; @@ -3914,9 +3908,15 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Az Ön ügyfeleinek adatvédelme."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Adatvédelmi szabályzat és felhasználási feltételek."; + /* No comment provided by engineer. */ "Privacy redefined" = "Adatvédelem újraértelmezve"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára."; + /* No comment provided by engineer. */ "Private filenames" = "Privát fájlnevek"; @@ -4143,14 +4143,14 @@ "Registered" = "Regisztrálva"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Elutasítás"; /* No comment provided by engineer. */ "Reject (sender NOT notified)" = "Elutasítás (a feladó NEM kap értesítést)"; /* No comment provided by engineer. */ -"Reject contact request" = "Kapcsolatkérés elutasítása"; +"Reject contact request" = "Meghívási kérés elutasítása"; /* No comment provided by engineer. */ "rejected" = "elutasítva"; @@ -4207,7 +4207,7 @@ "Renegotiate encryption?" = "Újraegyezteti a titkosítást?"; /* No comment provided by engineer. */ -"Repeat connection request?" = "Megismétli a kapcsolódási kérést?"; +"Repeat connection request?" = "Megismétli a meghívási kérést?"; /* No comment provided by engineer. */ "Repeat download" = "Letöltés ismét"; @@ -4216,7 +4216,7 @@ "Repeat import" = "Importálás ismét"; /* No comment provided by engineer. */ -"Repeat join request?" = "Megismétli a csatlakozási kérést?"; +"Repeat join request?" = "Megismétli a meghívási kérést?"; /* No comment provided by engineer. */ "Repeat upload" = "Feltöltés ismét"; @@ -4255,7 +4255,7 @@ "Reports" = "Jelentések"; /* chat list item title */ -"requested to connect" = "kérelmezve a kapcsolódáshoz"; +"requested to connect" = "Függőben lévő meghívási kérelem"; /* No comment provided by engineer. */ "Required" = "Szükséges"; @@ -4300,7 +4300,7 @@ "Restore database backup?" = "Visszaállítja az adatbázismentést?"; /* No comment provided by engineer. */ -"Restore database error" = "Hiba az adatbázis visszaállításakor"; +"Restore database error" = "Hiba történt az adatbázis visszaállításakor"; /* No comment provided by engineer. */ "Retry" = "Újrapróbálkozás"; @@ -4311,9 +4311,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Feltételek felülvizsgálata"; -/* No comment provided by engineer. */ -"Review later" = "Felülvizsgálat később"; - /* No comment provided by engineer. */ "Revoke" = "Visszavonás"; @@ -4336,7 +4333,7 @@ "Safer groups" = "Biztonságosabb csoportok"; /* alert button - chat item action */ +chat item action */ "Save" = "Mentés"; /* alert button */ @@ -4538,7 +4535,7 @@ "Send receipts" = "Kézbesítési jelentések küldése"; /* No comment provided by engineer. */ -"Send them from gallery or custom keyboards." = "Küldje el őket galériából vagy egyedi billentyűzetekről."; +"Send them from gallery or custom keyboards." = "Küldje el őket a galériából vagy az egyéni billentyűzetekről."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Legfeljebb az utolsó 100 üzenet elküldése az új tagok számára."; @@ -4547,7 +4544,7 @@ "Sender cancelled file transfer." = "A fájl küldője visszavonta az átvitelt."; /* No comment provided by engineer. */ -"Sender may have deleted the connection request." = "A küldő törölhette a kapcsolatkérést."; +"Sender may have deleted the connection request." = "A küldője törölhette a meghívási kérést."; /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára."; @@ -4622,7 +4619,7 @@ "Server operator changed." = "A kiszolgáló üzemeltetője módosult."; /* No comment provided by engineer. */ -"Server operators" = "Kiszolgáló-üzemeltetők"; +"Server operators" = "Kiszolgálóüzemeltetők"; /* alert title */ "Server protocol changed." = "A kiszolgáló-protokoll módosult."; @@ -4712,14 +4709,14 @@ "Shape profile images" = "Profilkép alakzata"; /* alert action - chat item action */ +chat item action */ "Share" = "Megosztás"; /* No comment provided by engineer. */ -"Share 1-time link" = "Egyszer használható meghívási hivatkozás megosztása"; +"Share 1-time link" = "Egyszer használható meghívó megosztása"; /* No comment provided by engineer. */ -"Share 1-time link with a friend" = "Egyszer használható meghívási hivatkozás megosztása egy baráttal"; +"Share 1-time link with a friend" = "Egyszer használható meghívó megosztása egy baráttal"; /* No comment provided by engineer. */ "Share address" = "Cím megosztása"; @@ -4743,7 +4740,7 @@ "Share SimpleX address on social media." = "SimpleX-cím megosztása a közösségi médiában."; /* No comment provided by engineer. */ -"Share this 1-time invite link" = "Ennek az egyszer használható meghívási hivatkozásnak a megosztása"; +"Share this 1-time invite link" = "Ennek az egyszer használható meghívónak a megosztása"; /* No comment provided by engineer. */ "Share to SimpleX" = "Megosztás a SimpleXben"; @@ -4788,10 +4785,10 @@ "SimpleX Address" = "SimpleX-cím"; /* No comment provided by engineer. */ -"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetváltón keresztül."; +"SimpleX address and 1-time links are safe to share via any messenger." = "A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül."; /* No comment provided by engineer. */ -"SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívási hivatkozás?"; +"SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó?"; /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba."; @@ -4830,7 +4827,7 @@ "SimpleX Lock turned on" = "SimpleX-zár bekapcsolva"; /* simplex link type */ -"SimpleX one-time invitation" = "Egyszer használható SimpleX-meghívási hivatkozás"; +"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."; @@ -4878,7 +4875,7 @@ "Somebody" = "Valaki"; /* blocking reason - report reason */ +report reason */ "Spam" = "Kéretlen tartalom"; /* No comment provided by engineer. */ @@ -4966,7 +4963,7 @@ "Switch audio and video during the call." = "Hang/Videó váltása hívás közben."; /* No comment provided by engineer. */ -"Switch chat profile for 1-time invitations." = "Csevegési profilváltás az egyszer használható meghívási hivatkozásokhoz."; +"Switch chat profile for 1-time invitations." = "Csevegési profilváltás az egyszer használható meghívókhoz."; /* No comment provided by engineer. */ "System" = "Rendszer"; @@ -5044,13 +5041,13 @@ "Thank you for installing SimpleX Chat!" = "Köszönjük, hogy telepítette a SimpleX Chatet!"; /* No comment provided by engineer. */ -"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Köszönet a felhasználóknak – [hozzájárulás a Weblate-en keresztül](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Köszönet a felhasználóknak [a Weblate-en való közreműködésért](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; /* No comment provided by engineer. */ -"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak – hozzájárulás a Weblate-en!"; +"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak a Weblate-en való közreműködésért!"; /* No comment provided by engineer. */ -"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – ezt a beállítások menüben engedélyezheti."; +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy meghívási kéréseket kap – ezt a beállítások menüben engedélyezheti."; /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ."; @@ -5109,9 +5106,6 @@ /* 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: **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető(k) számára is: **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "A második előre beállított üzemeltető az alkalmazásban!"; @@ -5179,7 +5173,7 @@ "This group no longer exists." = "Ez a csoport már nem létezik."; /* No comment provided by engineer. */ -"This is your own one-time link!" = "Ez az Ön egyszer használható meghívási hivatkozása!"; +"This is your own one-time link!" = "Ez az Ön egyszer használható meghívója!"; /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Ez az Ön SimpleX-címe!"; @@ -5533,7 +5527,7 @@ "via group link" = "a csoporthivatkozáson keresztül"; /* chat list item description */ -"via one-time link" = "egy egyszer használható meghívási hivatkozáson keresztül"; +"via one-time link" = "egy egyszer használható meghívón keresztül"; /* No comment provided by engineer. */ "via relay" = "egy továbbítókiszolgálón keresztül"; @@ -5725,7 +5719,7 @@ "You are already connecting to %@." = "A kapcsolódás már folyamatban van a következőhöz: %@."; /* No comment provided by engineer. */ -"You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívási hivatkozáson keresztül!"; +"You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívón keresztül!"; /* No comment provided by engineer. */ "You are already in group %@." = "Ön már a(z) %@ nevű csoport tagja."; @@ -5740,7 +5734,7 @@ "You are already joining the group via this link." = "A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül."; /* No comment provided by engineer. */ -"You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a csatlakozási kérést?"; +"You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a meghívási kérést?"; /* No comment provided by engineer. */ "You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; @@ -5766,9 +5760,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben módosíthatja."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Az üzemeltetőket a „Hálózat és kiszolgálók” menüben konfigurálhatja."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja."; @@ -5845,10 +5836,10 @@ "You decide who can connect." = "Ön dönti el, hogy kivel beszélget."; /* No comment provided by engineer. */ -"You have already requested connection via this address!" = "Már küldött egy kapcsolatkérést ezen a címen keresztül!"; +"You have already requested connection via this address!" = "Már küldött egy meghívási kérést ezen a címen keresztül!"; /* No comment provided by engineer. */ -"You have already requested connection!\nRepeat connection request?" = "Ön már küldött egy kapcsolódási kérést!\nMegismétli a kapcsolódási kérést?"; +"You have already requested connection!\nRepeat connection request?" = "Ön már küldött egy meghívási kérést!\nMegismétli a meghívási kérést?"; /* No comment provided by engineer. */ "You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva."; @@ -5890,10 +5881,10 @@ "You sent group invitation" = "Csoportmeghívó elküldve"; /* chat list item description */ -"you shared one-time link" = "Ön egy egyszer használható meghívási hivatkozást osztott meg"; +"you shared one-time link" = "Ön egy egyszer használható meghívót osztott meg"; /* chat list item description */ -"you shared one-time link incognito" = "Ön egy egyszer használható meghívási hivatkozást osztott meg inkognitóban"; +"you shared one-time link incognito" = "Ön egy egyszer használható meghívót osztott meg inkognitóban"; /* token info */ "You should receive notifications." = "Ön megkapja az értesítéseket."; @@ -5908,7 +5899,7 @@ "You will be connected when group link host's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a csoporthivatkozás tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később!"; +"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a meghívási kérése el lesz fogadva, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ "You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később!"; @@ -6015,6 +6006,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Profil SimpleX-címe"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Saját SMP-kiszolgálók"; - diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index d2fe0811ca..53798fe0eb 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -269,7 +269,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 giorno"; /* time interval */ @@ -279,11 +279,11 @@ "1 minute" = "1 minuto"; /* delete after time - time interval */ +time interval */ "1 month" = "1 mese"; /* delete after time - time interval */ +time interval */ "1 week" = "1 settimana"; /* delete after time */ @@ -341,8 +341,8 @@ "Accent" = "Principale"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Accetta"; /* No comment provided by engineer. */ @@ -355,7 +355,7 @@ "Accept contact request from %@?" = "Accettare la richiesta di contatto da %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Accetta in incognito"; /* call status */ @@ -817,7 +817,8 @@ /* rcv group event chat item */ "blocked %@" = "ha bloccato %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "bloccato dall'amministratore"; /* No comment provided by engineer. */ @@ -862,6 +863,9 @@ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Usando SimpleX Chat accetti di:\n- inviare solo contenuto legale nei gruppi pubblici.\n- rispettare gli altri utenti - niente spam."; + /* No comment provided by engineer. */ "call" = "chiama"; @@ -902,7 +906,7 @@ "Can't message member" = "Impossibile inviare un messaggio al membro"; /* alert action - alert button */ +alert button */ "Cancel" = "Annulla"; /* No comment provided by engineer. */ @@ -960,7 +964,7 @@ "Change self-destruct mode" = "Cambia modalità di autodistruzione"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Cambia codice di autodistruzione"; /* chat item text */ @@ -1128,12 +1132,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Condizioni d'uso"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Le condizioni verranno accettate per gli operatori attivati dopo 30 giorni."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Le condizioni verranno accettate per gli operatori: **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Le condizioni verranno accettate per gli operatori: **%@**."; @@ -1146,6 +1144,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "Configura server ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Configura gli operatori dei server"; + /* No comment provided by engineer. */ "Confirm" = "Conferma"; @@ -1555,7 +1556,7 @@ "decryption errors" = "errori di decifrazione"; /* delete after time - pref value */ +pref value */ "default (%@)" = "predefinito (%@)"; /* No comment provided by engineer. */ @@ -1565,7 +1566,7 @@ "default (yes)" = "predefinito (sì)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Elimina"; /* No comment provided by engineer. */ @@ -1881,7 +1882,7 @@ "Downgrade and open chat" = "Esegui downgrade e apri chat"; /* alert button - chat item action */ +chat item action */ "Download" = "Scarica"; /* No comment provided by engineer. */ @@ -1944,9 +1945,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Attiva l'accesso alla fotocamera"; -/* No comment provided by engineer. */ -"Enable Flux" = "Attiva Flux"; - /* No comment provided by engineer. */ "Enable for all" = "Attiva per tutti"; @@ -2325,7 +2323,9 @@ /* No comment provided by engineer. */ "Error: " = "Errore: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Errore: %@"; /* No comment provided by engineer. */ @@ -2343,9 +2343,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Anche quando disattivato nella conversazione."; -/* No comment provided by engineer. */ -"event happened" = "evento accaduto"; - /* No comment provided by engineer. */ "Exit without saving" = "Esci senza salvare"; @@ -2484,9 +2481,6 @@ /* No comment provided by engineer. */ "For all moderators" = "Per tutti i moderatori"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "per una migliore privacy dei metadati."; - /* servers error */ "For chat profile %@:" = "Per il profilo di chat %@:"; @@ -3139,7 +3133,7 @@ "Member" = "Membro"; /* profile update event chat item */ -"member %@ changed to %@" = "membro %1$@ cambiato in %2$@"; +"member %@ changed to %@" = "il membro %1$@ è diventato %2$@"; /* rcv group event chat item */ "member connected" = "si è connesso/a"; @@ -3589,8 +3583,8 @@ "observer" = "osservatore"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "off"; /* blur media */ @@ -3914,9 +3908,15 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy per i tuoi clienti."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Informativa sulla privacy e condizioni d'uso."; + /* No comment provided by engineer. */ "Privacy redefined" = "Privacy ridefinita"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server."; + /* No comment provided by engineer. */ "Private filenames" = "Nomi di file privati"; @@ -4143,7 +4143,7 @@ "Registered" = "Registrato"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Rifiuta"; /* No comment provided by engineer. */ @@ -4311,9 +4311,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Leggi le condizioni"; -/* No comment provided by engineer. */ -"Review later" = "Leggi più tardi"; - /* No comment provided by engineer. */ "Revoke" = "Revoca"; @@ -4336,7 +4333,7 @@ "Safer groups" = "Gruppi più sicuri"; /* alert button - chat item action */ +chat item action */ "Save" = "Salva"; /* alert button */ @@ -4712,7 +4709,7 @@ "Shape profile images" = "Forma delle immagini del profilo"; /* alert action - chat item action */ +chat item action */ "Share" = "Condividi"; /* No comment provided by engineer. */ @@ -4878,7 +4875,7 @@ "Somebody" = "Qualcuno"; /* blocking reason - report reason */ +report reason */ "Spam" = "Spam"; /* No comment provided by engineer. */ @@ -5109,9 +5106,6 @@ /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Le stesse condizioni si applicheranno all'operatore **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Le stesse condizioni si applicheranno agli operatori **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Il secondo operatore preimpostato nell'app!"; @@ -5766,9 +5760,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Puoi cambiarlo nelle impostazioni dell'aspetto."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Puoi configurare gli operatori nelle impostazioni di rete e server."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Puoi configurare i server nelle impostazioni."; @@ -6015,6 +6006,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Il tuo indirizzo SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "I tuoi server SMP"; - diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 9ef8c02a0e..89934d67ce 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -266,7 +266,7 @@ "0s" = "0秒"; /* delete after time - time interval */ +time interval */ "1 day" = "1日"; /* time interval */ @@ -276,11 +276,11 @@ "1 minute" = "1分"; /* delete after time - time interval */ +time interval */ "1 month" = "1ヶ月"; /* delete after time - time interval */ +time interval */ "1 week" = "1週間"; /* No comment provided by engineer. */ @@ -329,8 +329,8 @@ "above, then choose:" = "上で選んでください:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "承諾"; /* No comment provided by engineer. */ @@ -340,7 +340,7 @@ "Accept contact request from %@?" = "%@ からの連絡要求を受け入れますか?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "シークレットモードで承諾"; /* call status */ @@ -647,7 +647,7 @@ "Can't invite contacts!" = "連絡先を招待できません!"; /* alert action - alert button */ +alert button */ "Cancel" = "中止"; /* feature offered item */ @@ -687,7 +687,7 @@ "Change self-destruct mode" = "自己破壊モードの変更"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "自己破壊パスコードを変更する"; /* chat item text */ @@ -1066,7 +1066,7 @@ "Decryption error" = "復号化エラー"; /* delete after time - pref value */ +pref value */ "default (%@)" = "デフォルト (%@)"; /* No comment provided by engineer. */ @@ -1076,7 +1076,7 @@ "default (yes)" = "デフォルト(はい)"; /* alert action - swipe action */ +swipe action */ "Delete" = "削除"; /* No comment provided by engineer. */ @@ -1565,7 +1565,9 @@ /* No comment provided by engineer. */ "Error: " = "エラー : "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "エラー : %@"; /* No comment provided by engineer. */ @@ -1577,9 +1579,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "会話中に無効になっている場合でも。"; -/* No comment provided by engineer. */ -"event happened" = "イベント発生"; - /* No comment provided by engineer. */ "Exit without saving" = "保存せずに閉じる"; @@ -2292,8 +2291,8 @@ "observer" = "オブザーバー"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "オフ"; /* blur media */ @@ -2600,7 +2599,7 @@ "Reduced battery usage" = "電池使用量低減"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "拒否"; /* No comment provided by engineer. */ @@ -2700,7 +2699,7 @@ "Run chat" = "チャット起動"; /* alert button - chat item action */ +chat item action */ "Save" = "保存"; /* alert button */ @@ -2887,7 +2886,7 @@ "Settings" = "設定"; /* alert action - chat item action */ +chat item action */ "Share" = "共有する"; /* No comment provided by engineer. */ @@ -3607,6 +3606,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "あなたのSimpleXアドレス"; -/* No comment provided by engineer. */ -"Your SMP servers" = "あなたのSMPサーバ"; - diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index e2bdd06018..f9e289369e 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -269,7 +269,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 dag"; /* time interval */ @@ -279,11 +279,11 @@ "1 minute" = "1 minuut"; /* delete after time - time interval */ +time interval */ "1 month" = "1 maand"; /* delete after time - time interval */ +time interval */ "1 week" = "1 week"; /* delete after time */ @@ -341,8 +341,8 @@ "Accent" = "Accent"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Accepteer"; /* No comment provided by engineer. */ @@ -355,7 +355,7 @@ "Accept contact request from %@?" = "Accepteer contactverzoek van %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Accepteer incognito"; /* call status */ @@ -817,7 +817,8 @@ /* rcv group event chat item */ "blocked %@" = "blokkeerde %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "geblokkeerd door beheerder"; /* No comment provided by engineer. */ @@ -902,7 +903,7 @@ "Can't message member" = "Kan geen bericht sturen naar lid"; /* alert action - alert button */ +alert button */ "Cancel" = "Annuleren"; /* No comment provided by engineer. */ @@ -960,7 +961,7 @@ "Change self-destruct mode" = "Zelfvernietigings modus wijzigen"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Zelfvernietigings code wijzigen"; /* chat item text */ @@ -1128,12 +1129,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Gebruiksvoorwaarden"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Voor ingeschakelde operators worden de voorwaarden na 30 dagen geaccepteerd."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Voorwaarden worden geaccepteerd voor operator(s): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Voorwaarden worden geaccepteerd voor de operator(s): **%@**."; @@ -1555,7 +1550,7 @@ "decryption errors" = "decoderingsfouten"; /* delete after time - pref value */ +pref value */ "default (%@)" = "standaard (%@)"; /* No comment provided by engineer. */ @@ -1565,7 +1560,7 @@ "default (yes)" = "standaard (ja)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Verwijderen"; /* No comment provided by engineer. */ @@ -1869,7 +1864,7 @@ "Don't enable" = "Niet inschakelen"; /* No comment provided by engineer. */ -"Don't miss important messages." = "‐Mis geen belangrijke berichten."; +"Don't miss important messages." = "Mis geen belangrijke berichten."; /* No comment provided by engineer. */ "Don't show again" = "Niet meer weergeven"; @@ -1881,7 +1876,7 @@ "Downgrade and open chat" = "Downgraden en chat openen"; /* alert button - chat item action */ +chat item action */ "Download" = "Downloaden"; /* No comment provided by engineer. */ @@ -1944,9 +1939,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Schakel cameratoegang in"; -/* No comment provided by engineer. */ -"Enable Flux" = "Flux inschakelen"; - /* No comment provided by engineer. */ "Enable for all" = "Inschakelen voor iedereen"; @@ -2164,7 +2156,7 @@ "Error creating profile!" = "Fout bij aanmaken van profiel!"; /* No comment provided by engineer. */ -"Error creating report" = "Fout bij maken van rapport"; +"Error creating report" = "Fout bij het rapporteren"; /* No comment provided by engineer. */ "Error decrypting file" = "Fout bij het ontsleutelen van bestand"; @@ -2325,7 +2317,9 @@ /* No comment provided by engineer. */ "Error: " = "Fout: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Fout: %@"; /* No comment provided by engineer. */ @@ -2343,9 +2337,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Zelfs wanneer uitgeschakeld in het gesprek."; -/* No comment provided by engineer. */ -"event happened" = "gebeurtenis gebeurd"; - /* No comment provided by engineer. */ "Exit without saving" = "Afsluiten zonder opslaan"; @@ -2484,9 +2475,6 @@ /* No comment provided by engineer. */ "For all moderators" = "Voor alle moderators"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "voor betere privacy van metagegevens."; - /* servers error */ "For chat profile %@:" = "Voor chatprofiel %@:"; @@ -3589,8 +3577,8 @@ "observer" = "Waarnemer"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "uit"; /* blur media */ @@ -3800,9 +3788,15 @@ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "In behandeling"; + /* No comment provided by engineer. */ "Pending" = "in behandeling"; +/* No comment provided by engineer. */ +"pending approval" = "in afwachting van goedkeuring"; + /* No comment provided by engineer. */ "Periodic" = "Periodiek"; @@ -4137,7 +4131,7 @@ "Registered" = "Geregistreerd"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Afwijzen"; /* No comment provided by engineer. */ @@ -4146,6 +4140,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Contactverzoek afwijzen"; +/* No comment provided by engineer. */ +"rejected" = "afgewezen"; + /* call status */ "rejected call" = "geweigerde oproep"; @@ -4216,7 +4213,7 @@ "Reply" = "Antwoord"; /* chat item action */ -"Report" = "Rapport"; +"Report" = "rapporteren"; /* report reason */ "Report content: only group moderators will see it." = "Inhoud melden: alleen groepsmoderators kunnen dit zien."; @@ -4237,7 +4234,7 @@ "Report violation: only group moderators will see it." = "Rapporteer overtreding: alleen groepsmoderators kunnen dit zien."; /* report in notification */ -"Report: %@" = "Rapport: %@"; +"Report: %@" = "rapporteer: %@"; /* No comment provided by engineer. */ "Reporting messages to moderators is prohibited." = "Het is niet toegestaan om berichten aan moderators te melden."; @@ -4302,9 +4299,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Voorwaarden bekijken"; -/* No comment provided by engineer. */ -"Review later" = "Later beoordelen"; - /* No comment provided by engineer. */ "Revoke" = "Intrekken"; @@ -4327,7 +4321,7 @@ "Safer groups" = "Veiligere groepen"; /* alert button - chat item action */ +chat item action */ "Save" = "Opslaan"; /* alert button */ @@ -4520,7 +4514,7 @@ "Send notifications" = "Meldingen verzenden"; /* No comment provided by engineer. */ -"Send private reports" = "Verstuur rapporten privé"; +"Send private reports" = "Rapporteer privé"; /* No comment provided by engineer. */ "Send questions and ideas" = "Stuur vragen en ideeën"; @@ -4703,7 +4697,7 @@ "Shape profile images" = "Vorm profiel afbeeldingen"; /* alert action - chat item action */ +chat item action */ "Share" = "Deel"; /* No comment provided by engineer. */ @@ -4869,7 +4863,7 @@ "Somebody" = "Iemand"; /* blocking reason - report reason */ +report reason */ "Spam" = "Spam"; /* No comment provided by engineer. */ @@ -5100,9 +5094,6 @@ /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dezelfde voorwaarden gelden voor operator **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Dezelfde voorwaarden gelden voor operator(s): **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "De tweede vooraf ingestelde operator in de app!"; @@ -5382,6 +5373,9 @@ /* No comment provided by engineer. */ "Update settings?" = "Instellingen actualiseren?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Bijgewerkte voorwaarden"; + /* rcv group event chat item */ "updated group profile" = "bijgewerkt groep profiel"; @@ -5754,9 +5748,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "U kunt dit wijzigen in de instellingen onder uiterlijk."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "U kunt operators configureren in Netwerk- en serverinstellingen."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "U kunt servers configureren via instellingen."; @@ -6003,6 +5994,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Uw SimpleX adres"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Uw SMP servers"; - diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 72a9a538c6..82730db52a 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -269,7 +269,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 dzień"; /* time interval */ @@ -279,11 +279,11 @@ "1 minute" = "1 minuta"; /* delete after time - time interval */ +time interval */ "1 month" = "1 miesiąc"; /* delete after time - time interval */ +time interval */ "1 week" = "1 tydzień"; /* delete after time */ @@ -341,8 +341,8 @@ "Accent" = "Akcent"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Akceptuj"; /* No comment provided by engineer. */ @@ -355,7 +355,7 @@ "Accept contact request from %@?" = "Zaakceptuj prośbę o kontakt od %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Akceptuj incognito"; /* call status */ @@ -805,7 +805,8 @@ /* rcv group event chat item */ "blocked %@" = "zablokowany %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "zablokowany przez admina"; /* No comment provided by engineer. */ @@ -890,7 +891,7 @@ "Can't message member" = "Nie można wysłać wiadomości do członka"; /* alert action - alert button */ +alert button */ "Cancel" = "Anuluj"; /* No comment provided by engineer. */ @@ -945,7 +946,7 @@ "Change self-destruct mode" = "Zmień tryb samozniszczenia"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Zmień pin samozniszczenia"; /* chat item text */ @@ -1104,9 +1105,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Warunki użytkowania"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Warunki zostaną zaakceptowane dla aktywowanych operatorów po 30 dniach."; - /* No comment provided by engineer. */ "Configure ICE servers" = "Skonfiguruj serwery ICE"; @@ -1486,7 +1484,7 @@ "decryption errors" = "błąd odszyfrowywania"; /* delete after time - pref value */ +pref value */ "default (%@)" = "domyślne (%@)"; /* No comment provided by engineer. */ @@ -1496,7 +1494,7 @@ "default (yes)" = "domyślnie (tak)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Usuń"; /* No comment provided by engineer. */ @@ -1773,7 +1771,7 @@ "Downgrade and open chat" = "Obniż wersję i otwórz czat"; /* alert button - chat item action */ +chat item action */ "Download" = "Pobierz"; /* No comment provided by engineer. */ @@ -2172,7 +2170,9 @@ /* No comment provided by engineer. */ "Error: " = "Błąd: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Błąd: %@"; /* No comment provided by engineer. */ @@ -2187,9 +2187,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Nawet po wyłączeniu w rozmowie."; -/* No comment provided by engineer. */ -"event happened" = "nowe wydarzenie"; - /* No comment provided by engineer. */ "Exit without saving" = "Wyjdź bez zapisywania"; @@ -3241,8 +3238,8 @@ "observer" = "obserwator"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "wyłączony"; /* blur media */ @@ -3720,7 +3717,7 @@ "Reduced battery usage" = "Zmniejszone zużycie baterii"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Odrzuć"; /* No comment provided by engineer. */ @@ -3871,7 +3868,7 @@ "Safer groups" = "Bezpieczniejsze grupy"; /* alert button - chat item action */ +chat item action */ "Save" = "Zapisz"; /* alert button */ @@ -4223,7 +4220,7 @@ "Shape profile images" = "Kształtuj obrazy profilowe"; /* alert action - chat item action */ +chat item action */ "Share" = "Udostępnij"; /* No comment provided by engineer. */ @@ -5384,6 +5381,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Twój adres SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Twoje serwery SMP"; - diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index c8b971e21d..f86b3aa4fd 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -139,12 +139,6 @@ /* format for date separator in chat */ "%@, %@" = "%1$@, %2$@"; -/* No comment provided by engineer. */ -"%@, %@ and %lld members" = "%@, %@ и %lld членов группы"; - -/* No comment provided by engineer. */ -"%@, %@ and %lld other members connected" = "%@, %@ и %lld других членов соединены"; - /* copied message info */ "%@:" = "%@:"; @@ -202,9 +196,6 @@ /* No comment provided by engineer. */ "%lld group events" = "%lld событий"; -/* No comment provided by engineer. */ -"%lld members" = "Членов группы: %lld"; - /* No comment provided by engineer. */ "%lld messages blocked" = "%lld сообщений заблокировано"; @@ -269,7 +260,7 @@ "0s" = "0с"; /* delete after time - time interval */ +time interval */ "1 day" = "1 день"; /* time interval */ @@ -279,11 +270,11 @@ "1 minute" = "1 минута"; /* delete after time - time interval */ +time interval */ "1 month" = "1 месяц"; /* delete after time - time interval */ +time interval */ "1 week" = "1 неделю"; /* delete after time */ @@ -316,9 +307,6 @@ /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "Отдельное TCP-соединение будет использоваться **для каждого профиля чата, который Вы имеете в приложении**."; -/* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Отдельное TCP-соединение (и авторизация SOCKS) будет использоваться **для каждого контакта и члена группы**.\n**Обратите внимание**: если у Вас много контактов, потребление батареи и трафика может быть значительно выше, и некоторые соединения могут не работать."; - /* No comment provided by engineer. */ "Abort" = "Прекратить"; @@ -341,8 +329,8 @@ "Accent" = "Акцент"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Принять"; /* No comment provided by engineer. */ @@ -355,7 +343,7 @@ "Accept contact request from %@?" = "Принять запрос на соединение от %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Принять инкогнито"; /* call status */ @@ -445,9 +433,6 @@ /* feature role */ "admins" = "админы"; -/* No comment provided by engineer. */ -"Admins can block a member for all." = "Админы могут заблокировать члена группы."; - /* No comment provided by engineer. */ "Admins can create the links to join groups." = "Админы могут создать ссылки для вступления в группу."; @@ -481,12 +466,6 @@ /* No comment provided by engineer. */ "All data is kept private on your device." = "Все данные хранятся только на вашем устройстве."; -/* 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 шифрованием**, с постквантовой безопасностью в прямых разговорах."; @@ -538,9 +517,6 @@ /* No comment provided by engineer. */ "Allow message reactions." = "Разрешить реакции на сообщения."; -/* No comment provided by engineer. */ -"Allow sending direct messages to members." = "Разрешить посылать прямые сообщения членам группы."; - /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Разрешить посылать исчезающие сообщения."; @@ -799,25 +775,14 @@ /* No comment provided by engineer. */ "Block for all" = "Заблокировать для всех"; -/* No comment provided by engineer. */ -"Block group members" = "Блокируйте членов группы"; - -/* No comment provided by engineer. */ -"Block member" = "Заблокировать члена группы"; - -/* No comment provided by engineer. */ -"Block member for all?" = "Заблокировать члена для всех?"; - -/* No comment provided by engineer. */ -"Block member?" = "Заблокировать члена группы?"; - /* marked deleted chat item preview text */ "blocked" = "заблокировано"; /* rcv group event chat item */ "blocked %@" = "%@ заблокирован"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "заблокировано администратором"; /* No comment provided by engineer. */ @@ -862,6 +827,9 @@ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "По профилю чата или [по соединению](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Используя SimpleX Chat, Вы согласны:\n- отправлять только законные сообщения в публичных группах.\n- уважать других пользователей – не отправлять спам."; + /* No comment provided by engineer. */ "call" = "звонок"; @@ -889,20 +857,14 @@ /* No comment provided by engineer. */ "Can't call contact" = "Не удается позвонить контакту"; -/* No comment provided by engineer. */ -"Can't call member" = "Не удается позвонить члену группы"; - /* No comment provided by engineer. */ "Can't invite contact!" = "Нельзя пригласить контакт!"; /* No comment provided by engineer. */ "Can't invite contacts!" = "Нельзя пригласить контакты!"; -/* No comment provided by engineer. */ -"Can't message member" = "Не удается написать члену группы"; - /* alert action - alert button */ +alert button */ "Cancel" = "Отменить"; /* No comment provided by engineer. */ @@ -941,9 +903,6 @@ /* authentication reason */ "Change lock mode" = "Изменить режим блокировки"; -/* No comment provided by engineer. */ -"Change member role?" = "Поменять роль члена группы?"; - /* authentication reason */ "Change passcode" = "Изменить код доступа"; @@ -960,7 +919,7 @@ "Change self-destruct mode" = "Изменить режим самоуничтожения"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Изменить код самоуничтожения"; /* chat item text */ @@ -1128,12 +1087,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Условия использования"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Условия будут приняты для включенных операторов через 30 дней."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Условия будут приняты для оператора(ов): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Условия будут приняты для оператора(ов): **%@**."; @@ -1146,6 +1099,9 @@ /* No comment provided by engineer. */ "Configure ICE servers" = "Настройка ICE серверов"; +/* No comment provided by engineer. */ +"Configure server operators" = "Настроить операторов серверов"; + /* No comment provided by engineer. */ "Confirm" = "Подтвердить"; @@ -1555,7 +1511,7 @@ "decryption errors" = "ошибки расшифровки"; /* delete after time - pref value */ +pref value */ "default (%@)" = "по умолчанию (%@)"; /* No comment provided by engineer. */ @@ -1565,12 +1521,9 @@ "default (yes)" = "по умолчанию (да)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Удалить"; -/* No comment provided by engineer. */ -"Delete %lld messages of members?" = "Удалить %lld сообщений членов группы?"; - /* No comment provided by engineer. */ "Delete %lld messages?" = "Удалить %lld сообщений?"; @@ -1784,12 +1737,6 @@ /* chat feature */ "Direct messages" = "Прямые сообщения"; -/* 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)" = "Выключить (кроме исключений)"; @@ -1844,9 +1791,6 @@ /* 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." = "Не отправлять сообщения напрямую, даже если сервер получателя не поддерживает конфиденциальную доставку."; @@ -1881,7 +1825,7 @@ "Downgrade and open chat" = "Откатить версию и открыть чат"; /* alert button - chat item action */ +chat item action */ "Download" = "Загрузить"; /* No comment provided by engineer. */ @@ -1944,9 +1888,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Включить доступ к камере"; -/* No comment provided by engineer. */ -"Enable Flux" = "Включить Flux"; - /* No comment provided by engineer. */ "Enable for all" = "Включить для всех"; @@ -2115,9 +2056,6 @@ /* No comment provided by engineer. */ "Error accepting contact request" = "Ошибка при принятии запроса на соединение"; -/* No comment provided by engineer. */ -"Error adding member(s)" = "Ошибка при добавлении членов группы"; - /* alert title */ "Error adding server" = "Ошибка добавления сервера"; @@ -2154,9 +2092,6 @@ /* alert title */ "Error creating list" = "Ошибка создания списка"; -/* No comment provided by engineer. */ -"Error creating member contact" = "Ошибка создания контакта с членом группы"; - /* No comment provided by engineer. */ "Error creating message" = "Ошибка создания сообщения"; @@ -2235,9 +2170,6 @@ /* alert title */ "Error registering for notifications" = "Ошибка регистрации для уведомлений"; -/* No comment provided by engineer. */ -"Error removing member" = "Ошибка при удалении члена группы"; - /* alert title */ "Error reordering lists" = "Ошибка сортировки списков"; @@ -2274,9 +2206,6 @@ /* 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" = "Ошибка при отправке сообщения"; @@ -2325,7 +2254,9 @@ /* No comment provided by engineer. */ "Error: " = "Ошибка: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Ошибка: %@"; /* No comment provided by engineer. */ @@ -2343,9 +2274,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Даже когда они выключены в разговоре."; -/* No comment provided by engineer. */ -"event happened" = "событие произошло"; - /* No comment provided by engineer. */ "Exit without saving" = "Выйти без сохранения"; @@ -2478,15 +2406,9 @@ /* 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" = "Для всех модераторов"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "для лучшей конфиденциальности метаданных."; - /* servers error */ "For chat profile %@:" = "Для профиля чата %@:"; @@ -2562,9 +2484,6 @@ /* 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!" = "Полностью обновлены - работают в фоне!"; @@ -2631,18 +2550,12 @@ /* 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!" = "Группа будет удалена для Вас - это действие нельзя отменить!"; @@ -2679,9 +2592,6 @@ /* No comment provided by engineer. */ "History" = "История"; -/* No comment provided by engineer. */ -"History is not sent to new members." = "История не отправляется новым членам."; - /* time unit */ "hours" = "часов"; @@ -2916,9 +2826,6 @@ /* No comment provided by engineer. */ "Invite friends" = "Пригласить друзей"; -/* No comment provided by engineer. */ -"Invite members" = "Пригласить членов группы"; - /* No comment provided by engineer. */ "Invite to chat" = "Пригласить в разговор"; @@ -3132,66 +3039,15 @@ /* blur media */ "Medium" = "Среднее"; -/* member role */ -"member" = "член группы"; - -/* No comment provided by engineer. */ -"Member" = "Член группы"; - -/* profile update event chat item */ -"member %@ changed to %@" = "член %1$@ изменился на %2$@"; - /* rcv group event chat item */ "member connected" = "соединен(а)"; -/* 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!" = "Член группы будет удален - это действие нельзя отменить!"; - -/* 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" = "Меню"; @@ -3213,9 +3069,6 @@ /* 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" = "Информация об очереди сообщений"; @@ -3432,9 +3285,6 @@ /* No comment provided by engineer. */ "New media options" = "Новые медиа-опции"; -/* No comment provided by engineer. */ -"New member role" = "Роль члена группы"; - /* notification */ "new message" = "новое сообщение"; @@ -3582,15 +3432,12 @@ /* 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" = "читатель"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "нет"; /* blur media */ @@ -3782,9 +3629,6 @@ /* No comment provided by engineer. */ "Password to show" = "Пароль чтобы раскрыть"; -/* past/unknown group member */ -"Past member %@" = "Бывший член %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Вставить адрес компьютера"; @@ -3914,9 +3758,15 @@ /* No comment provided by engineer. */ "Privacy for your customers." = "Конфиденциальность для ваших покупателей."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Политика конфиденциальности и условия использования."; + /* No comment provided by engineer. */ "Privacy redefined" = "Более конфиденциальный"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Частные разговоры, группы и Ваши контакты недоступны для операторов серверов."; + /* No comment provided by engineer. */ "Private filenames" = "Защищенные имена файлов"; @@ -3971,9 +3821,6 @@ /* 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." = "Запретить посылать исчезающие сообщения."; @@ -4143,7 +3990,7 @@ "Registered" = "Зарегистрирован"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Отклонить"; /* No comment provided by engineer. */ @@ -4173,12 +4020,6 @@ /* 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?"; @@ -4311,9 +4152,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Посмотреть условия"; -/* No comment provided by engineer. */ -"Review later" = "Посмотреть позже"; - /* No comment provided by engineer. */ "Revoke" = "Отозвать"; @@ -4336,7 +4174,7 @@ "Safer groups" = "Более безопасные группы"; /* alert button - chat item action */ +chat item action */ "Save" = "Сохранить"; /* alert button */ @@ -4345,9 +4183,6 @@ /* alert button */ "Save and notify contact" = "Сохранить и уведомить контакт"; -/* No comment provided by engineer. */ -"Save and notify group members" = "Сохранить и уведомить членов группы"; - /* No comment provided by engineer. */ "Save and reconnect" = "Сохранить и переподключиться"; @@ -4540,9 +4375,6 @@ /* 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." = "Отправитель отменил передачу файла."; @@ -4696,9 +4528,6 @@ /* 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"; @@ -4712,7 +4541,7 @@ "Shape profile images" = "Форма картинок профилей"; /* alert action - chat item action */ +chat item action */ "Share" = "Поделиться"; /* No comment provided by engineer. */ @@ -4878,7 +4707,7 @@ "Somebody" = "Контакт"; /* blocking reason - report reason */ +report reason */ "Spam" = "Спам"; /* No comment provided by engineer. */ @@ -5088,18 +4917,6 @@ /* 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." = "Предыдущая версия данных чата не удалена при перемещении, её можно удалить."; @@ -5109,9 +4926,6 @@ /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Те же самые условия будут приняты для оператора **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Те же самые условия будут приняты для оператора(ов): **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Второй оператор серверов в приложении!"; @@ -5172,9 +4986,6 @@ /* 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." = "Эта группа больше не существует."; @@ -5295,15 +5106,6 @@ /* 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 %@" = "%@ разблокирован"; @@ -5376,9 +5178,6 @@ /* swipe action */ "Unread" = "Не прочитано"; -/* No comment provided by engineer. */ -"Up to 100 last messages are sent to new members." = "До 100 последних сообщений отправляются новым членам."; - /* No comment provided by engineer. */ "Update" = "Обновить"; @@ -5766,9 +5565,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Вы можете изменить это в настройках Интерфейса."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Вы можете настроить операторов в настройках Сети и серверов."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Вы можете настроить серверы позже."; @@ -5802,9 +5598,6 @@ /* 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 **%@**." = "Вы можете поделиться этим адресом с Вашими контактами, чтобы они могли соединиться с **%@**."; @@ -5859,9 +5652,6 @@ /* 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" = "Вы покинули группу"; @@ -5916,9 +5706,6 @@ /* 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." = "Вы все равно получите звонки и уведомления в профилях без звука, когда они активные."; @@ -6015,6 +5802,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Ваш адрес SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Ваши SMP серверы"; - diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index abe1632645..6b3381922a 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -164,7 +164,7 @@ "0s" = "0s"; /* delete after time - time interval */ +time interval */ "1 day" = "1 วัน"; /* time interval */ @@ -174,11 +174,11 @@ "1 minute" = "1 นาที"; /* delete after time - time interval */ +time interval */ "1 month" = "1 เดือน"; /* delete after time - time interval */ +time interval */ "1 week" = "1 สัปดาห์"; /* No comment provided by engineer. */ @@ -218,15 +218,15 @@ "above, then choose:" = "ด้านบน จากนั้นเลือก:"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "รับ"; /* notification body */ "Accept contact request from %@?" = "รับการขอติดต่อจาก %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "ยอมรับโหมดไม่ระบุตัวตน"; /* call status */ @@ -485,7 +485,7 @@ "Can't invite contacts!" = "ไม่สามารถเชิญผู้ติดต่อได้!"; /* alert action - alert button */ +alert button */ "Cancel" = "ยกเลิก"; /* feature offered item */ @@ -525,7 +525,7 @@ "Change self-destruct mode" = "เปลี่ยนโหมดทําลายตัวเอง"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "เปลี่ยนรหัสผ่านแบบทำลายตัวเอง"; /* chat item text */ @@ -835,7 +835,7 @@ "Decryption error" = "ข้อผิดพลาดในการ decrypt"; /* delete after time - pref value */ +pref value */ "default (%@)" = "ค่าเริ่มต้น (%@)"; /* No comment provided by engineer. */ @@ -845,7 +845,7 @@ "default (yes)" = "ค่าเริ่มต้น (ใช่)"; /* alert action - swipe action */ +swipe action */ "Delete" = "ลบ"; /* No comment provided by engineer. */ @@ -1310,7 +1310,9 @@ /* No comment provided by engineer. */ "Error: " = "ผิดพลาด: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "ข้อผิดพลาด: % @"; /* No comment provided by engineer. */ @@ -2013,8 +2015,8 @@ "observer" = "ผู้สังเกตการณ์"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "ปิด"; /* blur media */ @@ -2315,7 +2317,7 @@ "Reduced battery usage" = "ลดการใช้แบตเตอรี่"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "ปฏิเสธ"; /* No comment provided by engineer. */ @@ -2412,7 +2414,7 @@ "Run chat" = "เรียกใช้แชท"; /* alert button - chat item action */ +chat item action */ "Save" = "บันทึก"; /* alert button */ @@ -2614,7 +2616,7 @@ "Settings" = "การตั้งค่า"; /* alert action - chat item action */ +chat item action */ "Share" = "แชร์"; /* No comment provided by engineer. */ @@ -3307,6 +3309,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "ที่อยู่ SimpleX ของคุณ"; -/* No comment provided by engineer. */ -"Your SMP servers" = "เซิร์ฟเวอร์ SMP ของคุณ"; - diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index 88b2ac401e..718d547c67 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -266,7 +266,7 @@ "0s" = "0sn"; /* delete after time - time interval */ +time interval */ "1 day" = "1 gün"; /* time interval */ @@ -276,11 +276,11 @@ "1 minute" = "1 dakika"; /* delete after time - time interval */ +time interval */ "1 month" = "1 ay"; /* delete after time - time interval */ +time interval */ "1 week" = "1 hafta"; /* No comment provided by engineer. */ @@ -335,8 +335,8 @@ "Accent" = "Ana renk"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Kabul et"; /* No comment provided by engineer. */ @@ -349,7 +349,7 @@ "Accept contact request from %@?" = "%@ 'den gelen iletişim isteği kabul edilsin mi?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Takma adla kabul et"; /* call status */ @@ -751,7 +751,8 @@ /* rcv group event chat item */ "blocked %@" = "engellendi %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "yönetici tarafından engellendi"; /* No comment provided by engineer. */ @@ -833,7 +834,7 @@ "Can't message member" = "Üyeye mesaj gönderilemiyor"; /* alert action - alert button */ +alert button */ "Cancel" = "İptal et"; /* No comment provided by engineer. */ @@ -888,7 +889,7 @@ "Change self-destruct mode" = "Kendini yok etme modunu değiştir"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Kendini yok eden parolayı değiştir"; /* chat item text */ @@ -1047,12 +1048,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Kullanım koşulları"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Koşullar 30 gün sonra etkin operatörler tarafından kabul edilecektir."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Koşullar operatör(ler) için kabul edilecektir: **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Koşullar bu operatör(ler) için kabul edilecektir: **%@**."; @@ -1453,7 +1448,7 @@ "decryption errors" = "Şifre çözme hataları"; /* delete after time - pref value */ +pref value */ "default (%@)" = "varsayılan (%@)"; /* No comment provided by engineer. */ @@ -1463,7 +1458,7 @@ "default (yes)" = "varsayılan (evet)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Sil"; /* No comment provided by engineer. */ @@ -1752,7 +1747,7 @@ "Downgrade and open chat" = "Sürüm düşür ve sohbeti aç"; /* alert button - chat item action */ +chat item action */ "Download" = "İndir"; /* No comment provided by engineer. */ @@ -1815,9 +1810,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Kamera erişimini etkinleştir"; -/* No comment provided by engineer. */ -"Enable Flux" = "Flux'u Etkinleştir"; - /* No comment provided by engineer. */ "Enable for all" = "Herkes için etkinleştir"; @@ -2172,7 +2164,9 @@ /* No comment provided by engineer. */ "Error: " = "Hata: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Hata: %@"; /* No comment provided by engineer. */ @@ -2190,9 +2184,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Konuşma sırasında devre dışı bırakılsa bile."; -/* No comment provided by engineer. */ -"event happened" = "etkinlik yaşandı"; - /* No comment provided by engineer. */ "Exit without saving" = "Kaydetmeden çık"; @@ -3280,8 +3271,8 @@ "observer" = "gözlemci"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "kapalı"; /* blur media */ @@ -3759,7 +3750,7 @@ "Reduced battery usage" = "Azaltılmış pil kullanımı"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Reddet"; /* No comment provided by engineer. */ @@ -3910,7 +3901,7 @@ "Safer groups" = "Daha güvenli gruplar"; /* alert button - chat item action */ +chat item action */ "Save" = "Kaydet"; /* alert button */ @@ -4262,7 +4253,7 @@ "Shape profile images" = "Profil resimlerini şekillendir"; /* alert action - chat item action */ +chat item action */ "Share" = "Paylaş"; /* No comment provided by engineer. */ @@ -5429,6 +5420,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "SimpleX adresin"; -/* No comment provided by engineer. */ -"Your SMP servers" = "SMP sunucularınız"; - diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index ceb8ee0bfa..f54ecec21d 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -266,7 +266,7 @@ "0s" = "0с"; /* delete after time - time interval */ +time interval */ "1 day" = "1 день"; /* time interval */ @@ -276,11 +276,11 @@ "1 minute" = "1 хвилина"; /* delete after time - time interval */ +time interval */ "1 month" = "1 місяць"; /* delete after time - time interval */ +time interval */ "1 week" = "1 тиждень"; /* No comment provided by engineer. */ @@ -335,8 +335,8 @@ "Accent" = "Акцент"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "Прийняти"; /* No comment provided by engineer. */ @@ -349,7 +349,7 @@ "Accept contact request from %@?" = "Прийняти запит на контакт від %@?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "Прийняти інкогніто"; /* call status */ @@ -754,7 +754,8 @@ /* rcv group event chat item */ "blocked %@" = "заблоковано %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "заблоковано адміністратором"; /* No comment provided by engineer. */ @@ -836,7 +837,7 @@ "Can't message member" = "Не можу надіслати повідомлення користувачеві"; /* alert action - alert button */ +alert button */ "Cancel" = "Скасувати"; /* No comment provided by engineer. */ @@ -891,7 +892,7 @@ "Change self-destruct mode" = "Змінити режим самознищення"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "Змінити пароль самознищення"; /* chat item text */ @@ -1050,12 +1051,6 @@ /* No comment provided by engineer. */ "Conditions of use" = "Умови використання"; -/* No comment provided by engineer. */ -"Conditions will be accepted for enabled operators after 30 days." = "Умови будуть прийняті для ввімкнених операторів через 30 днів."; - -/* No comment provided by engineer. */ -"Conditions will be accepted for operator(s): **%@**." = "Умови приймаються для оператора(ів): **%@**."; - /* No comment provided by engineer. */ "Conditions will be accepted for the operator(s): **%@**." = "Для оператора(ів) приймаються умови: **%@**."; @@ -1456,7 +1451,7 @@ "decryption errors" = "помилки розшифровки"; /* delete after time - pref value */ +pref value */ "default (%@)" = "за замовчуванням (%@)"; /* No comment provided by engineer. */ @@ -1466,7 +1461,7 @@ "default (yes)" = "за замовчуванням (так)"; /* alert action - swipe action */ +swipe action */ "Delete" = "Видалити"; /* No comment provided by engineer. */ @@ -1758,7 +1753,7 @@ "Downgrade and open chat" = "Пониження та відкритий чат"; /* alert button - chat item action */ +chat item action */ "Download" = "Завантажити"; /* No comment provided by engineer. */ @@ -1821,9 +1816,6 @@ /* No comment provided by engineer. */ "Enable camera access" = "Увімкніть доступ до камери"; -/* No comment provided by engineer. */ -"Enable Flux" = "Увімкнути Flux"; - /* No comment provided by engineer. */ "Enable for all" = "Увімкнути для всіх"; @@ -2178,7 +2170,9 @@ /* No comment provided by engineer. */ "Error: " = "Помилка: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "Помилка: %@"; /* No comment provided by engineer. */ @@ -2196,9 +2190,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Навіть коли вимкнений у розмові."; -/* No comment provided by engineer. */ -"event happened" = "відбулася подія"; - /* No comment provided by engineer. */ "Exit without saving" = "Вихід без збереження"; @@ -2319,9 +2310,6 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Виправлення не підтримується учасником групи"; -/* No comment provided by engineer. */ -"for better metadata privacy." = "для кращої конфіденційності метаданих."; - /* servers error */ "For chat profile %@:" = "Для профілю чату %@:"; @@ -3328,8 +3316,8 @@ "observer" = "спостерігач"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "вимкнено"; /* blur media */ @@ -3834,7 +3822,7 @@ "Reduced battery usage" = "Зменшення використання акумулятора"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "Відхилити"; /* No comment provided by engineer. */ @@ -3969,9 +3957,6 @@ /* No comment provided by engineer. */ "Review conditions" = "Умови перегляду"; -/* No comment provided by engineer. */ -"Review later" = "Перегляньте пізніше"; - /* No comment provided by engineer. */ "Revoke" = "Відкликати"; @@ -3994,7 +3979,7 @@ "Safer groups" = "Безпечніші групи"; /* alert button - chat item action */ +chat item action */ "Save" = "Зберегти"; /* alert button */ @@ -4358,7 +4343,7 @@ "Shape profile images" = "Сформуйте зображення профілю"; /* alert action - chat item action */ +chat item action */ "Share" = "Поділіться"; /* No comment provided by engineer. */ @@ -4742,9 +4727,6 @@ /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Такі ж умови діятимуть і для оператора **%@**."; -/* No comment provided by engineer. */ -"The same conditions will apply to operator(s): **%@**." = "Такі ж умови будуть застосовуватися до оператора(ів): **%@**."; - /* No comment provided by engineer. */ "The second preset operator in the app!" = "Другий попередньо встановлений оператор у застосунку!"; @@ -5378,9 +5360,6 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Ви можете змінити його в налаштуваннях зовнішнього вигляду."; -/* No comment provided by engineer. */ -"You can configure operators in Network & servers settings." = "Ви можете налаштувати операторів у налаштуваннях Мережі та серверів."; - /* No comment provided by engineer. */ "You can configure servers via settings." = "Ви можете налаштувати сервери за допомогою налаштувань."; @@ -5624,6 +5603,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "Ваша адреса SimpleX"; -/* No comment provided by engineer. */ -"Your SMP servers" = "Ваші SMP-сервери"; - diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 3ed0d0fa14..b363d25a4c 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -121,6 +121,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ 已认证"; +/* No comment provided by engineer. */ +"%@ server" = "服务器"; + +/* No comment provided by engineer. */ +"%@ servers" = "服务器"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ 已上传"; @@ -239,7 +245,7 @@ "0s" = "0秒"; /* delete after time - time interval */ +time interval */ "1 day" = "1天"; /* time interval */ @@ -249,11 +255,11 @@ "1 minute" = "1分钟"; /* delete after time - time interval */ +time interval */ "1 month" = "1月"; /* delete after time - time interval */ +time interval */ "1 week" = "1周"; /* No comment provided by engineer. */ @@ -299,8 +305,8 @@ "Accent" = "强调"; /* accept contact request via notification - accept incoming call via notification - swipe action */ +accept incoming call via notification +swipe action */ "Accept" = "接受"; /* No comment provided by engineer. */ @@ -310,7 +316,7 @@ "Accept contact request from %@?" = "接受来自 %@ 的联系人请求?"; /* accept contact request via notification - swipe action */ +swipe action */ "Accept incognito" = "接受隐身聊天"; /* call status */ @@ -664,7 +670,8 @@ /* rcv group event chat item */ "blocked %@" = "已封禁 %@"; -/* marked deleted chat item preview text */ +/* blocked chat item +marked deleted chat item preview text */ "blocked by admin" = "由管理员封禁"; /* No comment provided by engineer. */ @@ -700,6 +707,9 @@ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。"; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "使用 SimpleX Chat 代表您同意:\n- 在公开群中只发送合法内容\n- 尊重其他用户 – 没有垃圾信息。"; + /* No comment provided by engineer. */ "call" = "呼叫"; @@ -740,7 +750,7 @@ "Can't message member" = "无法向成员发送消息"; /* alert action - alert button */ +alert button */ "Cancel" = "取消"; /* No comment provided by engineer. */ @@ -792,7 +802,7 @@ "Change self-destruct mode" = "更改自毁模式"; /* authentication reason - set passcode view */ +set passcode view */ "Change self-destruct passcode" = "更改自毁密码"; /* chat item text */ @@ -915,9 +925,15 @@ /* No comment provided by engineer. */ "Completed" = "已完成"; +/* No comment provided by engineer. */ +"Conditions of use" = "使用条款"; + /* No comment provided by engineer. */ "Configure ICE servers" = "配置 ICE 服务器"; +/* No comment provided by engineer. */ +"Configure server operators" = "配置服务器运营方"; + /* No comment provided by engineer. */ "Confirm" = "确认"; @@ -1291,7 +1307,7 @@ "decryption errors" = "解密错误"; /* delete after time - pref value */ +pref value */ "default (%@)" = "默认 (%@)"; /* No comment provided by engineer. */ @@ -1301,7 +1317,7 @@ "default (yes)" = "默认 (是)"; /* alert action - swipe action */ +swipe action */ "Delete" = "删除"; /* No comment provided by engineer. */ @@ -1575,7 +1591,7 @@ "Downgrade and open chat" = "降级并打开聊天"; /* alert button - chat item action */ +chat item action */ "Download" = "下载"; /* No comment provided by engineer. */ @@ -1959,7 +1975,9 @@ /* No comment provided by engineer. */ "Error: " = "错误: "; -/* alert message */ +/* alert message +file error text +snd error text */ "Error: %@" = "错误: %@"; /* No comment provided by engineer. */ @@ -1974,9 +1992,6 @@ /* No comment provided by engineer. */ "Even when disabled in the conversation." = "即使在对话中被禁用。"; -/* No comment provided by engineer. */ -"event happened" = "发生的事"; - /* No comment provided by engineer. */ "Exit without saving" = "退出而不保存"; @@ -2989,8 +3004,8 @@ "observer" = "观察者"; /* enabled status - group pref value - time to disappear */ +group pref value +time to disappear */ "off" = "关闭"; /* blur media */ @@ -3245,9 +3260,15 @@ /* No comment provided by engineer. */ "Privacy & security" = "隐私和安全"; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "隐私政策和使用条款。"; + /* No comment provided by engineer. */ "Privacy redefined" = "重新定义隐私"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "服务器运营方无法访问私密聊天、群组和你的联系人。"; + /* No comment provided by engineer. */ "Private filenames" = "私密文件名"; @@ -3456,7 +3477,7 @@ "Reduced battery usage" = "减少电池使用量"; /* reject incoming call via notification - swipe action */ +swipe action */ "Reject" = "拒绝"; /* No comment provided by engineer. */ @@ -3604,7 +3625,7 @@ "Safer groups" = "更安全的群组"; /* alert button - chat item action */ +chat item action */ "Save" = "保存"; /* alert button */ @@ -3941,7 +3962,7 @@ "Shape profile images" = "改变个人资料图形状"; /* alert action - chat item action */ +chat item action */ "Share" = "分享"; /* No comment provided by engineer. */ @@ -5060,6 +5081,3 @@ /* No comment provided by engineer. */ "Your SimpleX address" = "您的 SimpleX 地址"; -/* No comment provided by engineer. */ -"Your SMP servers" = "您的 SMP 服务器"; - diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 20b851bad7..160e1322b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2156,7 +2156,7 @@ أشرطة أدوات التطبيق تمويه الشفافية - فعّل flux + فعّل flux في إعدادات الشبكة والخوادم لتحسين خصوصية البيانات الوصفية. اللامركزية الشبكية المُشغل المُعد مسبقًا الثاني في التطبيق! لتحسين خصوصية البيانات الوصفية. @@ -2361,4 +2361,9 @@ المشرفين لا يمكن قراءة عبارة المرور في Keystore. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطورين. موافقة الانتظار + ضبّط مُشغلي الخادم + سياسة الخصوصية وشروط الاستخدام. + لا يمكن الوصول إلى الدردشات الخاصة والمجموعات وجهات اتصالك لمشغلي الخادم. + باستخدام SimpleX Chat، توافق على:\n- إرسال المحتوى القانوني فقط في المجموعات العامة.\n- احترام المستخدمين الآخرين – لا سبام. + اقبل diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index f2a18050c3..b0c6e32c70 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -182,12 +182,12 @@ Permet als teus contactes eliminar de manera irreversible els missatges enviats. (24 hores) Permet que els teus contactes enviïn missatges que desapareixen. Permet que els teus contactes enviïn missatges de veu. - Tant tu com el teu contacte pots suprimir de manera irreversible els missatges enviats. (24 hores) + Tant vos com els vostres contactes podeu suprimir de manera irreversible els missatges enviats. (24 hores) Tant tu com el teu contacte podeu enviar missatges que desapareguin. Tant tu com el teu contacte podeu afegir reaccions als missatges. Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores) Permet enviar missatges directes als membres. - Permet suprimir de manera irreversible els missatges enviats. (24 hores) + Permeteu suprimir de manera irreversible els missatges enviats. (24 hores) Permet enviar missatges que desapareixen. Permet enviar fitxers i mitjans. Permet enviar missatges de veu. @@ -307,7 +307,7 @@ Cancel·la el canvi d\'adreça Accepta les condicions Condicions acceptades - "Afegiu perfil" + Afegiu un perfil Voleu cancel·lar el canvi d\'adreça? 1 minut Accepta @@ -323,7 +323,7 @@ Configuració d\'adreça Afegeix a un altre dispositiu administradors - Suprimir contacte? + Voleu suprimir el contacte? Desapareixerà a: %s L\'autenticació del dispositiu està desactivada. S\'està desactivant el bloqueig SimpleX. L\'autenticació del dispositiu no està activada. Podeu activar SimpleX Lock mitjançant Configuració, un cop hàgiu activat l\'autenticació del dispositiu. @@ -331,7 +331,7 @@ %1$s
.]]> %1$s
.]]> L\'adreça del servidor de destinació de %1$s és incompatible amb la configuració del servidor de reenviament %2$s. - Suprimir perfil + Suprimeix el perfil %d hores Esborrar per mi Els missatges directes entre membres estan prohibits en aquest xat. @@ -341,7 +341,7 @@ %d missatge(s) bloquejat(s) per l\'administrador %d missatge(s) marcat(s) eliminat(s) Error de desxifrat - Suprimir cua + Suprimeix la cua Esborrar fitxer SimpleX s\'executa en segon pla
en lloc d\'utilitzar notificacions push.]]> Desactivar notificacions @@ -350,29 +350,29 @@ %d minuts Desactiva el bloqueig SimpleX Error del servidor de destinació: %1$s - Suprimir el missatge? - Suprimir %d missatges? - Suprimir %d missatges dels membres? - Suprimir el missatge del membre? + Voleu suprimir el missatge? + Voleu suprimir els %d missatges? + Voleu suprimir %d missatges dels membres? + Vols suprimir el missatge del membre? Error de descodificació Eliminar i notificar el contacte - Suprimir contacte - Suprimir sense notificació + Suprimeix el contacte + Suprimeix sense notificació Desconnectat Missatge que desapareix - Suprimir - Suprimir - Suprimir la connexió pendent? + Suprimeix + Suprimeix + Voleu suprimir la connexió pendent? Contrasenya i exportació de la base de dades - Suprimir servidor + Suprimeix el servidor Utilitza els hosts .onion a No si el servidor SOCKS no els admet.]]> Desactivat NO envieu missatges directament, encara que el vostre servidor de destinació no admeti l\'encaminament privat. - Suprimir l\'adreça? + Voleu suprimir l\'adreça? Opcions de desenvolupador Desactivar Esborrar adreça - Suprimir imatge + Suprimeix la imatge El nom mostrat no pot contenir espais en blanc. Descentralitzada Desactivar @@ -386,11 +386,11 @@ DISPOSITIU La base de dades es xifra amb una contrasenya aleatòria. Si us plau, canvieu-la abans d\'exportar. Contrasenya de la base de dades - Suprimir perfil? + Voleu suprimir el perfil? Esborrar la base de dades - Suprimir tots els fitxers - Suprimir fitxers i mitjans? - Suprimir fitxers de tots els perfils + Suprimeix tots els fitxers + Voleu suprimir els fitxers i els mitjans? + Suprimeix els fitxers de tots els perfils %d fitxer(s) amb una mida total de %s Base de dades xifrada! Suprimir missatges @@ -427,17 +427,17 @@ %s
.]]> %s
.]]> %s
, accepteu les condicions d\'ús.]]> - Suprimir perfil? - Suprimir perfil + Voleu suprimir el perfil? + Suprimeix el perfil predeterminat (%s) - Suprimir per a tothom + Suprimeix per a tothom Missatges directes Missatges que desapareixen Els missatges que desapareixen estan prohibits en aquest xat. %dd %d dia %d dies - Suprimir en + Suprimeix després %dh %d hora Els missatges directes entre membres estan prohibits. @@ -638,7 +638,7 @@ Introduïu el vostre nom: Error en obrir el navegador Error en exportar la base de dades de xat - Error en suprimir la base de dades de xat + S\'ha produït un error en suprimir la base de dades de xat Error en importar la base de dades de xat Error en canviar la configuració Error en eliminar el membre @@ -667,14 +667,14 @@ Error en carregar els detalls Error en rebre el fitxer Error en acceptar la sol·licitud de contacte - Error en suprimir el contacte - Error en suprimir el grup + S\'ha produït un error en suprimir el contacte + S\'ha produït un error en suprimir el grup Error en cancel·lar el canvi d\'adreça Error en canviar l\'adreça - Error en suprimir la sol·licitud de contacte - Error en suprimir la connexió de contacte pendent - Error en suprimir les notes privades - Error en suprimir el perfil d\'usuari + S\'ha produït un error en suprimir la sol·licitud de contacte + S\'ha produït un error en suprimir la connexió de contacte pendent + S\'ha produït un error en suprimir les notes privades + S\'ha produït un error en suprimir el perfil d\'usuari Comparar el fitxer Error: %1$s Error @@ -875,7 +875,7 @@ %ds %d setmanes Baixa noves versions de GitHub. - Habilita Flux + Habiliteu Flux a la configuració de la xarxa i dels servidors per obtenir una millor privadesa de les metadades. Connectar amb l\'ordinador Connectant Errors de descàrrega @@ -939,7 +939,7 @@ habilitat per al contacte habilitat habilitat per a tu - Els contactes poden marcar missatges per suprimir-los; els podreu veure. + Els contactes poden marcar missatges per suprimir-los. Encara els podreu veure. Habilitat per Personalitza i comparteix temes de color. Temes personalitzats @@ -1149,7 +1149,7 @@ Pot passar quan:\n1. Els missatges van caducar al client d\'enviament al cap de 2 dies o al servidor després de 30 dies.\n2. No s\'ha pogut desxifrar el missatge, perquè tu o el teu contacte feien servir una còpia de seguretat de la base de dades antiga.\n3. La connexió s\'ha compromès. Només dades de perfil local Unir-se al teu grup? - El missatge es marcarà per suprimir-lo. Els destinataris podran revelar aquest missatge. + El missatge es marcarà per suprimir-lo. Els destinataris podran visualitzar aquest missatge. Nova experiència de xat 🎉 Unir-te Enganxar enllaç per connectar! @@ -1160,7 +1160,7 @@ el membre %1$s ha canviat a %2$s Canvia l\'aspecte dels teus xats! Màxim 40 segons, rebut a l\'instant. - Només tu pots suprimir missatges de manera irreversible (el teu contacte pot marcar-los per suprimir-los). (24 hores) + Només vos podreu suprimir els missatges de manera irreversible (el vostre contacte pot marcar-los per suprimir-los). (24 hores) Obrir configuració del servidor altres errors - Notificació opcional als contactes suprimits.\n- Noms de perfil amb espais.\n- I més! @@ -1282,7 +1282,7 @@ Nou arxiu de bases de dades Obrir la carpeta de la base de dades Aturar SimpleX? - Atura SimpleX per exportar, importar o suprimir la base de dades de xat. No podreu rebre ni enviar missatges mentre el xat estigui aturat. + Atura SimpleX per poder exportar, importar o suprimir la base de dades de xat. No podreu rebre ni enviar missatges mentre el xat estigui aturat. Estableix contrasenya per a exportar Aturar Reinicieu l\'aplicació per utilitzar la base de dades de xat importada. @@ -1315,7 +1315,7 @@ Toqueu per activar el perfil. Silenciat quan està inactiu! Encara rebràs trucades i notificacions de perfils silenciats quan estiguin actius. - Només el vostre contacte pot suprimir missatges de manera irreversible (pots marcar-los per suprimir-los). (24 hores) + Només el vostre contacte pot suprimir missatges de manera irreversible (podeu marcar-los per suprimir-los). (24 hores) La supressió de missatges irreversible està prohibida en aquest xat. Els vostres contactes poden permetre la supressió completa del missatge. Eliminació irreversible del missatge @@ -1959,7 +1959,7 @@ mai No s\'han rebut ni enviats fitxers Reinicieu l\'aplicació per crear un perfil de xat nou. - Aquesta acció no es pot desfer: se suprimiran tots els fitxers i mitjans rebuts i enviats. Les imatges de baixa resolució es mantindran. + Aquesta acció no es pot desfer: se suprimiran tots els fitxers i els mitjans rebuts i enviats. Les imatges de baixa resolució es mantindran. Heu d\'utilitzar la versió més recent de la vostra base de dades de xat NOMÉS en un dispositiu, en cas contrari, podeu deixar de rebre els missatges d\'alguns contactes. Aquesta configuració s\'aplica als missatges del vostre perfil de xat actual La vostra base de dades de xat no està xifrada; definiu una contrasenya per protegir-la. @@ -2215,7 +2215,7 @@ L\'actualització del perfil s\'enviarà als vostres contactes. a + b ratllat - Suprimir en + Suprimeix els missatges després Eliminar el perfil de xat per La connexió no està preparada. Error en crear la llista de xat @@ -2258,7 +2258,7 @@ Violació de les normes de la comunitat Contingut inadequat Perfil inadequat - Suprimir informe + Suprimeix l\'informe L\'informe s\'arxivarà. Informar Spam @@ -2285,13 +2285,13 @@ Només ho veieu vosaltres i moderació Error en crear informe Establir nom del xat… - Suprimir els missatges de xat del teu dispositiu. + Suprimiu els missatges de xat del vostre dispositiu. Voleu canviar la supressió automàtica de missatges? predeterminat (%s) 1 any Desactivar la supressió de missatges Desactivar la supressió automàtica de missatges? - Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat abans del seleccionat se suprimiran. + Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat anteriors al seleccionat se suprimiran. Els missatges d\'aquest xat no se suprimiran mai. Port TCP per a missatgeria Emprar port web @@ -2324,4 +2324,23 @@ Ajudar els administradors a moderar els seus grups. rebutjat rebutjat + pendent + pendent d\'aprovació + Tots els missatges nous d\'aquests/es membres s\'amagaran! + Blocar membres per a tots/es? + Condicions actualitzades + Error en llegir la contrasenya de la base de dades + Els/les membres s\'eliminaran del xat; això no es pot desfer! + Els missatges d\'aquests/es membres es mostraran! + Desblocar membres per a tots/es? + moderació + La frase de contrasenya a Keystore no es pot llegir. Això pot haver passat després que l\'actualització del sistema sigui incompatible amb l\'aplicació. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. + Els/les membres s\'eliminaran del grup; això no es pot desfer! + La frase de contrasenya a Keystore no es pot llegir, introduïu-la manualment. Això pot haver passat després que l\'actualització del sistema sigui incompatible amb l\'aplicació. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. + Expulsar membres? + Política de privadesa i condicions d\'ús. + Els xats privats, els grups i els vostres contactes no són accessibles per als operadors de servidor. + Acceptar + En utilitzar SimpleX Chat accepteu:\n- enviar només contingut legal en grups públics.\n- Respectar els altres usuaris, sense correu brossa. + Configurar els operadors de servidor diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 9d13f4f262..3b8db5044b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -2359,4 +2359,12 @@ Aktualizované podmínky čekám na schválení čekám + Blokovat členy všem? + Všechny nové zprávy od těchto členů budou skryty! + Odblokovat členy všem? + moderátoři + Zprávy od těchto členů budou zobrazeny! + Členové budou odstraněny ze skupiny - toto nelze zvrátit! + Odebrat členy? + Členové budou odstraněny z chatu - toto nelze zvrátit! 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 551b6caa7f..145d4142a7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -20,7 +20,7 @@ Gelöscht als gelöscht markiert Das Senden von Dateien wird noch nicht unterstützt - Der Empfang von Dateien wird noch nicht unterstützt + Das Herunterladen von Dateien wird noch nicht unterstützt Profil Unbekanntes Nachrichtenformat Ungültiges Nachrichtenformat @@ -58,9 +58,9 @@ Fehler beim Senden der Nachricht Fehler beim Hinzufügen von Mitgliedern Fehler beim Beitritt zur Gruppe - Datei kann nicht empfangen werden + Datei kann nicht heruntergeladen werden Der Absender hat die Dateiübertragung abgebrochen. - Fehler beim Empfangen der Datei + Fehler beim Herunterladen der Datei Fehler beim Erstellen der Adresse Kontakt besteht bereits Sie sind bereits mit %1$s verbunden. @@ -195,10 +195,10 @@ Bild Warten auf ein Bild - Es wird um den Empfang eines Bildes gebeten + Es wird um das Herunterladen eines Bildes gebeten Bild gesendet Warten auf ein Bild - Das Bild wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! + Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! Bild wurde im Fotoalbum gespeichert Datei @@ -206,7 +206,7 @@ Ihr Kontakt hat eine Datei gesendet, die größer ist als die derzeit unterstützte maximale Größe (%1$s). Die derzeit maximal unterstützte Dateigröße beträgt %1$s. Warte auf Datei - Die Datei wird empfangen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später noch mal nach! + Die Datei wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später noch mal nach! Datei gespeichert Datei nicht gefunden Fehler beim Speichern der Datei @@ -597,8 +597,8 @@ Starten Sie die App neu, um ein neues Chat-Profil zu erstellen. Sie dürfen die neueste Version Ihrer Chat-Datenbank NUR auf einem Gerät verwenden, andernfalls erhalten Sie möglicherweise keine Nachrichten mehr von einigen Ihrer Kontakte. Dateien und Medien löschen? - Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! - Keine empfangenen oder gesendeten Dateien + Es werden alle herunter- und hochgeladene Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! + Keine herunter- oder hochgeladene Dateien %d Datei(en) mit einem Gesamtspeicherverbrauch von %s nie Älter als ein Tag @@ -1077,22 +1077,22 @@ Die Datenbank-Version ist neuer als die App, keine Abwärts-Migration für: %s Verberge: Migrationen: %s - Das Bild wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. - Die Datei wird empfangen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Das Bild wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. + Die Datei wird heruntergeladen, sobald das Hochladen durch ihren Kontakt abgeschlossen ist. Chat-Profil löschen Profil löschen Verbergen des Profils aufheben Passwort für Profil Verbergen des Chat-Profils aufheben - Aufforderung zum Empfang des Videos + Aufforderung zum Herunterladen des Videos Es können nur 10 Videos zur gleichen Zeit versendet werden Zu viele Videos auf einmal! Video Video gesendet - Das Video wird empfangen, sobald Ihr Kontakt das Hochladen beendet hat. + Das Video wird heruntergeladen, sobald Ihr Kontakt das Hochladen beendet hat. Auf das Video warten Auf das Video warten - Das Video wird empfangen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! + Das Video wird heruntergeladen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! Ihre XFTP-Server Host Fehler beim Speichern der XFTP-Server @@ -1159,11 +1159,11 @@ Sowohl Sie als auch Ihr Kontakt können Anrufe tätigen. Nur Sie können Anrufe tätigen. Audio-/Video-Anrufe nicht erlauben. - Den Empfang der Datei beenden\? - Das Senden der Datei beenden\? - Der Empfang der Datei wird beendet. + Herunterladen der Datei beenden? + Das Hochladen der Datei beenden? + Das Herunterladen der Datei wird beendet. Das Senden der Datei wird beendet. - Datei beenden + Download beenden Die Datei wird von den Servern gelöscht. Widerrufen Datei widerrufen @@ -1906,7 +1906,7 @@ Privates Nachrichten-Routing 🚀 Schützen Sie Ihre IP-Adresse vor den Nachrichten-Relais , die Ihr Kontakt ausgewählt hat. \nAktivieren Sie es in den *Netzwerk & Server* Einstellungen. - Dateien sicher empfangen + Dateien sicher herunterladen Mit reduziertem Akkuverbrauch. Keine Information Debugging-Zustellung @@ -2191,7 +2191,7 @@ Fehler in der Server-Konfiguration. Für das Chat-Profil %s: Keine Medien- und Dateiserver. - Keine Server für den Empfang von Dateien. + Keine Server für das Herunterladen von Dateien. Keine Server für das Versenden von Dateien. Nicht ausgelieferte Nachrichten Die SimpleX-Adresse auf sozialen Medien teilen. @@ -2246,7 +2246,7 @@ Der Server-Betreiber wurde geändert. Das Server-Protokoll wurde geändert. Transparenz - Flux aktivieren + Für einen besseren Metadatenschutz Flux in den Netzwerk- und Servereinstellungen aktivieren. Dezentralisiertes Netzwerk Der zweite voreingestellte Netzwerk-Betreiber in der App! Verbesserte Chat-Navigation @@ -2447,4 +2447,9 @@ Moderatoren Mitglieder für Alle blockieren? Alle neuen Nachrichten dieser Mitglieder werden nicht angezeigt! + Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam. + Datenschutzbestimmungen und Nutzungsbedingungen. + Akzeptieren + Server-Betreiber konfigurieren + Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index ad90b97d38..be71a7a927 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -7,9 +7,9 @@ Bueno para la batería. La aplicación comprueba si hay mensajes cada 10 minutos. Podrías perderte llamadas o mensajes urgentes.]]> Aceptar Copia de seguridad de los datos de la aplicación - un dia - un mes - una semana + 1 día + 1 mes + 1 semana Se permiten los mensajes temporales pero sólo si tu contacto también los permite. Añadir servidores mediante el escaneo de códigos QR. Añadir servidores predefinidos @@ -20,7 +20,7 @@ Permites a tus contactos enviar mensajes de voz. siempre La aplicación sólo puede recibir notificaciones cuando se está ejecutando. No se iniciará ningún servicio en segundo plano. - ICONO APLICACIÓN + ICONO DE LA APLICACIÓN La optimización de la batería está activa, desactivando el servicio en segundo plano y las solicitudes periódicas de nuevos mensajes. Puedes volver a activarlos en Configuración. El servicio está siempre en funcionamiento en segundo plano. Las notificaciones se muestran en cuanto haya mensajes nuevos. Se puede desactivar en Configuración – las notificaciones se seguirán mostrando mientras la app esté en funcionamiento.]]> @@ -29,7 +29,7 @@ y después: ¿Aceptar solicitud de conexión\? Aceptar incógnito - Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse! + Se eliminarán todos los mensajes SOLO para tí. ¡No puede deshacerse! Añadir servidor ¿Acceder a los servidores a través del proxy SOCKS en el puerto %d\? El proxy debe iniciarse antes de activar esta opción. Todos tus contactos permanecerán conectados. @@ -49,7 +49,7 @@ Audio activado ID de mensaje erróneo Auto aceptar imágenes - Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse! + Se eliminarán todos los chats y mensajes. ¡No puede deshacerse! Aceptar Se permiten mensajes temporales. Android Keystore se usará para almacenar de forma segura la frase de contraseña - permite que el servicio de notificación funcione. @@ -108,7 +108,7 @@ Llamada con cifrado de extremo a extremo cifrado de extremo a extremo mensaje duplicado - Herramientas desarrollo + Herramientas para desarrolladores Eliminar los archivos de todos los perfiles Activar ¡Base de datos cifrada! @@ -136,7 +136,7 @@ conectando (anunciado) conexión %1$d Conecta vía enlace / Código QR - El contacto y todos los mensajes serán eliminados. ¡No podrá deshacerse! + El contacto y todos los mensajes serán eliminados. ¡No puede deshacerse! Contacto verificado el contacto dispone de cifrado de extremo a extremo Desconectar @@ -158,7 +158,7 @@ Conectado Copiado en portapapeles Crea enlace de invitación de un uso. - Escanear código QR ]]> + Escanear código QR ]]> Eliminar Eliminar ¡El contacto aun no se ha conectado! @@ -238,8 +238,7 @@ Email Conectar Conectar mediante enlace - Base de Datos y -\nContraseña + Base de Datos y Contraseña Contribuye Core versión: v%s Eliminar imagen @@ -272,7 +271,7 @@ Cambiar rol Mediante perfil (predeterminado) o por conexión (BETA) cambiando de servidor… - Preferencias de Chat + Preferencias generales cancelado %s SimpleX está parado LLAMADAS @@ -312,12 +311,12 @@ Girar la cámara Invitación de grupo caducada La invitación al grupo ya no es válida, ha sido eliminada por el remitente. - El grupo será eliminado para tí. ¡No podrá deshacerse! + El grupo será eliminado para tí. ¡No puede deshacerse! Cómo usar la sintaxis markdown en modo incógnito mediante enlace de un solo uso Dirección de contacto SimpleX Error al guardar servidores SMP - Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces SimpleX que no son de confianza aparecerán en rojo. + Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces de SimpleX que no son de confianza aparecerán en rojo. Error al actualizar la configuración de red Error al crear dirección Error al eliminar perfil @@ -343,7 +342,7 @@ Error al cambiar dirección Error al guardar archivo Error - De la Galería + De la galería Imagen Vídeo Si has recibido un enlace de invitación a SimpleX Chat puedes abrirlo en tu navegador: @@ -411,7 +410,7 @@ Ocultar pantalla de aplicaciones en aplicaciones recientes. Cifrar Ampliar la selección de roles - El grupo será eliminado para todos los miembros. ¡No podrá deshacerse! + El grupo será eliminado para todos los miembros. ¡No puede deshacerse! Activar TCP keep-alive activado para tí error @@ -428,7 +427,7 @@ ayuda Compartir enlace Cómo funciona - El mensaje será eliminado. ¡No podrá deshacerse! + El mensaje será eliminado. ¡No puede deshacerse! El modo incógnito protege tu privacidad creando un perfil aleatorio por cada contacto. Da permiso en el siguiente diálogo para recibir notificaciones instantáneas.]]> Instalar terminal de SimpleX Chat @@ -490,7 +489,7 @@ eliminado por el moderador invitación a conectarse ¡Las notificaciones instantáneas están desactivadas! - mensaje nuevo + nuevo mensaje Nueva solicitud de contacto Inicie sesión con sus credenciales Error en la entrega del mensaje @@ -519,7 +518,7 @@ OK (sólo almacenado por miembros del grupo) Ayuda sintaxis markdown - Servidores y Redes + Servidores y Red Se usarán hosts .onion si están disponibles. cursiva Llamada audio entrante @@ -564,7 +563,7 @@ Salir ¿Salir del grupo\? propietario - El miembro será expulsado del grupo. ¡No podrá deshacerse! + El miembro será expulsado del grupo. ¡No puede deshacerse! Sólo los propietarios del grupo pueden activar los mensajes de voz. Más Marcar como verificado @@ -578,7 +577,7 @@ Error al restaurar base de datos Seleccionar contactos Guardar perfil de grupo - Restablecer colores + Reiniciar colores Sólo tú puedes enviar mensajes temporales. Sólo tu contacto puede enviar mensajes temporales. No se permiten mensajes de voz. @@ -596,7 +595,7 @@ llamada rechazada secreto Abrir SimpleX Chat para aceptar llamada - Restablecer valores predetarminados + Reiniciar a valores predetarminados Pendiente Notificaciones periódicas Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes. @@ -605,7 +604,7 @@ Notificaciones privadas imagen del perfil No se permiten mensajes de voz. - Proteger la pantalla + Proteger pantalla de la aplicación repositorio GitHub .]]> Grabar mensaje de voz ha expulsado a %1$s @@ -684,7 +683,7 @@ Expulsar Expulsar miembro Enviar mensaje directo - Restablecer + Reiniciar Pegar Código de seguridad Escanea el código de seguridad desde la aplicación de tu contacto. @@ -744,14 +743,14 @@ Altavoz desactivado Inciar chat nuevo Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. - Gracias por instalar SimpleX Chat! - Para proteger tu privacidad, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos. + ¡Gracias por instalar SimpleX Chat! + Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos. Para proteger tu información, activa el Bloqueo SimpleX. \nSe te pedirá que completes la autenticación antes de activar esta función. Para actualizar la configuración el cliente se reconectará a todos los servidores. ¿Usar servidores SimpleX Chat\? Enlace de grupo SimpleX - Invitación única SimpleX + Invitación SimpleX de un uso Enlaces SimpleX El servidor requiere autorización para crear colas, comprueba la contraseña Para recibir notificaciones, introduce la contraseña de la base de datos @@ -782,7 +781,7 @@ %s no está verificado Probar servidor Probar servidores - Estrella en GitHub + Califica en GitHub Servidores para conexiones nuevas en tu perfil ¿Usar conexión directa a Internet\? El perfil sólo se comparte con tus contactos. @@ -871,7 +870,7 @@ Mediante navegador mediante %1$s Servicio SimpleX Chat - ¡Bienvenido %1$s ! + ¡Bienvenido %1$s! has sido invitado al grupo Esperando archivo Esperando imagen @@ -936,7 +935,7 @@ Mis perfiles Mi dirección SimpleX Tu servidor - Dirección del servidor + Dirección de tu servidor Tu perfil actual Tu perfil es almacenado en tu dispositivo y solamente se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. Sistema @@ -993,10 +992,10 @@ Confirmar actualizaciones de la bases de datos la versión de la base de datos es más reciente que la aplicación, pero no hay migración hacia versión anterior para: %s EXPERIMENTAL - IDs de la base de datos y opciónes de aislamiento de transporte. + IDs de la base de datos y opciones de aislamiento de transporte. El archivo se recibirá cuando el contacto termine de subirlo. La imagen se recibirá cuando el contacto termine de subirla. - Mostrar opciones de desarrollador + Mostrar opciones para desarrolladores Ocultar: Mostrar: Eliminar perfil @@ -1107,7 +1106,7 @@ Abriendo base de datos… Error al introducir dirección Guía de Usuario.]]> - Enlace de un uso + Enlace de un solo uso Dirección SimpleX Cuando alguien solicite conectarse podrás aceptar o rechazar su solicitud. Compartir dirección @@ -1144,7 +1143,7 @@ Mensaje enviado Dejar de compartir ¿Dejar de compartir la dirección\? - COLORES DEL INTERFAZ + COLORES DE LA INTERFAZ Puedes crearla más tarde ¿Compartir la dirección con los contactos\? Compartir con contactos @@ -1234,7 +1233,7 @@ APLICACIÓN Reiniciar Cerrar - Las notificaciones dejarán de funcionar hasta que reinicies la aplicación + Las notificaciones dejarán de funcionar hasta que vuelvas a iniciar la aplicación Desactivado Error al cancelar cambio de dirección Sin chats filtrados @@ -1319,7 +1318,7 @@ ¿Permitir confirmaciones para grupos\? Las confirmaciones están deshabilitadas para los grupos %d Las confirmaciones están activadas para %d grupos - Grupos pequeños (max. 20) + Grupos pequeños (máx. 20) Activar para todos los grupos %s: %s desactivado @@ -1368,7 +1367,7 @@ Recuerda: los servidores están conectados mediante proxy SOCKS, pero las llamadas y las previsualizaciones de enlaces usan conexión directa.]]> Cifrar archivos locales Nueva aplicación para ordenador! - 6 idiomas nuevos para el interfaz + 6 nuevos idiomas para la interfaz Cifrado de los nuevos archivos locales (excepto vídeos). Envía un mensaje para conectar Descubre y únete a grupos @@ -1518,8 +1517,8 @@ No se envía el historial a los miembros nuevos. O muestra el código QR Hasta 100 últimos mensajes son enviados a los miembros nuevos. - El código QR escaneado no es un enlace SimpleX. - El texto pegado no es un enlace SimpleX. + El código QR escaneado no es un enlace de SimpleX. + El texto pegado no es un enlace de SimpleX. Permitir acceso a la cámara Podrás ver el enlace de invitación en detalles de conexión. ¿Guardar invitación no usada? @@ -1543,7 +1542,7 @@ Error al crear mensaje Error al eliminar notas privadas ¿Eliminar notas privadas? - Opciones desarrollador + Opciones para desarrolladores ha bloqueado a %s ha desbloqueado a %s has bloqueado a %s @@ -1571,7 +1570,7 @@ El ordenador está inactivo El ordenador está ocupado Error crítico - Todos los mensajes serán borrados. ¡No podrá deshacerse! + Todos los mensajes serán eliminados. ¡No puede deshacerse! ¿Iniciar chat? Mensaje de bienvenida demasiado largo Tiempo de espera para conectar con el ordenador agotado @@ -1684,8 +1683,8 @@ Error al guardar ajustes El archivo exportado no existe Para continuar, SimpleX debe estar parado. - cifrado de extremo a extremo con secreto perfecto hacía adelante, repudio y recuperación tras ataque.]]> - cifrado de extremo a extremo resistente a tecnología cuántica con secreto perfecto hacía adelante, repudio y recuperación tras ataque.]]> + cifrado de extremo a extremo con secreto perfecto hacia adelante, repudio y recuperación tras ataque.]]> + cifrado de extremo a extremo resistente a tecnología cuántica con secreto perfecto hacia adelante, repudio y recuperación tras ataque.]]> Migrar aquí Migrar a otro dispositivo Migrar a otro dispositivo mediante código QR. @@ -1712,7 +1711,7 @@ Enlaces SimpleX no permitidos Mensajes de voz no permitidos Enlaces SimpleX - Los miembros pueden enviar enlaces SimpleX. + Los miembros pueden enviar enlaces de SimpleX. Enlaces SimpleX no permitidos. propietarios Móvil @@ -1808,7 +1807,7 @@ Respuesta recibida Mosaico Quitar imagen - Restablecer color + Reiniciar color Escala Respuesta enviada Establecer tema predefinido @@ -1823,7 +1822,7 @@ información cola del servidor: %1$s \n \núltimo mensaje recibido: %2$s - Restablecer al tema de la aplicación + Reiniciar al tema de la aplicación Enrutamiento privado de mensajes 🚀 Recibe archivos de forma segura Mejora del envío de mensajes @@ -1835,14 +1834,13 @@ Nuevos temas de chat Información cola de mensajes ninguno - Protege tu dirección IP de los servidores de retransmisión elegidos por tus contactos. -\nActívalo en ajustes de *Servidores y Redes*. - Restablecer al tema del usuario + Protege tu dirección IP de los servidores elegidos por tus contactos.\nActívalo en *Servidores y Red*. + Reiniciar al tema del usuario Error al inicializar WebView. Actualiza tu sistema a la última versión. Por favor, ponte en contacto con los desarrolladores. \nError: %s Interfaz en persa Clave incorrecta o dirección del bloque del archivo desconocida. Es probable que el archivo se haya eliminado. - Archivo no encontrado, probablemente haya sido borrado o cancelado. + Archivo no encontrado, probablemente haya sido eliminado o cancelado. Error del servidor de archivos: %1$s Error de archivo Error en archivo temporal @@ -1856,7 +1854,7 @@ Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador. No se puede enviar el mensaje Las preferencias seleccionadas no permiten este mensaje. - Info servidores + Estadísticas servidores Archivos Mostrando Suscritas @@ -1903,9 +1901,9 @@ La dirección del servidor es incompatible con la configuración de red: %1$s. La versión del servidor es incompatible con tu aplicación: %1$s. Tamaño de la fuente - Error al restablecer las estadísticas - Restablecer - Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse! + Error al reiniciar las estadísticas + Reiniciar + Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse! Descargado Servidor SMP Aún no hay conexión directa, el mensaje es reenviado por el administrador. @@ -1944,8 +1942,8 @@ ¿Reconectar servidores? Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional. Reconectar todos los servidores para forzar la entrega de mensajes. Usa tráfico adicional. - Restablecer todas las estadísticas - ¿Restablecer todas las estadísticas? + Reiniciar estadísticas + ¿Reiniciar todas las estadísticas? Mensajes enviados Total enviados Archivos descargados @@ -1965,7 +1963,7 @@ Tamaño Conexiones activas Iniciado el %s. - Iniciado el %s \nLa información es privada en tu dispositivo. + Iniciado el %s \nLos datos son privados en tu dispositivo. Bloques eliminados Bloques descargados Bloques subidos @@ -1999,7 +1997,7 @@ Configuración ¿Confirmas la eliminación del contacto? ¡Contacto eliminado! - El contacto será eliminado. ¡No podrá deshacerse! + El contacto será eliminado. ¡No puede deshacerse! ¡Conversación eliminada! Elimina sin notificar Eliminar sólo la conversación @@ -2020,7 +2018,7 @@ Por favor, pide a tu contacto que active las llamadas. Enviar mensaje para activar llamadas. Elimina hasta 20 mensajes a la vez. - Barra de herramientas accesible + Barra de menú accesible Archiva contactos para charlar más tarde. Puedes guardar el archivo exportado. Fuerte @@ -2071,7 +2069,7 @@ %1$s mensajes no enviados Descargar Reenviando %1$s mensajes - Los mensajes han sido borrados después de seleccionarlos. + Los mensajes han sido eliminados después de seleccionarlos. ¡Nada para reenviar! Guardando %1$s mensajes No uses credenciales con proxy. @@ -2085,7 +2083,7 @@ Tus credenciales podrían ser enviadas sin cifrar. ¿Eliminar archivo? El archivo de bases de datos subido será eliminado permanentemente de los servidores. - Los mensajes serán eliminados. ¡No podrá deshacerse! + Los mensajes serán eliminados. ¡No puede deshacerse! Error al cambiar perfil Selecciona perfil de chat Perfil a compartir @@ -2110,7 +2108,7 @@ Protocolos de SimpleX auditados por Trail of Bits. Intercambia audio y video durante la llamada. Seguridad mejorada ✅ - Borra o modera hasta 200 mensajes a la vez. + Elimina o modera hasta 200 mensajes a la vez. Cambia el perfil de chat para invitaciones de un solo uso. Error al guardar servidores Error en la configuración del servidor. @@ -2121,7 +2119,7 @@ Seguridad de conexión Compartir enlace de un uso con un amigo Comparte tu dirección SimpleX en redes sociales. - Configurar dirección + Ajustes de dirección Crear enlace de un uso Para redes sociales ¿Dirección SimpleX o enlace de un uso? @@ -2153,11 +2151,11 @@ Condiciones aceptadas Servidores de archivos y multimedia añadidos Servidores de mensajes añadidos - ¿Dirección o enlace de un uso? + ¿Dirección o enlace de un solo uso? Las condiciones serán aceptadas automáticamente para los operadores habilitados el: %s. Continuar El texto con las condiciones actuales no se ha podido cargar, puedes revisar las condiciones en el siguiente enlace: - Habilita Flux + Activa Flux en Servidores y Red para mejorar la privacidad de los metadatos. Error al aceptar las condiciones Error al actualizar el servidor para mejorar la privacidad de los metadatos. @@ -2169,8 +2167,8 @@ Servidor del operador O para compartir en privado Selecciona los operadores de red a utilizar - Campartir dirección públicamente - Compartir los enlaces de un uso y las direcciones SimpleX es seguro a través de cualquier medio. + Compartir dirección públicamente + Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. Actualizar Sitio web Tus servidores @@ -2187,7 +2185,7 @@ solamente con un contacto - comparte en persona o mediante cualquier aplicación de mensajería.]]> Puedes añadir un nombre a la conexión para recordar a quién corresponde. La aplicación protege tu privacidad mediante el uso de diferentes operadores en cada conversación. - Puedes configurar los operadores desde Servidores y Redes. + Puedes configurar los operadores desde los ajustes de Servidores y Red. %s.]]> %s.]]> %s.]]> @@ -2204,7 +2202,7 @@ Por ejemplo, si tu contacto recibe a través de un servidor de SimpleX Chat, tu aplicación enviará a través de un servidor de Flux. Pulsa Crear dirección SimpleX en el menú para crearla más tarde. La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado. - El mensaje ha sido borrado o aún no se ha recibido. + El mensaje ha sido eliminado o aún no se ha recibido. Móvil remoto O importa desde un archivo Mensajes directos entre miembros de este chat no permitidos. @@ -2217,13 +2215,13 @@ Invitar al chat Añadir amigos Añadir miembros del equipo - El chat será eliminado para todos los miembros. ¡No podrá deshacerse! + El chat será eliminado para todos los miembros. ¡No puede deshacerse! Eliminar chat ¿Eliminar chat? Salir del chat - El chat será eliminado para tí. ¡No podrá deshacerse! + El chat será eliminado para tí. ¡No puede deshacerse! Sólo los propietarios del chat pueden cambiar las preferencias. - El miembro será eliminado del chat. ¡No podrá deshacerse! + El miembro será eliminado del chat. ¡No puede deshacerse! El rol cambiará a %s. Todos serán notificados. Dejarás de recibir mensajes de este chat. El historial del chat se conserva. Cómo ayuda a la privacidad @@ -2268,7 +2266,7 @@ Grupos Abrir con %s Añadir a la lista - Todos los chats serán quitados de la lista %s y esta será eliminada + Todos los chats se quitarán de la lista %s y esta será eliminada Crear lista Eliminar ¿Eliminar lista? @@ -2286,8 +2284,8 @@ informe archivado por %s ¿Archivar informe? El informe será archivado para ti. - Informe - Borrar informe + Informar + Eliminar informe 1 informe Informes Informar de spam: sólo los moderadores del grupo lo verán. @@ -2303,7 +2301,7 @@ Archivo bloqueado por el operador del servidor:\n%1$s Archivar Archivar informe - Informes de miembro + Informes de miembros %d informes Informar del perfil de un miembro: sólo los moderadores del grupo lo verán. Otro motivo @@ -2325,9 +2323,9 @@ Desactivar ¿Modificar la eliminación automática de mensajes? Elimina los mensajes del dispositivo - Los mensajes de esta conversación nunca se borran. - Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No podrá deshacerse! - Un año + Los mensajes de esta conversación nunca se eliminan. + Todos los mensajes previos al período seleccionado serán eliminados del chat. ¡No puede deshacerse! + 1 año predefinido (%s) Puerto TCP para mensajes Se usa el puerto TCP %1$s cuando no se ha especificado otro. @@ -2365,13 +2363,18 @@ ¿Desbloquear miembros para todos? ¡Todos los mensajes nuevos de estos miembros estarán ocultos! ¿Bloquear miembros para todos? - Los miembros serán expulsados del chat. ¡No podrá deshacerse! + Los miembros serán expulsados del chat. ¡No puede deshacerse! Condiciones actualizadas moderadores - Los miembros serán expulsados del grupo. ¡No podrá deshacerse! + Los miembros serán expulsados del grupo. ¡No puede deshacerse! pendiente de aprobación pendiente Error al leer la frase de contraseña de la base de datos La frase de contraseña no se ha podido leer en Keystore. Puede deberse a alguna actualización del sistema incompatible con la aplicación. Si no es así, por favor, ponte en contacto con los desarrolladores. La frase de contraseña no se ha podido leer en Keystore. Por favor, introdúcela manualmente. Puede deberse a alguna actualización del sistema incompatible con la aplicación. Si no es así, por favor, ponte en contacto con los desarrolladores. + Aceptar + Política de privacidad y condiciones de uso. + Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores. + Al usar SimpleX Chat, aceptas:\n- enviar únicamente contenido legal en los grupos públicos.\n- respetar a los demás usuarios - spam prohibido. + Configurar operadores de servidores diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index dd5ce1dc6a..43fcb2f5f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -2325,4 +2325,23 @@ Tous les signalements seront archivés pour vous. Archiver tous les signalements ? Archiver %d signalements ? + Modifier la suppression automatique des messages ? + Supprimer les messages de discussion de votre appareil. + par défaut (%s) + Bloquer ces membres pour tous ? + Désactiver la suppression automatique des messages ? + Désactiver la suppression des messages + Ne manquez pas les messages importants. + Recevoir une notification en cas de mention. + Vie privée et sécurité renforcées + Suppression plus rapide des groupes. + Envoi plus rapide des messages. + Pour moi + Archiver les rapports + Pour tous les modérateurs + Les membres peuvent signaler des messages aux modérateurs. + Groupes plus performants + Erreur lors de la lecture de la phase secrète de la base de données + Aider les administrateurs à modérer leurs groupes. + Tous les nouveaux messages de ces membres seront cachés ! 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 4449fbed58..5ab5dd8a63 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1,6 +1,6 @@ - %1$d üzenet visszafejtése sikertelen. + Nem sikerült visszafejteni %1$d üzenetet. %1$d üzenet kihagyva. %1$d üzenet kihagyva %1$s TAG @@ -13,7 +13,7 @@ Megszakítja a cím módosítását? Megszakítás 30 másodperc - Egyszer használható meghívási hivatkozás + Egyszer használható meghívó %1$s szeretne kapcsolatba lépni Önnel a következőn keresztül: A SimpleX Chat névjegye 1 nap @@ -26,7 +26,7 @@ Elfogadás gombra fent, majd: Elfogadás inkognitóban - Elfogadja a kapcsolódási kérést? + Elfogadja a meghívási kérést? Elfogadás Elfogadás Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára. @@ -90,7 +90,7 @@ titkosítás elfogadása… Nem lehet meghívni a partnert! téves üzenet ID - Kapcsolatkérések automatikus elfogadása + Meghívási kérések automatikus elfogadása Megjegyzés: NEM fogja tudni helyreállítani, vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> hívás… További másodlagos szín @@ -128,7 +128,7 @@ Az összes alkalmazásadat törölve. Legjobb akkumulátoridő. Csak akkor kap értesítéseket, amikor az alkalmazás meg van nyitva. (NINCS háttérszolgáltatás.)]]> Megjelenés - Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. A beállításokban újraengedélyezheti. + Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. Ezt a beállításokban újraengedélyezheti. Letiltja a tagot? %1$s hívása befejeződött Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> @@ -199,7 +199,7 @@ Csoport létrehozása véletlenszerű profillal. A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. - Kapcsolódik az egyszer használható meghívási hivatkozással? + Kapcsolódik az egyszer használható meghívóval? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) Csak név @@ -213,7 +213,7 @@ Partner ellenőrizve Kapcsolódik saját magához? Kimásolva a vágólapra - Kapcsolatkérés elküldve! + Meghívási kérés elküldve! Kapcsolódás a számítógéphez Kapcsolat Helyesbíti a nevet a következőre: %s? @@ -234,13 +234,13 @@ Kapcsolódási hiba A partnere még nem kapcsolódott! - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. - Hozzájárulás + Közreműködés kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása törölt partner Törli a tag üzenetét? A csevegés fut - Egyszer használható meghívási hivatkozás létrehozása + Egyszer használható meghívó létrehozása Törlés Új üzenetek ellenőrzése 10 percenként, legfeljebb 1 percen keresztül Adatbázis törlése @@ -407,7 +407,7 @@ Letiltja a kézbesítési jelentéseket a csoportok számára? nap %d nap - Duplikált megjelenített név! + Duplikált megjelenítendő név! Letiltás (felülírások megtartásával) Adatbázis fejlesztése %d üzenet letiltva @@ -456,58 +456,58 @@ Engedélyezés az összes tag számára A kézbesítési jelentések le vannak tiltva! Kibontás - Hiba az üzenet küldésekor + Hiba történt az üzenet elküldésekor Adja meg a jelkódot Mindenkinél - Hiba a titkosítás újraegyeztetésekor - Hiba az adatbázis titkosításakor - Hiba a csoport törlésekor + Hiba történt a titkosítás újraegyeztetésekor + Hiba történt az adatbázis titkosításakor + Hiba történt a csoport törlésekor Kilépés mentés nélkül A tárolt fájlok- és a médiatartalmak titkosítása - Hiba a cím beállításakor + Hiba történt a cím beállításakor A csoportmeghívó lejárt - Hiba az ICE-kiszolgálók mentésekor + Hiba történt az ICE-kiszolgálók mentésekor Hiba Hiba - Hiba az XFTP-kiszolgálók betöltésekor - Hiba az SMP-kiszolgálók betöltésekor - Hiba a hálózat konfigurációjának frissítésekor + Hiba történt az XFTP-kiszolgálók betöltésekor + Hiba történt az SMP-kiszolgálók betöltésekor + Hiba történt a hálózat konfigurációjának frissítésekor TCP életben tartása Kamera váltás Üdvözlöm!\nCsatlakozzon hozzám a SimpleX Chaten keresztül: %s A megjelenített név nem tartalmazhat szóközöket. Csoport Adja meg az üdvözlőüzenetet… (nem kötelező) - Hiba a csevegési adatbázis exportálásakor - Hiba a fájl mentésekor + Hiba történt a csevegési adatbázis exportálásakor + Hiba történt a fájl mentésekor Helyi fájlok titkosítása titkosítás elfogadva %s számára %d üzenet megjelölve törlésre a titkosítás újraegyeztetése engedélyezve van Önmegsemmisítés engedélyezése Olvasatlan és kedvenc csevegésekre való szűrés. - A csevegések betöltése sikertelen + Nem sikerült betölteni a csevegéseket A csoport már létezik! Francia kezelőfelület Csoporthivatkozások Végre, megvannak! 🚀 - Hiba a csevegés elindításakor + Hiba történt a csevegés elindításakor A csoport profilja a tagok eszközein tárolódik, nem a kiszolgálókon. Adja meg a jelmondatot… - Hiba a felhasználói adatvédelem frissítésekor + Hiba történt a felhasználói adatvédelem frissítésekor Titkosít Csoport nem található! - Hiba az SMP-kiszolgálók mentésekor + Hiba történt az SMP-kiszolgálók mentésekor Visszafejlesztés és a csevegés megnyitása A csoport inaktív Gyors és nem kell várni, amíg a feladó online lesz! - Hiba a csoporthoz való csatlakozáskor + Hiba történt a csoporthoz való csatlakozáskor Kedvenc Csoport moderálása Fájl Csoporthivatkozás a titkosítás újraegyeztetése szükséges %s számára - Hiba a profilváltáskor! + Hiba történt a profilváltáskor! Kísérleti funkciók Engedélyezés (felülírások megtartásával) Adja meg a helyes jelmondatot. @@ -516,7 +516,7 @@ A zárolási képernyőn megjelenő hívások engedélyezése a Beállításokban. titkosítás elfogadva Engedélyezi a kézbesítési jelentéseket? - Hiba a csoportprofil mentésekor + Hiba történt a csoportprofil mentésekor hiba A fájl törölve lesz a kiszolgálókról. Akkor is, ha le van tiltva a beszélgetésben. @@ -526,52 +526,52 @@ Teljesen decentralizált – csak a tagok számára látható. Fájl: %s Hívás befejezése - Hiba a csoporthivatkozás törlésekor + Hiba történt a csoporthivatkozás törlésekor Fájl elmentve Kapcsolat javítása? Fájlok és médiatartalmak KONZOLHOZ Nem sikerült a titkosítást újraegyeztetni. - Hiba 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 Adja meg a jelszót a keresőben A fájl akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Fájl letöltése - A csevegés betöltése sikertelen + Nem sikerült betölteni a csevegést Adja meg a kiszolgálót kézzel A fájl akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! - Hiba a csoporthivatkozás létrehozásakor + Hiba történt a csoporthivatkozás létrehozásakor A galériából Engedélyezés (csoport felülírások megtartásával) - Hiba a partner törlésekor + Hiba történt a partner törlésekor A tagok véglegesen törölhetik az elküldött üzeneteiket. (24 óra) - Hiba a szerepkör módosításakor + Hiba történt a szerepkör módosításakor Javítás A tagok küldhetnek eltűnő üzeneteket. Kapcsolat javítása - Hiba a profil létrehozásakor! - Hiba a tag(ok) hozzáadásakor + Hiba történt a profil létrehozásakor! + Hiba történt a tag(ok) hozzáadásakor Fájl A tagok küldhetnek fájlokat és médiatartalmakat. Törlés ennyi idő után - Hiba a beállítás módosításakor - Hiba a csoporthivatkozás frissítésekor + Hiba történt a beállítás módosításakor + Hiba történt a csoporthivatkozás frissítésekor a csoport törölve csoportprofil frissítve - Hiba a függőben lévő meghívó törlésekor - Hiba a csevegési adatbázis importálásakor - Hiba a kézbesítési jelentések engedélyezésekor! - Hiba az XFTP-kiszolgálók mentésekor + Hiba történt a függőben lévő meghívó törlésekor + Hiba történt a csevegési adatbázis importálásakor + Hiba történt a kézbesítési jelentések engedélyezésekor! + Hiba történt az XFTP-kiszolgálók mentésekor A tagok küldhetnek egymásnak közvetlen üzeneteket. - Hiba a tag eltávolításakor + Hiba történt a tag eltávolításakor befejeződött A csoport üdvözlőüzenete Adja meg a csoport nevét: - Hiba a meghívó küldésekor + Hiba történt a meghívó elküldésekor Adjon meg egy nevet: - Hiba a felhasználó jelszavának mentésekor + Hiba történt a felhasználó jelszavának mentésekor Téma exportálása Adja meg ennek az eszköznek a nevét… Hiba @@ -580,10 +580,10 @@ súgó Önmegsemmisítő-jelkód engedélyezése KÍSÉRLETI - Hiba a cím módosításának megszakításakor - Hiba a fájl fogadásakor + Hiba történt a cím módosításának megszakításakor + Hiba történt a fájl fogadásakor titkosítás rendben - Hiba a kapcsolatkérés törlésekor + Hiba történt a meghívási kérés törlésekor Engedélyezi a kézbesítési jelentéseket a csoportok számára? Partner általi javítás nem támogatott Fájl nem található @@ -592,23 +592,23 @@ Adatbázis exportálása Teljes név: Tovább csökkentett akkumulátor-használat - Hiba a csevegés megállításakor + Hiba történt a csevegés megállításakor titkosítás rendben %s számára A csoport törölve lesz az összes tag számára – ez a művelet nem vonható vissza! Titkosítás javítása az adatmentések helyreállítása után. - Hiba a csevegési adatbázis törlésekor + Hiba történt a csevegési adatbázis törlésekor Teljes hivatkozás - Hiba a cím módosításakor + Hiba történt a cím módosításakor A tagok küldhetnek hangüzeneteket. Csoportbeállítások Hiba: %s Eltűnő üzenetek SimpleX-zár bekapcsolása - Hiba a kapcsolat szinkronizálásakor - Hiba a cím létrehozásakor + Hiba történt a kapcsolat szinkronizálásakor + Hiba történt a cím létrehozásakor engedélyezve - Hiba a részletek betöltésekor - Hiba történt a kapcsolatkérés elfogadásakor + Hiba történt a részletek betöltésekor + Hiba történt a meghívási kérés elfogadásakor a titkosítás újraegyeztetése engedélyezve van %s számára a titkosítás újraegyeztetése szükséges Rejtett csevegési profilok @@ -621,7 +621,7 @@ Hogyan használja a saját kiszolgálóit Csevegési üzenetek gyorsabb megtalálása Téma importálása - Hiba a téma importálásakor + Hiba történt a téma importálásakor Partner nevének és az üzenet tartalmának elrejtése Nem kompatibilis adatbázis-verzió Hogyan működik a SimpleX @@ -659,7 +659,7 @@ A fájlok- és a médiatartalmak küldése le van tiltva. Hogyan működik Elrejtés: - Hiba a partnerrel történő kapcsolat létrehozásában + Hiba történt a partnerrel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) beolvashatja a QR-kódot a videohívásban, vagy a partnere megoszthat egy meghívási hivatkozást.]]> Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! @@ -737,7 +737,7 @@ Az onion-kiszolgálók nem lesznek használva. perc Tudjon meg többet - Új kapcsolatkérés + Új meghívási kérés Csatlakozás a csoporthoz Társított számítógép beállítások meghíva az Ön csoporthivatkozásán keresztül @@ -775,11 +775,11 @@ érvénytelen adat Győződjön meg arról, hogy a megadott WebRTC ICE-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Csak a csoport tulajdonosai engedélyezhetik a fájlok- és a médiatartalmak küldését. - A fájl betöltése… + Fájl betöltése… Nincs hozzáadandó partner Üzenetvázlat - meghívta egy partnerét - Egyszer használható meghívási hivatkozás + Függőben lévő meghívó + Egyszer használható meghívó Értesítések Egyszerre csak 10 kép küldhető el ajánlotta: %s, ekkor: %2s @@ -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. Az üzenet visszafejtése sikertelen volt, 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 visszafejteni az üzenetet, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült. megfigyelő inkognitó a csoporthivatkozáson keresztül Onion-kiszolgálók használata, ha azok rendelkezésre állnak. @@ -820,9 +820,9 @@ csatlakozás mint %s Nincs csevegés kijelölve Csak helyi profiladatok - inkognitó egy egyszer használható meghívási hivatkozáson keresztül + inkognitó egy egyszer használható meghívón keresztül Moderálva: %s - Egyszer használható meghívási hivatkozás + Egyszer használható meghívó Érvénytelen név! Beszélgessünk a SimpleX Chatben Moderálva @@ -871,7 +871,7 @@ elutasított hívás Időszakos fogadott, tiltott - Megismétli a kapcsolódási kérést? + Megismétli a meghívási kérést? Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) Szerepkör SimpleX kapcsolattartási cím @@ -906,8 +906,8 @@ Csak Ön tud hívásokat indítani. Biztonságos sorba állítás Értékelje az alkalmazást - Egyszer használható meghívási hivatkozás megosztása - Hiba az adatbázis visszaállításakor + Egyszer használható meghívó megosztása + Hiba történt az adatbázis visszaállításakor %s és %s Ön engedélyezi Csökkentett akkumulátor-használat @@ -926,7 +926,7 @@ (beolvasás, vagy beillesztés a vágólapról) Várakozás a videóra Válasz - Ez az Ön egyszer használható meghívási hivatkozása! + Ez az Ön egyszer használható meghívója! SimpleX Chat hívások Új inkognitóprofil használata Frissítse az alkalmazást, és lépjen kapcsolatba a fejlesztőkkel. @@ -958,7 +958,7 @@ Adatbázismentés visszaállítása Visszavonás Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését. - Ön egy egyszer használható meghívási hivatkozást osztott meg + Ön egy egyszer használható meghívót osztott meg A hivatkozás megnyitása a böngészőben gyengítheti az adatvédelmet és a biztonságot. A megbízhatatlan SimpleX-hivatkozások pirossal vannak kiemelve. Saját ICE-kiszolgálók Kapcsolat létrehozása @@ -1070,7 +1070,7 @@ tulajdonos Bekapcsolás %s, %s és %s kapcsolódott - Egyszer használható SimpleX-meghívási hivatkozás + Egyszer használható SimpleX-meghívó Hívások nem sikerült elküldeni KEZELŐFELÜLET SZÍNEI @@ -1096,12 +1096,12 @@ Adatbázis-jelmondat beállítása Biztonsági kód megtekintése Feloldja a tag letiltását? - A küldő törölhette a kapcsolatkérést. + A küldője törölhette a meghívási kérést. Érvénytelen adatbázis-jelmondat Saját SMP-kiszolgálók A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása - egy egyszer használható meghívási hivatkozáson keresztül + egy egyszer használható meghívón keresztül Csoportbeállítások megadása a következőn keresztül: %1$s igen @@ -1115,7 +1115,7 @@ Megállítás Megállítja a címmegosztást? Csevegési profilok módosítása - Megismétli a csatlakozási kérést? + Megismétli a meghívási kérést? Várakozás a képre Hangüzenetek Eltávolítja a tagot? @@ -1316,12 +1316,12 @@ Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg a partnerét, hogy küldjön egy másikat. A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel. Érvénytelen fájlelérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. - Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet. + Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %1$s). A fájl fogadása le fog állni. Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani! A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését. - Ön egy egyszer használható meghívási hivatkozást osztott meg inkognitóban + Ön egy egyszer használható meghívót osztott meg inkognitóban Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Később engedélyezheti a „Beállításokban” Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! @@ -1346,7 +1346,7 @@ 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ó - Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később! + 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.]]> Biztonságos kvantumálló-protokollon keresztül. @@ -1384,7 +1384,7 @@ Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> Ez a karakterlánc nem egy meghívási hivatkozás! Új csevegés indításához - A kapcsolódás már folyamatban van ezen az egyszer használható meghívási hivatkozáson keresztül! + A kapcsolódás már folyamatban van ezen az egyszer használható meghívón keresztül! Nem veszíti el a partnereit, ha később törli a címét. A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! @@ -1404,13 +1404,13 @@ Ön irányítja csevegését! Kód hitelesítése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. - A kapcsolatkérés el lesz küldve ezen csoporttag számára. + A meghívási kérés el lesz küldve ezen csoporttag számára. Inkognitóprofil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott. - Már küldött egy kapcsolatkérést ezen a címen keresztül! + Már küldött egy meghívási kérést ezen a címen keresztül! Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: %s. - Amikor az emberek kapcsolatot kérnek, Ön elfogadhatja vagy elutasíthatja azokat. + Amikor az emberek meghívót küldenek, Ön elfogadhatja vagy elutasíthatja azokat. Megjelenítendő üzenet beállítása az új tagok számára! - Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. Protokoll időtúllépése kB-onként Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. @@ -1421,7 +1421,7 @@ Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. A kézbesítési jelentések engedélyezve vannak %d partnernél Küldés a következőn keresztül: - Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. Bluetooth támogatás és további fejlesztések. Ez a funkció még nem támogatott. Próbálja meg a következő kiadásban. @@ -1434,13 +1434,13 @@ Jelmondat beállítása az exportáláshoz 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 – hozzájárulás a Weblate-en! + 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. 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. Jelmondat mentése a Keystore-ba - Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! 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! ✅ @@ -1454,7 +1454,7 @@ A profil aktiválásához koppintson az ikonra. A kézbesítési jelentések le vannak tiltva %d partnernél Munkamenet kód - Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + Köszönet a felhasználóknak a Weblate-en való közreműködésért! Kis csoportok (max. 20 tag) Az Ön által elfogadott kérelem vissza lesz vonva! Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet @@ -1479,8 +1479,8 @@ Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub tárolónkban.]]> - hiba a tartalom megjelenítésekor - hiba az üzenet megjelenítésekor + Hiba történt a tartalom megjelenítésekor + Hiba történt az üzenet megjelenítésekor Láthatóvá teheti a SimpleXbeli partnerei számára a „Beállításokban”. Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás. @@ -1503,9 +1503,9 @@ Vagy mutassa meg ezt a kódot Kamera hozzáférés engedélyezése Megtartja a fel nem használt meghívót? - Ennek az egyszer használható meghívási hivatkozásnak a megosztása + Ennek az egyszer használható meghívónak a megosztása Új csevegés - A csevegések betöltése… + Csevegések betöltése… Hivatkozás létrehozása… Vagy QR-kód beolvasása Érvénytelen QR-kód @@ -1553,8 +1553,8 @@ frissített profil %1$s a következőre módosította a nevét: %2$s Privát jegyzetek - Hiba a privát jegyzetek törlésekor - Hiba az üzenet létrehozásakor + Hiba történt a privát jegyzetek törlésekor + Hiba történt az üzenet létrehozásakor Kiüríti a privát jegyzeteket? Létrehozva Mentett üzenet @@ -1581,21 +1581,21 @@ Feloldás Az összes tag számára feloldja a tag letiltását? Ön letiltotta őt: %s - Hiba a tag az összes csoporttag számára való letiltásakor + Hiba történt a tag az összes csoporttag számára való letiltásakor Az üzenet túl nagy Az üdvözlőüzenet túl hosszú Az adatbázis átköltöztetése folyamatban van.\nEz eltarthat néhány percig. Hanghívás A hívás befejeződött Videóhívás - Hiba a böngésző megnyitásakor + Hiba történt a böngésző megnyitásakor A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további információkat a SimpleX Chat fejlesztőivel. Hálózati beállítások megerősítése - Hiba a csevegési adatbázis exportálásakor + Hiba történt a csevegési adatbázis exportálásakor Alkalmaz Archiválás és feltöltés Feltöltés megerősítése - Hiba az adatbázis törlésekor + Hiba történt az adatbázis törlésekor Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak. Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra. Alkalmazásadatok átköltöztetése @@ -1610,10 +1610,10 @@ Letöltési hivatkozás részletei Engedélyezés a közvetlen csevegésekben (BÉTA)! Adja meg a jelmondatot - Hiba a beállítások mentésekor - Hiba az archívum letöltésekor - Hiba az archívum feltöltésekor - Hiba a jelmondat hitelesítésekor: + Hiba történt a beállítások mentésekor + Hiba történt az archívum letöltésekor + Hiba történt az archívum feltöltésekor + Hiba történt a jelmondat hitelesítésekor: Az exportált fájl nem létezik A fájl törölve lett, vagy érvénytelen a hivatkozás %s letöltve @@ -1628,7 +1628,7 @@ Vagy az archívum hivatkozásának beillesztése Archívum hivatkozásának beillesztése Letöltés ismét - Sikertelen importálás + Nem sikerült az importálás Ellenőrizze, hogy a hálózati beállítások megfelelők-e ehhez az eszközhöz. A folytatáshoz a csevegést meg kell szakítani. Csevegés megállítása folyamatban @@ -1666,7 +1666,7 @@ Ez a csevegés végpontok közötti kvantumálló titkosítással védett. végpontok közötti titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> végpontok közötti kvantumálló titkosítással sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve.]]> - Hiba az értesítés megjelenítésekor, lépjen kapcsolatba a fejlesztőkkel. + 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 Engedély(ek) megadása a hívások kezdeményezéséhez @@ -1797,7 +1797,7 @@ Privát üzenet-útválasztás 🚀 Fájlok biztonságos fogadása Csökkentett akkumulátor-használattal. - Hiba a WebView előkészítésekor. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.\nHiba: %s + Hiba történt a WebView előkészítésekor. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.\nHiba: %s Felhasználó által létrehozott téma visszaállítása Üzenetsorbaállítási információ nincs @@ -1839,9 +1839,9 @@ Proxyzott kiszolgálók Újrakapcsolódik a kiszolgálókhoz? Újrakapcsolódik a kiszolgálóhoz? - Hiba a kiszolgálóhoz való újrakapcsolódáskor + Hiba történt a kiszolgálóhoz való újrakapcsolódáskor Újrakapcsolódás az összes kiszolgálóhoz - Hiba a statisztikák visszaállításakor + Hiba történt a statisztikák visszaállításakor Visszaállítás Az összes statisztika visszaállítása Visszaállítja az összes statisztikát? @@ -1892,7 +1892,7 @@ Fogadott üzenetek Letöltési hibák Hiba - Hiba a kiszolgálókhoz való újrakapcsolódáskor + Hiba történt a kiszolgálókhoz való újrakapcsolódáskor Fájlok Betűméret Nincs információ, próbálja meg újratölteni @@ -1932,9 +1932,9 @@ Letiltás Letiltva Stabil - Hiba a(z) %1$s továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. + Hiba történt a(z) %1$s továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. A(z) %1$s célkiszolgáló verziója nem kompatibilis a(z) %2$s továbbítókiszolgálóval. - A(z) %1$s továbbítókiszolgáló nem tudott csatlakozni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. + A(z) %1$s továbbítókiszolgáló nem tudott kapcsolódni a(z) %2$s célkiszolgálóhoz. Próbálja meg később. A(z) %1$s célkiszolgáló címe nem kompatibilis a(z) %2$s továbbítókiszolgáló beállításaival. Médiatartalom elhomályosítása Közepes @@ -2016,7 +2016,7 @@ Új üzenet Érvénytelen hivatkozás Ellenőrizze, hogy a SimpleX-hivatkozás helyes-e. - Hiba a profilváltáskor + Hiba történt a profilváltáskor A kapcsolata át lett helyezve ide: %s, de egy váratlan hiba történt a profilra való átirányításkor. Az üzenetek törölve lesznek – ez a művelet nem vonható vissza! Eltávolítja az archívumot? @@ -2031,14 +2031,14 @@ Jelszó Felhasználónév A hitelesítőadatai titkosítatlanul is elküldhetők. - Hiba a proxy mentésekor + Hiba történt a proxy mentésekor Győződjön meg arról, hogy a proxy konfigurációja helyes. Proxyhitelesítés Véletlenszerű hitelesítőadatok használata %1$d egyéb fájlhiba. Nincs mit továbbítani! %1$d fájl letöltése még folyamatban van. - %1$d fájlt nem sikerült letölteni. + Nem sikerült letölteni %1$d fájlt. %1$d fájl nem lett letöltve. Letöltés %1$d fájl törölve lett. @@ -2050,9 +2050,9 @@ Továbbítja az üzeneteket fájlok nélkül? Az üzeneteket törölték miután kijelölte őket. %1$s üzenet mentése - Hiba az üzenetek továbbításakor + Hiba történt az üzenetek továbbításakor Hang elnémítva - Hiba a WebView előkészítésekor. Győződjön meg arról, hogy a WebView telepítve van-e, és támogatja-e az arm64 architektúrát.\nHiba: %s + Hiba történt a WebView előkészítésekor. Győződjön meg arról, hogy a WebView telepítve van-e, és támogatja-e az arm64 architektúrát.\nHiba: %s Sarok Üzenetbuborék alakja Farok @@ -2066,19 +2066,19 @@ Továbbfejlesztett hívásélmény Továbbfejlesztett üzenetdátumok. Továbbfejlesztett felhasználói élmény - Testre szabható üzenetbuborékok. + Személyre szabható üzenetbuborékok. Legfeljebb 200 üzenet egyszerre való törlése, vagy moderálása. Legfeljebb 20 üzenet egyszerre való továbbítása. Hang/Videó váltása hívás közben. - Csevegési profilváltás az egyszer használható meghívási hivatkozásokhoz. + 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. - Hiba a kiszolgálók mentésekor + Hiba történt a kiszolgálók mentésekor Nincsenek üzenet-kiszolgálók. Nincsenek üzenetfogadási kiszolgálók. Nincsenek média- és fájlkiszolgálók. A(z) %s nevű csevegési profilhoz: - Cím vagy egyszer használható meghívási hivatkozás? + Cím vagy egyszer használható meghívó? Új kiszolgáló Címbeállítások Előre beállított kiszolgálók @@ -2088,7 +2088,7 @@ Nincsenek fájlküldő-kiszolgálók. Nincsenek fájlfogadási kiszolgálók. Hibák a kiszolgálók konfigurációjában. - Hiba a feltételek elfogadásakor + Hiba történt a feltételek elfogadásakor Kézbesítetlen üzenetek A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön partnere lehet, hogy offline állapotban van. Nincs üzenet @@ -2096,17 +2096,17 @@ Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. - Egyszer használható meghívási hivatkozás megosztása egy baráttal - egyetlen partnerrel használható – személyesen vagy bármilyen üzenetváltón keresztül megosztható.]]> + Egyszer használható meghívó megosztása egy baráttal + csak egyetlen partnerrel használható – személyesen vagy bármilyen üzenetváltó-alkalmazáson keresztül megosztható.]]> Beállíthatja a partner nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. Kapcsolatbiztonság - A SimpleX-cím és az egyszer használható meghívási hivatkozás biztonságosan megosztható bármilyen üzenetváltón keresztül. + A SimpleX-cím és az egyszer használható meghívó biztonságosan megosztható bármilyen üzenetváltó-alkalmazáson keresztül. A hivatkozás cseréje elleni védelem érdekében összehasonlíthatja a biztonsági kódokat a partnerével. A közösségi médiához Vagy a privát megosztáshoz - SimpleX-cím vagy egyszer használható meghívási hivatkozás? - Egyszer használható meghívási hivatkozás létrehozása - Kiszolgáló-üzemeltetők + SimpleX-cím vagy egyszer használható meghívó? + Egyszer használható meghívó létrehozása + Kiszolgálóüzemeltetők Hálózatüzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. @@ -2130,7 +2130,7 @@ A feltételek el lesznek elfogadva a következő időpontban: %s. Kiszolgálók használata %s használata - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: %s.]]> %s.]]> %s.]]> @@ -2149,17 +2149,17 @@ Hozzáadott média- és fájlkiszolgálók Feltételek megnyitása Módosítások megtekintése - Hiba a kiszolgáló frissítésekor + Hiba történt a kiszolgáló frissítésekor A kiszolgáló-protokoll módosult. A kiszolgáló üzemeltetője módosult. Kiszolgáló-üzemeltető Kiszolgáló hozzáadva a következő üzemeltetőhöz: %s. - Hiba a kiszolgáló hozzáadásakor + Hiba történt a kiszolgáló hozzáadásakor Átlátszóság Elhomályosítás Hálózati decentralizáció A második előre beállított üzemeltető az alkalmazásban! - Flux engedélyezése + A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a jobb metaadat-adatvédelem érdekében. Alkalmazás-eszköztárak a metaadatok jobb védelme érdekében. Javított csevegési navigáció @@ -2205,7 +2205,7 @@ A tagok közötti közvetlen üzenetek le vannak tiltva ebben a csevegésben. Amikor egynél több üzemeltető van engedélyezve, akkor egyik sem rendelkezik olyan metaadatokkal, amelyekből megtudható, hogy ki kivel kommunikál. elfogadott meghívó - kérelmezve a kapcsolódáshoz + Függőben lévő meghívási kérelem Az üzemeltetőkről A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba. A titkosítás újraegyeztetése folyamatban van. @@ -2213,7 +2213,7 @@ Javítás Kapcsolat javítása? Naplózás engedélyezése - Hiba az adatbázis mentésekor + Hiba történt az adatbázis mentésekor áthúzott A következő csevegési profil törlése Üzenetek törlése ennyi idő után @@ -2225,9 +2225,9 @@ Lista hozzáadása Összes Hozzáadás listához - Hiba a csevegési lista létrehozásakor - Hiba a csevegési lista betöltésekor - Hiba a csevegési lista frissítésekor + Hiba történt a csevegési lista létrehozásakor + Hiba történt a csevegési lista betöltésekor + Hiba történt a csevegési lista frissítésekor Üzleti Partnerek Kedvencek @@ -2246,8 +2246,8 @@ Jegyzetek Lista módosítása Elrendezés módosítása - Hiba a jelentés létrehozásakor - Hiba a beállítások mentésekor + Hiba történt a jelentés létrehozásakor + Hiba történt a beállítások mentésekor A jelentés archiválva lesz az Ön számára. Tartalom jelentése: csak a csoport moderátorai látják. Archívum @@ -2328,7 +2328,7 @@ A jelmondat nem olvasható a Keystore-ban, ezért kézzel szükséges megadni. Ez az alkalmazással nem kompatibilis rendszerfrissítés után történhetett meg. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. függőben jóváhagyásra vár - Hiba az adatbázis-jelmondat olvasásakor + Hiba történt az adatbázis-jelmondat olvasásakor A jelmondat nem olvasható a Keystore-ban. Ez az alkalmazással nem kompatibilis rendszerfrissítés után történhetett meg. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. Frissített feltételek A tagok el lesznek távolítva a csoportból – ez a művelet nem vonható vissza! @@ -2339,4 +2339,9 @@ Ezen tagok összes új üzenete el lesz rejtve! A tagok összes üzenete meg fog jelenni! moderátorok + Elfogadás + A SimpleX Chat használatával Ön elfogadja, hogy:\n- csak elfogadott tartalmakat tesz közzé a nyilvános csoportokban.\n- tiszteletben tartja a többi felhasználót, és nem küld kéretlen tartalmat senkinek. + Adatvédelmi szabályzat és felhasználási feltételek. + A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára. + Kiszolgálóüzemeltetők beállítása 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 835b21db45..278b94feb3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -1788,7 +1788,7 @@ Kesalahan Keychain Simpan frasa sandi dan buka obrolan Buka obrolan - Frasa sandi tidak ditemukan di Keystore, silakan masukkan secara manual. Hal ini mungkin terjadi jika Anda pulihkan data aplikasi menggunakan alat cadangan. Jika tidak, silakan hubungi pengembang. + Frasa sandi tidak ditemukan di Keystore, silakan masukkan secara manual. Hal ini mungkin terjadi jika Anda memulihkan data aplikasi menggunakan alat cadangan. Jika tidak demikian, silakan hubungi pengembang. Toolbar aplikasi yang dijangkau Anggota akan dihapus dari grup - ini tidak dapat dibatalkan! Batas waktu protokol per KB @@ -2316,4 +2316,25 @@ Laporkan pesan dilarang di grup ini. Dilarang laporkan pesan ke moderator. Arsipkan %d laporan? + Gagal membaca frasa sandi basis data + Privasi dan keamanan lebih baik + Semua pesan baru dari anggota ini akan disembunyikan! + Blokir anggota untuk semua? + Dapat notifikasi saat disebut. + Kirim pesan lebih cepat. + Jangan lewatkan pesan penting. + Kinerja grup yang lebih baik + Hapus grup lebih cepat. + Syarat diperbarui + Anggota akan dihapus dari obrolan - hal ini tidak dapat dibatalkan! + Hapus anggota? + ditolak + tertunda + menunggu persetujuan + Buka blokir anggota untuk semua? + Kirim laporan pribadi + Bantu admin memoderasi grup. + Atur pesan kedaluwarsa obrolan. + Nama berkas media pribadi. + ditolak 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 d9b207f27a..08a8f9b87e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1586,7 +1586,7 @@ Errore di eliminazione delle note private Consegna dei messaggi migliorata Entra in conversazioni di gruppo - membro %1$s cambiato in %2$s + il membro %1$s è diventato %2$s Incolla un link per connettere! indirizzo di contatto rimosso contatto %1$s cambiato in %2$s @@ -2177,7 +2177,7 @@ Trasparenza Decentralizzazione della rete Il secondo operatore preimpostato nell\'app! - Attiva Flux + Attiva Flux nelle impostazioni \"Rete e server\" per una migliore privacy dei metadati. Vedi le condizioni aggiornate Sfocatura Server dei messaggi aggiunti @@ -2376,4 +2376,9 @@ Bloccare i membri per tutti? moderatori Tutti i nuovi messaggi di questi membri verranno nascosti! + Usando SimpleX Chat accetti di:\n- inviare solo contenuto legale nei gruppi pubblici.\n- rispettare gli altri utenti - niente spam. + Le chat private, i gruppi e i tuoi contatti non sono accessibili agli operatori dei server. + Accetta + Configura gli operatori dei server + Informativa sulla privacy e condizioni d\'uso. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 8a92168d26..21c04b8473 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1993,4 +1993,19 @@ 利用条件の承諾 %s の利用条件に承諾しています。]]> 後で作成する場合はメニューから「SimpleXのアドレスを作成」を選択してください。 + 運営者について + 1年 + 1 件のレポート + モデレーターにメッセージを報告することを許可する + これらのメンバーからの新しいメッセージはすべて非表示になります! + リストに追加 + アドレス設定 + チームのメンバーを会話に追加する + 追加されたメッセージサーバー + アドレスか使い捨てのリンク? + 友達を追加 + チームメンバーを追加 + リストを追加 + すべて + ワンタイムリンクを生成 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 686b1959d5..d0b3cd5f53 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -2281,7 +2281,7 @@ Lijst wijzigen Wijzig volgorde ‐Fout bij het opslaan van instellingen - Fout bij maken van rapport + Fout bij het rapporteren Archief Schending van de communityrichtlijnen Een andere reden @@ -2295,7 +2295,7 @@ Alleen jij en moderators zien het Spam Rapport archiveren? - Rapport + rapporteren Reden melding? Het rapport wordt voor u gearchiveerd. Anders melden: alleen groepsmoderators kunnen het zien. @@ -2341,22 +2341,36 @@ Voor mij Leden kunnen berichten melden bij moderators. Het melden van berichten in deze groep is niet toegestaan - Rapport: %s + Rapporteer: %s Het melden van berichten aan moderators is niet toegestaan. Voor alle moderators Rapporten archiveren Betere prestaties van groepen Stel de berichtvervaldatum in chats in. Betere privacy en veiligheid - ‐Mis geen belangrijke berichten. + Mis geen belangrijke berichten. Sneller verwijderen van groepen. Ontvang een melding als u vermeld wordt. Help beheerders bij het modereren van hun groepen. Vermeld leden 👋 Organiseer chats in lijsten Namen van persoonlijke mediabestanden. - Verstuur rapporten privé + Rapporteer privé Sneller verzenden van berichten. afgewezen afgewezen + Fout bij het lezen van database wachtwoord + Alle nieuwe berichten van deze leden worden verborgen! + Leden voor iedereen blokkeren? + Leden worden uit de chat verwijderd. Dit kan niet ongedaan worden gemaakt! + Leden worden uit de groep verwijderd. Dit kan niet ongedaan worden gemaakt! + Leden voor iedereen deblokkeren? + Berichten van deze leden worden getoond! + moderatoren + Wachtwoord in Keystore kan niet worden gelezen, voer deze handmatig in. Dit kan zijn gebeurd na een systeemupdate die niet compatibel is met de app. Als dit niet het geval is, neem dan contact op met de ontwikkelaars. + in afwachting van goedkeuring + Leden verwijderen? + Bijgewerkte voorwaarden + Wachtwoord in Keystore kan niet worden gelezen. Dit kan zijn gebeurd na een systeemupdate die niet compatibel is met de app. Als dit niet het geval is, neem dan contact op met de ontwikkelaars. + in behandeling diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 184548e0da..a0cce488af 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -341,7 +341,7 @@ Ocultar Da Galeria Vídeo - Os membros do grupo podem enviar mensagens temporárias. + Os membros podem enviar mensagens temporárias. Arquivo Nome completo: Chamada de áudio recebida @@ -360,7 +360,7 @@ Mensagens que desaparecem Preferências do grupo Mensagens temporárias são proibidas nesse bate-papo. - Os membros do grupo podem enviar mensagens diretas. + Os membros podem enviar mensagens diretas. %dmês Link completo Ocultar @@ -377,7 +377,7 @@ Falha ao carregar as conversas Arquivo: %s Arquivo salvo - Os membros do grupo podem enviar mensagens de voz. + Os membros podem enviar mensagens de voz. O grupo será excluído para todos os membros - isso não pode ser desfeito! AJUDA Ocultar contato e mensagem @@ -406,7 +406,7 @@ perfil do grupo atualizado Grupo excluído O modo Incognito protege sua privacidade usando um novo perfil aleatório para cada contato. - Os membros do grupo podem excluir mensagens enviadas de forma irreversível. (24 horas) + Os membros grupo podem excluir mensagens enviadas de forma irreversível. (24 horas) %dsemana Configuração de servidor aprimorada Interface francesa @@ -534,7 +534,7 @@ Suas preferências Definir preferências de grupo Somente você pode excluir irreversivelmente as mensagens (seu contato pode marcá-las para exclusão). (24 horas) - A exclusão irreversível de mensagens é proibida neste grupo. + A exclusão irreversível de mensagens é proibida. Os destinatários vêem as atualizações conforme você as digita. Uso da bateria reduzido Mais melhorias chegarão em breve! @@ -641,7 +641,7 @@ \nAtenção: você não será capaz de se conectar aos servidores sem um endereço .onion Versão principal: v%s repositório do GitHub.]]> - Pode ser mudado mais tarde via configurações. + Como isso afeta a bateria %1$s quer se conectar com você via sem criptografia ponta-a-ponta Abrir @@ -849,7 +849,7 @@ Para começar um novo bate-papo Ligar Bem-vindo(a)! - A próxima geração \nde mensageiros privados + O futuro da transmissão de mensagens PROXY SOCKS A tentativa de alterar a senha do banco de dados não foi concluída. Pare o bate-papo para exportar, importar ou excluir o banco de dados do chat. Você não poderá receber e enviar mensagens enquanto o chat estiver interrompido. @@ -862,7 +862,7 @@ A atualização das configurações reconectará o cliente a todos os servidores. Atualizar Sistema - Mensagens de voz são proibidas neste grupo. + Mensagens de voz são proibidas. Verificar a segurança da conexão Para proteger o fuso horário, os arquivos de imagem/voz usam UTC. formato de mensagem desconhecido @@ -1061,7 +1061,7 @@ Senha alterada! Você pode ativar o bloqueio SimpleX via configurações. Hash de mensagem incorreta - O hash da mensagem anterior é diferente. + O hash da mensagem anterior é diferente.\" %1$d mensagens falharam em serem descriptografadas. ID de mensagem incorreta A ID da próxima mensagem está incorreta (menor ou igual à anterior). @@ -1171,7 +1171,7 @@ Permitir reações à mensagens. Somente você pode adicionar reações à mensagens. Somente seu contato pode adicionar reações à mensagens. - Reações à mensagens são proibidas neste grupo. + Reações a mensagens são proibidas. horas minutos segundos @@ -1199,7 +1199,7 @@ %s (atual) Permitir que seus contatos adicionem reações à mensagens. Você e seu contato podem adicionar reações à mensagens. - Os membros do grupo podem adicionar reações às mensagens. + Os membros podem adicionar reações. Reações à mensagens são proibidas neste bate-papo. Proibir reações à mensagens. personalizado @@ -1265,7 +1265,7 @@ Erro ao sincronizar conexão Favorito Arquivos e mídia proibidos! - Os membros do grupo podem enviar arquivos e mídia. + Os membros podem enviar arquivos e mídias. Corrigir Correção não suportada pelo contato Desligar @@ -1800,7 +1800,7 @@ Aviso: iniciar conversa em múltiplos dispositivos não é suportado e pode causar falhas na entrega de mensagens Internet cabeada não deve usar a mesma base de dados em dois dispositivos.]]> - Membros do grupo podem enviar link SimpleX + Membros podem enviar links SimpleX. Importando arquivo Modo claro Ativado para @@ -1897,7 +1897,7 @@ Escala Preencher Ajustar - Links SimpleX são proibidos neste grupo. + Links SimpleX são proibidos. Migrar para outro dispositivo via QR code. Chamadas picture-in-picture Use o aplicativo enquanto está em chamada. @@ -1982,7 +1982,7 @@ Sessões de transporte Recepção de mensagem Pendente - Começando de %s. \nTodos os dados são privados do seu dispositivo. + Começando em %s.\nTodos os dados são mantidos privados em seu dispositivo. Total Servidores proxiados Servidores conectados anteriormente @@ -2055,7 +2055,7 @@ Por favor reinicie o aplicativo. Me lembre mais tarde Para ser notificado sobre os novos lançamentos, habilite a checagem periódica de versões Estáveis e Beta. - Barra de ferramentas de conversa acessível + Barras de ferramentas de aplicativos acessível Falha no baixar de %1$d arquivo(s). %1$s mensagens não encaminhadas. DADOS DO BATE-PAPO @@ -2196,7 +2196,7 @@ Criar lista Editar Canto - Ativar flux + Ativar o Flux nas Configurações de rede e servidores para melhor privacidade de metadados. Todas denúncias serão arquivadas para você. Arquivar todas denúncias? Arquivar %d denúncias? @@ -2220,4 +2220,166 @@ Erro ao criar denúncia Chat Seus servidores + aprovação pendente + pendente + Os membros podem denunciar mensagens aos moderadores. + Operadores da rede + Operador + Nenhum serviço de segundo plano + Abrir mudanças + moderadores + Descentralização da rede + Privacidade para seus clientes. + Mencione membros 👋 + Seja notificado quando mencionado. + Ajude os administradores a moderar seus grupos. + Organize os chats em listas + Nomes de arquivos de mídia privados. + Conteúdo inapropriado + Perfil inapropriado + Nenhuma mensagem de servidores. + Nenhuma mensagem + Denúncias de membros + Ou compartilhe em particular + Nenhum chat não lido + Nenhum chat + Notas + Abrir com %s + O nome da lista e o emoji devem ser diferentes para todas as listas. + Novas credenciais SOCKS serão usadas para cada servidor. + Notificações e bateria + As mensagens desses membros serão exibidas! + Silenciar tudo + Para redes sociais + Servidores predefinidos + Abrir links da lista de bate-papo + Abrir web link? + Convidar ao chat + Abrir condições + Nome da lista... + Novas credenciais SOCKS serão usadas toda vez que você iniciar o aplicativo. + Abrir link + Forma da mensagem + moderador + A mensagem é muito grande! + Por favor, reduza o tamanho da mensagem e a envie novamente. + Operador da rede + Para roteamento privado + Aprimorada a navegação de bate-papo + - Abra o chat na primeira mensagem não lida.\n- Pule para mensagens citadas. + Os membros serão removidos do chat. Essa ação não pode ser desfeita! + Said do chat + Os membros serão removidos do grupo. Essa ação não pode ser desfeita! + Nove servidor + Nenhum chat encontrado + As mensagens neste chat nunca serão excluídas. + A frase-senha na Keystore não pôde ser lida. Isso pode ter acontecido após uma atualização do sistema incompatível com o aplicativo. Se não for o caso, entre em contato com os desenvolvedores. + Somente os proprietários do chat podem alterar as preferências. + A frase-senha na Keystore não pôde ser lida, insira-a manualmente. Isso pode ter acontecido após uma atualização do sistema incompatível com o aplicativo. Se não for o caso, entre em contato com os desenvolvedores. + Grupos + Ou importar arquivo compactado + Lista + Reduza o tamanho da mensagem ou remova a mídia e envie novamente. + Como isso ajuda na privacidade + Não + Sair do chat? + O membro será removido do chat - essa ação não pode ser desfeita! + Encaminhe até 20 mensagens de uma vez. + Nenhuma mídia & nenhum arquivo de servidores. + Nenhum servidor para enviar arquivos. + Nenhum servidor para roteamento de mensagens privadas. + Nenhum servidor para receber arquivos. + Nenhum servidor para receber mensagens. + Abra Configurações do Safari / Websites / Microfone, e escolha Permitir para localhost. + Servidor do operador + Nenhum chat na lista %s. + Somente o remetente e os moderadores podem vê-lo. + Somente você e os moderadores podem ver isso + rejeitado + Denunciar + Proibir a denúncia de mensagens aos moderadores. + Denunciar conteúdo: somente os moderadores do grupo poderão ver. + Denunciar perfil de membro: somente moderadores do grupo poderão ver. + Dispositivos móveis remotos + Remover membros? + Denunciar outro: somente os moderadores do grupo poderão ver. + Qual é a razão da denúncia? + Denúncia: %s + rejeitado + É proibido denunciar mensagens neste grupo. + Barra de ferramentas de chat acessível + Denúncias + Operador do servidor alterado. + Definir nome do chat… + Compartilhar o endereço publicamente + Denunciar violação: somente os moderadores do grupo poderão ver. + %s servidores + Protocolos SimpleX analisados pela Trail of Bits. + Enviar denúncias privadas + Defina a expiração de mensagens em chats. + Spam + Spam + Salvar lista + Compartilhe um link único com um amigo + Endereço SimpleX ou link único? + Som silenciado + Alterne entre áudio e vídeo durante a chamada. + Selecione as operadoras de rede a serem utilizadas. + Revisar condições + Endereços SimpleX e links únicos são seguros para compartilhar por meio de qualquer mensageiro. + Operadores do servidor + Servidor adicionado ao operador %s. + riscar + Denunciar spam: somente os moderadores do grupo poderão ver. + Protocolo do servidor alterado. + Compartilhe o endereço do SimpleX nas redes sociais. + Revisar depois + Servidor + O SimpleX Chat e o Flux fizeram um acordo para incluir servidores operados pelo Flux no aplicativo. + conexão solicitada + The role will be changed to %s. Everyone in the chat will be notified. + Transparência + Alterne o perfil de chat para convites únicos. + Desbloquear membros para todos? + Para enviar + Condições atualizadas + O segundo operador predefinido no aplicativo! + Esta mensagem foi excluída ou ainda não foi recebida. + Ver condições atualizadas + Toque em Criar endereço SimpleX no menu para criá-lo mais tarde. + Usar porta TCP %1$s quando nenhuma porta for especificada. + A denúncia será arquivado para você. + Para receber + Esta ação não pode ser desfeita - as mensagens enviadas e recebidas neste chat antes da selecionada serão excluídas. + Para se proteger contra a substituição do seu link, você pode comparar os códigos de segurança dos contatos. + Menções não lidas + Porta TCP para mensagens + Usar porta web + O aplicativo protege sua privacidade usando diferentes operadores em cada conversa. + Quando mais de um operador está ativado, nenhum deles têm metadados para saber quem se comunica com quem. + Sim + Seu perfil de chat será enviado aos membros do chat + Ver condições + Usar para mensagens + Você pode definir o nome da conexão para lembrar com quem o link foi compartilhado. + Você pode configurar servidores nas configurações. + Usar %s + Usar servidores + Website + Você pode mencionar até %1$s membros por mensagem! + Final + Você pode copiar e reduzir o tamanho da mensagem para enviá-la. + Atualização + Você pode configurar operadores em Configurações de rede & servidores. + Usar para arquivos + Você deixará de receber mensagens deste chat. O histórico do chat será preservado. + Para fazer chamadas, permita usar seu microfone. Encerre a chamada e tente ligar novamente. + Os servidores para novos arquivos do seu perfil de chat atual + A conexão atingiu o limite de mensagens não entregues, seu contato pode estar offline. + Mensagens não entregues + Configurar operadores de servidor + Chats privados, grupos e seus contatos não são acessíveis aos operadores de servidor. + Aceitar + Ao usar o SimpleX Chat, você concorda em:\n- enviar apenas conteúdo legal em grupos públicos.\n- respeitar outros usuários – sem spam. + Política de privacidade e condições de uso. 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 f8f356614b..dafa22dfa9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -2231,7 +2231,7 @@ Прозрачность Децентрализация сети Второй оператор серверов в приложении! - Включить Flux + Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных. для лучшей конфиденциальности метаданных. Улучшенная навигация в разговоре Посмотреть измененные условия @@ -2354,7 +2354,7 @@ Открыто с %s Создать список Добавить в список - Список изменений + Изменить список Сохранить список Имя списка... Исправить соединение? @@ -2458,4 +2458,9 @@ Участники будут удалены из разговора - это действие нельзя отменить! модераторы Удалить членов группы? + Принять + Используя SimpleX Chat, Вы согласны:\n- отправлять только законные сообщения в публичных группах.\n- уважать других пользователей – не отправлять спам. + Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. + Настроить операторов серверов + Политика конфиденциальности и условия использования. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index c721c95663..257daec596 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -264,7 +264,7 @@ Kullanıldığında bütün veriler silinir. Kendiliğinden yok olan mesajlar Kişilerinin sana, kendiğinden yok olan mesajlar göndermesine izin ver. - Bu grupta kendiliğinden yok olan mesajlara izin verilmiyor. + Kendiliğinden yok olan mesajlara izin verilmiyor. %1$d mesajlar deşifrelenemedi. %1$s ÜYELER %1$d atlanılmış mesaj(lar) @@ -605,7 +605,7 @@ Yedekleri geri yükledikten sonra şifrelemeyi onar. Fransız arayüzü Daha da azaltılmış pil kullanımı - Grup üyeleri, mesajlara tepki ekleyebilir. + Üyeler, mesajlara tepki ekleyebilir. Grup profili, üyelerinin aygıtlarında barındırılmaktadır, sunucularda değil. Gizle Gizle @@ -648,9 +648,9 @@ Grup tam adı: Dosya ve medya Grup üyeleri doğrudan mesaj gönderebilir. - Grup üyeleri, gönderilen mesajları kalıcı olarak silebilir. (24 saat içinde) + Üyeler, gönderilen mesajları kalıcı olarak silebilir. (24 saat içinde) Grup üyeleri sesli mesaj gönderebilirler. - Bu toplu konuşmada, dosya ve medya yasaklanmıştır. + Dosya ve medya yasaklanmıştır. Grup üyeleri dosya ve medya paylaşabilir. Grup bağlantıları Konuşmada devre dışı bırakıldığında bile @@ -690,7 +690,7 @@ Android Keystore parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar. Karşılama mesajı Karşılama mesajı - Bu grupta sesli mesajlar yasaktır. + Sesli mesajlar yasaktır. Neler yeni %s sürümünde yeni Daha fazla bilgi edinin @@ -730,7 +730,7 @@ Arkadaşlarınızı davet edin kalın italik - Daha sonra ayarlardan değiştirebilirsiniz. + Pili nasıl etkiler kişi uçtan uca şifrelemeye sahiptir kişi uçtan uca şifrelemeye sahip değildir Sohbet durduruldu @@ -758,7 +758,7 @@ Japonca ve Portekizce kullanıcı arayüzü Kişiler davet edilemiyor! Davetin süresi dolmuş! - Geri alınamaz mesaj silme bu grupta yasaktır + Geri alınamaz mesaj silme yasaktır Mesaj gönderildi bilgisi! Gruba katılınıyor tek seferlik gizli bağlantı paylaştınız @@ -835,7 +835,7 @@ Alıcı adresini değiştir Mesaj tepkileri Tercihleriniz - Mesaj tepkileri bu grupta yasaklıdır + Mesaj tepkileri yasaklıdır. Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. Zaten %1$s e bağlısınız Doğrulanamadınız; lütfen tekrar deneyin. @@ -931,7 +931,7 @@ - daha stabil mesaj iletimi. \n- biraz daha iyi gruplar. \n- ve daha fazlası! - 2 katmanlı uçtan uca şifreleme ile kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar.]]> + Sadece istemci cihazlar kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar. Grup tercihlerini sadece grup sahipleri değiştirebilir. metin yok Ağ durumu @@ -940,7 +940,7 @@ Kilit modunu değiştir Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. Lütfen doğru bağlantıyı kullandığınızı kontrol edin veya irtibat kişinizden size başka bir bağlantı göndermesini isteyin. - SimpleX arka plan hizmeti kullanılır - günde pilin yüzde birkaçını kullanır.]]> + SimpleX arka planda çalışır.]]> Periyodik bildirimler Periyodik bildirimler devre dışı Bildirimleri almak için lütfen veri tabanı parolasını girin @@ -949,7 +949,7 @@ Hatırlayın veya güvenli bir şekilde saklayın - kaybolan bir parolayı kurtarmanın bir yolu yoktur! Sohbet konsolunu aç - Sohbet profillerini aö + Sohbet profillerini değiştir. Bu metin ayarlarda mevcut Filtrelenmiş sohbet yok Çok fazla görsel! @@ -1022,7 +1022,7 @@ Mesaj taslağı Sohbet profillerini parola ile koru! Daha az pil kullanımı - Gizliliği korumak için, diğer tüm platformlar gibi kullanıcı kimliği kullanmak yerine, SimpleX mesaj kuyrukları için kişilerinizin her biri için ayrı tanımlayıcılara sahiptir. + Gizliliği korumak için, SimpleX her bir konuşma için farklı bir ID kullanır. Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). Alıcılar güncellemeleri siz yazdıkça görürler. Bilgilerinizi kullanarak giriş yapın @@ -1713,7 +1713,7 @@ SimpleX bağlantısı gönderimini yasakla Eşzamanlılık alınıyor SimpleX bağlantıları - SimpleX bağlantıları bu grupta yasaklandı + SimpleX bağlantıları yasaklandı Kulaklık Hoparlör Kulaklıklar @@ -2053,14 +2053,14 @@ Ses kapatıldı Yüklendi Sunucu istatistikleri sıfırlanacaktır - bu geri alınamaz! - Erişilebilir sohbet araç çubuğu + Erişilebilir uygulama araç çubukları Görünüm ayarlarından değiştirebilirsiniz. Sohbet listesini değiştir: Sistem modu Erişilebilir sohbet araç çubuğu İçin bilgi gösteriliyor İstatistikler - %s\'den başlayarak.\nTüm veriler cihazınıza özeldir. + %s\'den başlayarak.\nTüm veriler cihazınızda gizli tutulur. Bir proxy aracılığıyla gönderildi Sunucu adresi Yükleme hataları @@ -2158,4 +2158,69 @@ Arşiv Raporu arşivleyelim mi? uçtan uca şifreli olarak gönderilir ve doğrudan mesajlarda kuantum sonrası güvenlik sağlanır.]]> + Veritabanı şifresini okurken hata oluştu + Sohbeti sil + Sohbet silinsin mi? + Bağlantı hazır değil. + Kullanım şartları + daha iyi üstveri gizliliği için. + Sunucuları kaydederken hata oluştu + Şartlar 30 gün sonra etkin operatörler için kabul edilecektir. + Raporu sil + Listeyi değiştir + %s için de geçerli olacaktır.]]> + Üyeler herkes için engellensin mi? + Topluluk kurallarının ihlali + Şartları kabul ederken hata oluştu + Sil + Liste silinsin mi? + Bağlantı güvenliği + İçerik kullanım şartlarını ihlal ediyor + Rapor oluşturulurken hata oluştu + Bağlantı engellendi + Bağlantı sunucu operatörü tarafından engellendi:\n%1$s. + Dosya sunucu operatörü tarafından engellendi:\n%1$s. + %d rapor + Mesaj çok büyük! + Otomatik silinen mesajlar değiştirilsin mi? + Otomatik silinen mesajlar devre dışı bırakılsın mı? + Liste oluştur + Sırayı değiştir + Düzenle + Sosyal medya için + Tek kullanımlık bağlantı oluştur + Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir. + varsayılan (%s) + Bu üyelerden gelen yeni mesajların hepsi gizlenecektir. + İyileştirilmiş grup performansı + Sohbet zaten var! + %1$s ile bağlısınız.]]> + Konuşma profili %s için: + Üyeler arası doğrudan mesajlaşma yasaklıdır. + Şartlar %s tarihinde etkin operatörler için otomatik olarak kabul edilecektir. + Sohbet mesajlarını cihazınızdan silin. + Her 10 dakikada mesajları kontrol et + Devam et + İyileştirilmiş gizlilik ve güvenlik + Daha hızlı mesaj gönderme. + Bu sohbette üyeler arası doğrudan mesajlaşma yasaklıdır. + Sohbet listesi yüklenirken hata oluştu + Sohbet listesi oluşturulurken hata oluştu + Sohbet listesini güncellerken hata oluştu + Sık kullanılanlar + Konuşmalar + Veritabanını kaydederken hata oluştu + Sohbet sizin için silinecek - bu geri alınamaz! + %s için de geçerli olacaktır.]]> + Bütün moderatörler için + Benim için + Ayarlar kaydedilirken hata oluştu + Günlükleri etkinleştir + Sohbet + Grupların daha hızlı silinmesi. + Sohbet bütün üyeler için silinecek - bu geri alınamaz! + Sunucu eklerken hata oluştu + Onar + Bağlantı onarılsın mı? + Sunucuyu güncellerken hata oluştu diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index b7b8248b10..311d492892 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -226,16 +226,16 @@ đang thay đổi địa chỉ… đang thay đổi địa chỉ… Bảng điều khiển trò chuyện - Ứng dụng SimpleX Chat đang hoạt động - Cơ sở dữ liệu SimpleX Chat đã bị xóa - Ứng dụng SimpleX Chat đã được dừng lại. Nếu bạn đã sử dụng cơ sở dữ liệu này trên một thiết bị khác, bạn nên chuyển nó trở lại trước khi bắt đầu ứng dụng. - Cơ sở dữ liệu SimpleX Chat đã được nhập - Ứng dụng SimpleX Chat đã được dừng lại + Kết nối trò chuyện đang hoạt động + Cơ sở dữ liệu trò chuyện đã bị xóa + Kết nối trò chuyện đã được dừng lại. Nếu bạn đã sử dụng cơ sở dữ liệu này trên một thiết bị khác, bạn nên chuyển nó trở lại trước khi khởi động kết nối. + Cơ sở dữ liệu trò chuyện đã được nhập + Kết nối trò chuyện đã được dừng lại đang thay đổi địa chỉ cho %s… Tùy chọn trò chuyện Màu trò chuyện - CƠ SỞ DỮ LIỆU SIMPLEX CHAT - Ứng dụng SimpleX Chat đã được dừng lại + CƠ SỞ DỮ LIỆU TRÒ CHUYỆN + Kết nối trò chuyện đã được dừng lại Cơ sở dữ liệu đã được di chuyển! Các cuộc trò chuyện CÁC CUỘC TRÒ CHUYỆN @@ -248,17 +248,17 @@ Kiểm tra kết nối internet của bạn và thử lại Chủ đề trò chuyện Chế độ màu - Xác minh xóa - Xóa + Xác minh dọn dẹp + Dọn dẹp Nút đóng - Xóa ghi chú riêng tư? + Dọn dẹp ghi chú riêng tư? có màu - Xóa cuộc trò chuyện - Xóa + Dọn dẹp cuộc trò chuyện + Dọn dẹp Sắp ra mắt! Di chuyển từ một thiết bị kháctrên thiết bị mới và quét mã QR.]]> - Xóa - Xóa cuộc trò chuyện? + Dọn dẹp + Dọn dẹp cuộc trò chuyện? Cài đặt cấu hình cho các máy chủ ICE Xác nhận các cài đặt mạng Xác nhận mã truy cập @@ -302,10 +302,10 @@ đang kết nối (lời mời giới thiệu) Kết nối tới máy tính đang ở trong tình trạng không tốt Kết nối đã bị ngắt - Kết nối thông qua đường dẫn / mã QR + Kết nối qua đường dẫn / mã QR Kết nối tới chính bạn? Yêu cầu kết nối đã được gửi! - Kết nối thông qua đường dẫn + Kết nối qua đường dẫn Đang kết nối cuộc gọi Kết nối đã bị ngắt Đang kết nối tới máy tính @@ -318,18 +318,18 @@ Thời gian chờ kết nối Lỗi kết nối Kết nối - Kết nối thông qua đường dẫn? + Kết nối qua đường dẫn? kết nối %1$d kết nối đã được tạo lập Kết nối với %1$s? Lỗi kết nối (AUTH) Kết nối đang kết nối cuộc gọi… - Kết nối thông qua địa chỉ liên lạc? - Kết nối thông qua đường dẫn dùng một lần? + Kết nối qua địa chỉ liên lạc? + Kết nối qua đường dẫn dùng một lần? Liên hệ đã được kiểm tra Các liên hệ - Liên hệ có cho phép + Liên hệ cho phép Liên hệ đã tồn tại liện hệ %1$s đã thay đổi thành %2$s Liên hệ này vẫn chưa được kết nối! @@ -412,7 +412,7 @@ \nQuá trình này có thể mất một vài phút. Mật khẩu cơ sở dữ liệu và xuất dữ liệu Mật khẩu cơ sở dữ liệu khác với mật khẩu được lưu trong Keystore. - Mật khẩu cơ sở dữ liệu là cần thiết để mở ứng dụng SimpleX Chat. + Mật khẩu cơ sở dữ liệu là cần thiết để mở kết nối trò chuyện. Lỗi cơ sở dữ liệu Xóa Xóa địa chỉ? @@ -559,7 +559,7 @@ Bất kỳ ai cũng có thể tạo máy chủ. Tiếp tục Kết nối nhanh hơn với bạn bè. - Cơ sở dữ liệu SimpleX Chat đã được xuất + Cơ sở dữ liệu trò chuyện đã được xuất Lưu trữ các liên hệ để trò chuyện sau. Ngắt kết nối máy tính? Xóa tối đa 20 tin nhắn cùng một lúc. @@ -592,7 +592,7 @@ Không hiển thị lại Tải về tệp tin Đang tải về kho lưu trữ - Hạ cấp và mở SimpleX Chat + Hạ cấp và mở kết nối trò chuyện Tải về Không bật Đã tải về @@ -682,7 +682,7 @@ Lỗi tạo địa chỉ Lỗi chấp nhận yêu cầu liên hệ Lỗi thay đổi địa chỉ - Lỗi xóa cơ sở dữ liệu SimpleX Chat + Lỗi xóa cơ sở dữ liệu trò chuyện Lỗi tạo liên hệ thành viên Lỗi hủy bỏ thay đổi địa chỉ Lỗi tạo đường dẫn nhóm @@ -694,13 +694,13 @@ Lỗi xóa yêu cầu liên hệ Lỗi xóa cơ sở dữ liệu Lỗi xóa kết nối liên hệ đang chờ xử lý - Lỗi xuất cơ sở dữ liệu SimpleX Chat + Lỗi xuất cơ sở dữ liệu trò chuyện Lỗi mã hóa cơ sở dữ liệu Lỗi bật chỉ báo đã nhận! Lỗi xóa ghi chú riêng tư Lỗi xóa hồ sơ người dùng - Lỗi nhập cơ sở dữ liệu SimpleX Chat - Lỗi xuất cơ sở dữ liệu SimpleX Chat + Lỗi nhập cơ sở dữ liệu trò chuyện + Lỗi xuất cơ sở dữ liệu trò chuyện Lỗi tải xuống kho lưu trữ Lỗi khởi tạo WebView. Cập nhật hệ điều hành của bạn lên phiên bản mới. Vui lòng liên hệ với nhà phát triển. \nLỗi: %s @@ -725,8 +725,8 @@ Lỗi lưu máy chủ XFTP Lỗi lưu máy chủ SMP Lỗi gửi tin nhắn - Lỗi khởi động ứng dụng - Lỗi dừng ứng dụng + Lỗi khởi động kết nối trò chuyện + Lỗi dừng kết nối trò chuyện Lỗi hiển thị thông báo, liên hệ với nhà phát triển. Lỗi lưu mật khẩu người dùng Lỗi lưu cài đặt @@ -749,11 +749,11 @@ Lỗi tải lên kho lưu trữ Tập tin đã xuất không tồn tại TẬP TIN - Không thể tải tin nhắn + Không thể tải các cuộc trò chuyện Không tìm thấy tệp - có thể tập tin đã bị xóa và hủy bỏ. Lỗi tệp Xuất chủ đề - Không thể tải tin nhắn + Không thể tải cuộc trò chuyện Tập tin Nhanh chóng và không cần phải đợi người gửi hoạt động! Không tìm thấy tệp @@ -776,7 +776,7 @@ Tệp sẽ được nhận khi liên hệ của bạn hoạt động, vui lòng chờ hoặc kiểm tra lại sau! Trạng thái tệp: %s Lấp đầy - CƠ SỞ DỮ LIỆU SIMPLEX CHAT + CƠ SỞ DỮ LIỆU TRÒ CHUYỆN Lỗi chuyển đổi hồ sơ Lọc các cuộc hội thoại chưa đọc và các cuộc hội thoại yêu thích. Cuối cùng, chúng ta đã có chúng! 🚀 @@ -956,7 +956,7 @@ MÀU SẮC GIAO DIỆN đã được mời Đường dẫn không hợp lệ - tác vụ trò chuyện không hợp lệ + cuộc trò chuyện không hợp lệ dữ liệu không hợp lệ định dạng tin nhắn không hợp lệ Mời @@ -1066,7 +1066,7 @@ Tin nhắn Lỗi keychain đã rời - đã được mời thông qua đường dẫn nhóm của bạn + đã được mời qua đường dẫn nhóm của bạn thành viên đã rời Nó cho phép việc có các kết nối ẩn danh mà không có bất kỳ dữ liệu chung nào giữa chúng trong một hồ sơ trò chuyện @@ -1244,7 +1244,7 @@ Mở SimpleX Chat để chấp nhận cuộc gọi Mở Cài đặt Safari / Trang Web / Mic, rồi chọn Cho phép với localhost. Mã truy cập - Mở kết nối trò chuyện + Mở cuộc trò chuyện Mở vị trí tệp Sử dụng từ máy tính trong ứng dụng di động và quét mã QR.]]> Hoặc dán đường dẫn lưu trữ @@ -1393,7 +1393,7 @@ Tiếp tục Không thể tải văn bản về các điều kiện hiện tại, bạn có thể xem xét các điều kiện thông qua đường dẫn này: Các điều kiện sử dụng - Cho phép flux + Sử dụng Flux trong cài đặt Mạng & máy chủ để bảo mật siêu dữ liệu tốt hơn. Các điều kiện sẽ được chấp nhận vào: %s. Lỗi thêm máy chủ Lỗi cập nhật máy chủ @@ -2007,7 +2007,7 @@ Đây là đường dẫn dùng một lần của riêng bạn! Cài đặt này áp dụng cho các tin nhắn trong hồ sơ trò chuyện hiện tại của bạn Đây là địa chỉ SimpleX của riêng bạn! - Để kết nối thông qua đường dẫn + Để kết nối qua đường dẫn Để bảo vệ đường dẫn của bạn khỏi bị thay thế, bạn có thể so sánh các mã bảo mật liên lạc. Để bảo vệ địa chỉ IP của bạn, định tuyến riêng tư sử dụng các máy chủ SMP của bạn để gửi tin nhắn. Cách ly truyền tải @@ -2090,7 +2090,7 @@ Sử dụng các dịch vụ .onion Xác minh mã bảo mật Sử dụng hồ sơ ẩn danh mới - thông qua %1$s + qua %1$s Sử dụng proxy SOCKS Sử dụng SimpleX Chat Sử dụng cho các kết nối mới @@ -2103,7 +2103,7 @@ Sử dụng từ máy tính Sử dụng định tuyến riêng tư với các máy chủ không xác định khi địa chỉ IP không được bảo vệ. Xem các điều kiện - thông qua đường dẫn dùng một lần + qua đường dẫn dùng một lần Các tin nhắn thoại bị cấm. Lịch sử hữu hình Cuộc gọi video @@ -2116,9 +2116,9 @@ Video bật Các tin nhắn thoại bị cấm trong cuộc trò chuyện này. Tin nhắn thoại - Thông qua giao thức kháng lượng tử an toàn. - thông qua đường dẫn nhóm - Thông qua trình duyệt + Qua giao thức kháng lượng tử an toàn. + qua đường dẫn nhóm + Qua trình duyệt video Xem các điều kiện đã được cập nhật Xem sự cố @@ -2131,7 +2131,7 @@ thông qua relay Tin nhắn thoại cuộc gọi video (không được mã hóa đầu cuối) - thông qua đường dẫn địa chỉ liên lạc + qua đường dẫn địa chỉ liên lạc Video Chúng tôi không lưu bất kỳ liên hệ hay tin nhắn nào của bạn (một khi đã được gửi) trên các máy chủ. Website @@ -2333,4 +2333,23 @@ đã từ chối Thiết lập giờ hết hạn cho tin nhắn trong các cuộc trò chuyện. Tên các tệp tin đa phương tiện riêng tư. + Lỗi đọc mật khẩu cơ sở dữ liệu + Tất cả các tin nhắn mới từ những thành viên này sẽ bị ẩn! + Chặn các thành viên cho tất cả? + các kiểm duyệt viên + Các tin nhắn từ những thành viên này sẽ được hiển thị! + Các thành viên sẽ bị xóa khỏi nhóm - việc này không thể được hoàn tác! + Các thành viên sẽ bị xóa khỏi cuộc trò chuyện - việc này không thể được hoàn tác! + Không thể đọc mật khẩu trong Keystore. Điều này có thể xảy ra sau bản cập nhật hệ thống không tương thích với ứng dụng. Nếu không phải như vậy, xin vui lòng liên hệ với các nhà phát triển. + đang chờ xử lý + Các điều kiện đã được cập nhật + Không thể đọc mật khẩu trong Keystore, xin vui lòng nhập thủ công. Điều này có thể xảy ra sau khi bản cập nhật hệ thống không tương thích với ứng dụng. Nếu không phải như vậy, vui lòng liên hệ với các nhà phát triển. + đang chờ phê duyệt + Bỏ chặn các thành viên cho tất cả? + Xóa các thành viên? + Chính sách quyền riêng tư và các điều kiện sử dụng. + Bằng việc sử dụng SimpleX Chat, bạn đồng ý:\n- chỉ gửi nội dung hợp pháp trong các nhóm công khai.\n- tôn trọng những người dùng khác - không gửi tin rác. + Các cuộc trò chuyện riêng tư, nhóm và liên hệ của bạn không thể truy cập được đối với các bên vận hành máy chủ. + Chấp nhận + Định cấu hình các bên vận hành máy chủ 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 67faae4943..24abd90a31 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 @@ -2116,21 +2116,21 @@ 创建一次性链接 用于社交媒体 或者私下分享 - 服务器运营者 - 网络运营者 - 30 天后将接受已启用的运营者的条款。 + 服务器运营方 + 网络运营方 + 30 天后将接受已启用的运营方的条款。 继续 稍后审阅 - 选择要使用的网络运营者。 + 选择要使用的网络运营方。 更新 你可以通过设置配置服务器。 - %s.]]> - 将于下列日期自动接受已启用的运营者的条款:%s。 + %s.]]> + 将于下列日期自动接受已启用的运营方的条款:%s。 预设服务器 你的服务器 接受条款的将来日期为:%s。 - 网络运营者 - 运营者 + 网络运营方 + 运营方 %s 台服务器 网站 无法加载当前条款文本,你可以通过此链接审阅条款: @@ -2147,17 +2147,17 @@ 用于消息 打开更改 打开条款 - 运营者服务器 - 已添加服务器到运营者 %s - 服务器运营者已更改。 + 运营方服务器 + 已添加服务器到运营方 %s + 服务器运营方已更改。 服务器协议已更改。 透明度 网络去中心化 - 应用中的第二个预设运营者! + 应用中的第二个预设运营方! 改进了聊天导航 查看更新后的条款 比如,如果你通过 SimpleX 服务器收到消息,应用会通过 Flux 服务器传送它们。 - 应用通过在每个对话中使用不同运营者保护你的隐私。 + 应用通过在每个对话中使用不同运营方保护你的隐私。 接受条款 模糊 地址或一次性链接? @@ -2167,13 +2167,13 @@ 已接受条款 应用工具栏 仅用于一名联系人
- 面对面或通过任何消息应用分享.]]> - %s.]]> - %s.]]> - %s.]]> - %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> %s的服务器,请接受使用条款。]]> - %s.]]> - 开启 flux + %s.]]> + 在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。 接受条款出错 为了更好的元数据隐私。 添加服务器出错 @@ -2189,8 +2189,8 @@ 此消息被删除或尚未收到。 连接达到了未送达消息上限,你的联系人可能处于离线状态。 为了防止链接被替换,你可以比较联系人安全代码。 - 你可以在“网络和服务器”设置中配置运营者。 - 接受运营者条款的日期:%s + 你可以在“网络和服务器”设置中配置运营方。 + 接受运营方条款的日期:%s 远程移动设备 或者导入压缩文件 小米设备:请在系统设置中开启“自动启动”让通知正常工作。]]> @@ -2228,10 +2228,10 @@ 聊天 将从聊天中删除成员 - 此操作无法撤销! 请减小消息尺寸并再次发送。 - 当启用了超过一个运营者时,没有一个运营者拥有了解谁和谁联络的元数据。 + 当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。 已接受邀请 被请求连接 - 关于运营者 + 关于运营方 SimpleX Chat 和 Flux 达成协议将 Flux 运营的服务器包括在应用中。 修复 修复连接? @@ -2297,9 +2297,9 @@ %d 个举报 垃圾信息 连接被阻止 - 连接被服务器运营者阻止:\n%1$s. + 连接被服务器运营方阻止:\n%1$s. 内容违反使用条款 - 文件被服务器运营者阻止:\n%1$s. + 文件被服务器运营方阻止:\n%1$s. 询问 @@ -2360,4 +2360,9 @@ 为所有其他成员解封这些成员吗? moderators 将从聊天中移除这些成员 — 此操作无法撤销! + 隐私政策和使用条款。 + 接受 + 使用 SimpleX Chat 代表您同意:\n- 在公开群中只发送合法内容\n- 尊重其他用户 – 没有垃圾信息。 + 服务器运营方无法访问私密聊天、群组和你的联系人。 + 配置服务器运营方 From 66273790e626b0f9141ab2e80d725941a76ba0a6 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 31 Mar 2025 19:38:54 +0100 Subject: [PATCH 158/289] website: translations (#5792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Russian) Currently translated at 100.0% (258 of 258 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% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Japanese) Currently translated at 98.8% (255 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ja/ * Translated using Weblate (French) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 10.8% (28 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/ * Translated using Weblate (Japanese) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ja/ --------- Co-authored-by: noname Co-authored-by: summoner001 Co-authored-by: Miyu Sakatsuki Co-authored-by: Farias França --- website/langs/es.json | 8 +++++--- website/langs/fr.json | 8 +++++--- website/langs/hu.json | 28 ++++++++++++++-------------- website/langs/ja.json | 8 +++++--- website/langs/pt_BR.json | 12 +++++++----- website/langs/zh_Hant.json | 31 ++++++++++++++++++++++++++++++- 6 files changed, 66 insertions(+), 29 deletions(-) diff --git a/website/langs/es.json b/website/langs/es.json index b88a592ac4..56055abf67 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -244,15 +244,17 @@ "signing-key-fingerprint": "Huella digital de la clave de firma (SHA-256)", "releases-to-this-repo-are-done-1-2-days-later": "Las versiones aparecen varios días más tarde en este repositorio", "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo", - "hero-overlay-3-title": "Evaluación de la seguridad", + "hero-overlay-3-title": "Evaluaciones de seguridad", "hero-overlay-card-3-p-2": "Trail of Bits revisó la criptografía y los componentes de red de la plataforma SimpleX en noviembre de 2022. Más información.", "jobs": "Únete al equipo", - "hero-overlay-3-textlink": "Evaluación de la seguridad", + "hero-overlay-3-textlink": "Evaluaciones de seguridad", "hero-overlay-card-3-p-1": "Trail of Bits es una consultora de seguridad y tecnología líder cuyos clientes incluyen grandes tecnológicas, agencias gubernamentales e importantes proyectos de blockchain.", "docs-dropdown-9": "Descargas", "please-enable-javascript": "Habilita JavaScript para ver el código QR.", "please-use-link-in-mobile-app": "Usa el enlace en la apliación móvil", "docs-dropdown-10": "Transparencia", "docs-dropdown-11": "FAQ", - "docs-dropdown-12": "Seguridad" + "docs-dropdown-12": "Seguridad", + "docs-dropdown-14": "SimpleX para empresas", + "hero-overlay-card-3-p-3": "Trail of Bits revisó el diseño criptográfico de los protocolos de red SimpleX en julio de 2024." } diff --git a/website/langs/fr.json b/website/langs/fr.json index 61be2c8621..ba2c4fb00c 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -245,15 +245,17 @@ "f-droid-org-repo": "Dépot F-Droid.org", "stable-versions-built-by-f-droid-org": "Versions stables créées par F-Droid.org", "comparison-section-list-point-4a": "Les relais SimpleX ne peuvent pas compromettre le chiffrement e2e. Vérifier le code de sécurité pour limiter les attaques sur le canal hors bande", - "hero-overlay-3-title": "Évaluation de sécurité", + "hero-overlay-3-title": "Évaluation de la sécurité", "hero-overlay-card-3-p-2": "Trail of Bits a examiné les composants cryptographiques et réseau de la plateforme SimpleX en novembre 2022. En savoir plus.", "jobs": "Rejoignez notre équipe", - "hero-overlay-3-textlink": "Évaluation de sécurité", + "hero-overlay-3-textlink": "Évaluations de la sécurité", "hero-overlay-card-3-p-1": "Trail of Bits est un cabinet leader dans le secteur de la sécurité et des technologies qui compte parmi ses clients des grandes entreprises de la tech, des agences gouvernementales et d'importants projets de blockchain.", "docs-dropdown-9": "Téléchargements", "please-enable-javascript": "Veuillez activer JavaScript pour voir le code QR.", "please-use-link-in-mobile-app": "Veuillez utiliser le lien dans l'application mobile", "docs-dropdown-10": "Transparence", "docs-dropdown-12": "Sécurité", - "docs-dropdown-11": "FAQ" + "docs-dropdown-11": "FAQ", + "hero-overlay-card-3-p-3": "Trail of Bits a examiné la conception cryptographique des protocoles réseau SimpleX en juillet 2024.", + "docs-dropdown-14": "SimpleX pour les entreprises" } diff --git a/website/langs/hu.json b/website/langs/hu.json index ae9ee1d12b..46ad9668cb 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -11,7 +11,7 @@ "simplex-explained-tab-1-text": "1. Felhasználói élmény", "simplex-explained-tab-2-text": "2. Hogyan működik", "simplex-explained-tab-3-text": "3. Mit látnak a kiszolgálók", - "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetváltón.", + "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetváltó-alkalmazásban.", "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú üzenet-sorballítással és felhasználói profilazonosítók nélkül?", "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 egyirányú üzeneteket továbbítanak, anélkül, hogy teljes képet kapnának a felhasználók beszélgetéseiről vagy kapcsolatairól.", @@ -20,12 +20,12 @@ "smp-protocol": "SMP-protokoll", "chat-protocol": "Csevegésprotokoll", "donate": "Támogatás", - "copyright-label": "© 2020-2024 SimpleX | Nyílt forráskódú projekt", + "copyright-label": "© 2020-2025 SimpleX | Nyílt forráskódú projekt", "simplex-chat-protocol": "A SimpleX Chat-protokoll", "terminal-cli": "Terminál CLI", "terms-and-privacy-policy": "Adatvédelmi irányelvek", "hero-header": "Újradefiniált adatvédelem", - "hero-subheader": "Az első üzenetváltó
felhasználói azonosítók nélkül", + "hero-subheader": "Az első üzenetváltó-alkalmazás
felhasználói azonosítók nélkül", "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", "hero-overlay-1-textlink": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "hero-overlay-2-textlink": "Hogyan működik a SimpleX?", @@ -111,10 +111,10 @@ "privacy-matters-overlay-card-3-p-1": "Mindenkinek törődnie kell a magánélet és a kommunikáció biztonságával — az ártalmatlan beszélgetések veszélybe sodorhatják, még akkor is, ha nincs semmi rejtegetnivalója.", "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", - "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.", + "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ő platformoktó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 — nem tudjuk, hogy hányan használják a SimpleX-kiszolgálóinkat.", "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. A SimpleX használata olyan, mintha minden egyes kapcsolatnak más-más “eldobható” e-mail-címe vagy telefonja lenne és nem kell ezeket gondosan kezelni.", - "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 platform 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 megvédi annak titkosságát, hogy kivel kommunikál, elrejtve azt a SimpleX-platform 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-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, 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 nem kötelező felhasználói cím esetében is, bár spam kapcsolatfelvételi kérések küldésére használható, megváltoztathatja vagy teljesen törölheti azt 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.", @@ -122,21 +122,21 @@ "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az általunk biztosított, előre konfigurált kiszolgálókat használják.", - "simplex-unique-overlay-card-4-p-2": "A SimpleX platform nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", + "simplex-unique-overlay-card-4-p-2": "A SimpleX-platform nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX-platformra való fejlesztést fontolgatja, például a SimpleX-alkalmazások felhasználóinak szánt chatbotot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", - "simplex-unique-card-1-p-1": "A SimpleX védi az ön profiljához tartozó kapcsolatait és metaadatait, elrejtve azokat a SimpleX platform kiszolgálói és a megfigyelők elől.", + "simplex-unique-card-1-p-1": "A SimpleX védi az ön profiljához tartozó kapcsolatokat és metaadatokat, elrejtve azokat a SimpleX-platform kiszolgálói és a megfigyelők elől.", "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő platformtól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", - "simplex-unique-card-2-p-1": "Mivel a SimpleX platformon nincs azonosítója vagy állandó címe, 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-card-2-p-1": "Mivel a SimpleX-platformon nincs azonosítója vagy állandó címe, 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-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.", "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", "simplex-unique-card-4-p-2": "Használhatja a SimpleXet a saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", - "join": "Csatlakozás", - "we-invite-you-to-join-the-conversation": "Meghívjuk Önt, hogy csatlakozzon a beszélgetéshez", + "join": "Csatlakozzon a közösségeinkhez", + "we-invite-you-to-join-the-conversation": "Meghívjuk Önt, hogy csatlakozzon a beszélgetésekhez", "join-the-REDDIT-community": "Csatlakozzon a REDDIT közösséghez", "join-us-on-GitHub": "Csatlakozzon hozzánk a GitHubon", "donate-here-to-help-us": "Adományozzon és segítsen nekünk", - "sign-up-to-receive-our-updates": "Regisztráljon az oldalra, hogy megkapja frissítéseinket", + "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ép-alkalmazásának letöltése", "why-simplex-is": "A SimpleX mitől", @@ -166,7 +166,7 @@ "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-label": "Győződjön meg arról, hogy az üzenetváltó amit használ nem fér hozzá az adataidhoz!", + "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 adataidhoz!", "simplex-private-section-header": "Mitől lesz a SimpleX privát", "simplex-network-section-header": "SimpleX hálózat", "simplex-network-section-desc": "A Simplex Chat a P2P és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", @@ -197,7 +197,7 @@ "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált - egyetlen hálózatként működnek", "comparison-section-list-point-7": "A P2P-hálózatoknak vagy van egy központi hitelesítője, vagy az egész hálózat kompromittálódhat", - "see-here": "lásd itt", + "see-here": "tekintse meg itt", "guide-dropdown-1": "Gyors indítás", "guide-dropdown-2": "Üzenetek küldése", "guide-dropdown-3": "Titkos csoportok", @@ -207,7 +207,7 @@ "guide-dropdown-7": "Adatvédelem és biztonság", "guide-dropdown-8": "Alkalmazás beállításai", "guide": "Útmutató", - "docs-dropdown-1": "SimpleX platform", + "docs-dropdown-1": "SimpleX-platform", "docs-dropdown-2": "Android fájlok elérése", "docs-dropdown-3": "Hozzáférés a csevegési adatbázishoz", "docs-dropdown-8": "SimpleX jegyzékszolgáltatás", diff --git a/website/langs/ja.json b/website/langs/ja.json index 6f994b59da..c24883fb00 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -246,13 +246,15 @@ "simplex-private-5-title": "何レイヤーもの
コンテンツパディング", "hero-overlay-card-3-p-1": "Trail of Bitsは、大手ハイテク企業、政府機関、主要なブロックチェーン・プロジェクトなどを顧客に持つ、セキュリティとテクノロジーの大手コンサルタント会社です。", "jobs": "チームに参加する", - "hero-overlay-3-textlink": "セキュリティ監査", - "hero-overlay-3-title": "セキュリティ監査", + "hero-overlay-3-textlink": "セキュリティ評価", + "hero-overlay-3-title": "セキュリティ評価", "hero-overlay-card-3-p-2": "Trail of Bitsは2022年11月にSimpleXプラットフォームの暗号とネットワークのコンポーネントを検証しました。詳しくは お知らせをご覧ください。", "docs-dropdown-9": "ダウンロード", "please-enable-javascript": "QRコードを表示するためにJavaScriptを有効にしてください。", "please-use-link-in-mobile-app": "このリンクをモバイルアプリで使用してください", "docs-dropdown-10": "透明性", "docs-dropdown-11": "よくある質問", - "docs-dropdown-12": "セキュリティ" + "docs-dropdown-12": "セキュリティ", + "docs-dropdown-14": "ビジネス向けSimpleX", + "hero-overlay-card-3-p-3": "Trail of Bits は 2024 年 7 月に SimpleX ネットワーク プロトコルの暗号設計をレビューしました。" } diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 73095c6db2..b25888591e 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -11,7 +11,7 @@ "simplex-explained-tab-1-p-1": "Você pode criar contatos e grupos e ter conversas bidirecionais, como em qualquer outro mensageiro.", "simplex-explained-tab-2-p-1": "Para cada conexão, são usadas duas filas de mensagens separadas para enviar e receber mensagens por meio de servidores diferentes.", "hero-p-1": "Outros aplicativos possuem IDs de usuário: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
O SimpleX não tem, nem mesmo números aleatórios.
Isso melhora radicalmente a sua privacidade.", - "terms-and-privacy-policy": "Termos e Política de Privacidade", + "terms-and-privacy-policy": "Termos de serviço & Política de Privacidade", "hero-2-header-desc": "O vídeo mostra como você se conecta com seu amigo através do QR code de uso único dele, pessoalmente ou via link de vídeo. Você também pode se conectar compartilhando um link de convite.", "feature-7-title": "Armazenamento do aplicativo criptografado portátil — mova o perfil para outro dispositivo", "simplex-explained": "Explicação do SimpleX", @@ -242,9 +242,9 @@ "simplex-chat-repo": "Repositório Simplex Chat", "f-droid-org-repo": "Repositório F-Droid.org", "stable-versions-built-by-f-droid-org": "Versões estáveis criadas por F-Droid.org", - "releases-to-this-repo-are-done-1-2-days-later": "Os lançamentos para este repositório são feitos 1-2 dias depois", - "hero-overlay-3-textlink": "Avaliação Segura", - "hero-overlay-3-title": "Avaliação Segura", + "releases-to-this-repo-are-done-1-2-days-later": "Os lançamentos para este repositório são feitos 1 ou 2 dias depois", + "hero-overlay-3-textlink": "Avaliações de segurança", + "hero-overlay-3-title": "Avaliações de segurança", "hero-overlay-card-3-p-1": "Trail of Bits é uma consultoria líder em segurança e tecnologia cujos clientes incluem grandes empresas de tecnologia, agências governamentais e grandes projetos de blockchain.", "hero-overlay-card-3-p-2": "Trail of Bits analisou a criptografia da plataforma SimpleX e os componentes de rede em novembro de 2022. Leia mais em o anúncio.", "f-droid-page-f-droid-org-repo-section-text": "Os repositórios SimpleX Chat e F-Droid.org assinam compilações com chaves diferentes. Para mudar, exporte o banco de dados de bate-papo e reinstale o aplicativo.", @@ -254,5 +254,7 @@ "docs-dropdown-9": "Baixar", "docs-dropdown-11": "FAQ", "docs-dropdown-10": "Transparência", - "docs-dropdown-12": "Segurança" + "docs-dropdown-12": "Segurança", + "hero-overlay-card-3-p-3": "Trail of Bits revisou o design criptografico das redes utilizadas pelo SimpleX em julho de 2024", + "docs-dropdown-14": "SimpleX para negócios" } diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index 0967ef424b..d95c6259c7 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -1 +1,30 @@ -{} +{ + "home": "家", + "developers": "開發人員", + "reference": "參考", + "blog": "博客", + "features": "特徵", + "why-simplex": "為什麼選擇SimpleX", + "simplex-privacy": "SimpleX 隱私", + "simplex-network": "SimpleX 網路", + "simplex-explained": "Simplex 解釋", + "simplex-explained-tab-1-text": "1. 用戶體驗", + "simplex-explained-tab-2-text": "2. 它是如何工作的", + "simplex-explained-tab-3-text": "3. 伺服器可以看到什麼", + "terminal-cli": "終端 CLI", + "hero-header": "重新定義隱私", + "hero-subheader": "第一個沒有 User ID 的 Messenger", + "simplex-explained-tab-3-p-2": "用戶可以通過使用 Tor 訪問伺服器來進一步提高元數據隱私,防止按 IP 位址進行序列化。", + "smp-protocol": "SMP 協定", + "simplex-explained-tab-2-p-2": "伺服器僅以一種方式傳遞消息,而無法全面瞭解使用者的對話或連接。", + "simplex-explained-tab-2-p-1": "對於每個連接,您可以使用兩個單獨的消息佇列通過不同的伺服器發送和接收消息。", + "chat-protocol": "聊天協定", + "copyright-label": "© 2020-2024 單工 |開源專案", + "donate": "捐", + "simplex-explained-tab-1-p-1": "您可以創建聯繫人和群組,並進行雙向對話,就像在任何其他 Messenger 中一樣。", + "simplex-explained-tab-1-p-2": "它如何在沒有使用者配置檔標識符的情況下使用單向佇列?", + "simplex-explained-tab-3-p-1": "伺服器對每個佇列都有單獨的匿名憑證,並且不知道它們屬於哪些使用者。", + "chat-bot-example": "聊天機器人示例", + "simplex-chat-protocol": "SimpleX Chat 協定", + "terms-and-privacy-policy": "隱私策略" +} From 4b6d1d4585c36034385b2ea4f82a191daa1fce8c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 31 Mar 2025 20:09:46 +0100 Subject: [PATCH 159/289] 6.3.1: ios 270, android 281, desktop 97 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++--- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 060f0effe8..366bfd167a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1988,7 +1988,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2013,7 +2013,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2038,7 +2038,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2055,11 +2055,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2075,11 +2075,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2100,7 +2100,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2115,7 +2115,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2137,7 +2137,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2152,7 +2152,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2174,7 +2174,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2200,7 +2200,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2225,7 +2225,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2251,7 +2251,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2310,7 +2310,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 269; + CURRENT_PROJECT_VERSION = 270; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2325,7 +2325,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3; + MARKETING_VERSION = 6.3.1; 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 09095de72e..50e49c7b26 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 -android.version_code=279 +android.version_name=6.3.1 +android.version_code=281 -desktop.version_name=6.3 -desktop.version_code=96 +desktop.version_name=6.3.1 +desktop.version_code=97 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From d45ecff13ab95646728f48142e0034bc07af3f60 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 1 Apr 2025 11:44:22 +0000 Subject: [PATCH 160/289] flatpak: update metainfo (#5794) --- .../flatpak/chat.simplex.simplex.metainfo.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 45e0f9a418..6ad4fda03e 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,27 @@ + + https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +

New in v6.3.1:

+
    +
  • scrolling/navigation improvements.
  • +
  • faster onboarding (conditions and operators are combined to one screen).
  • +
+

New in v6.3.0:

+
    +
  • Mention members and get notified when mentioned.
  • +
  • Send private reports to moderators.
  • +
  • Delete, block and change role for multiple members at once
  • +
  • Faster sending messages and faster deletion.
  • +
  • Organize chats into lists to keep track of what's important.
  • +
  • Jump to found and forwarded messages.
  • +
  • Private media file names.
  • +
  • Message expiration in chats.
  • +
+
+
https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html 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 161/289] 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 162/289] 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 163/289] 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 e7f8533112b32794db93d1f73e5605037cd987b1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 3 Apr 2025 10:55:54 +0100 Subject: [PATCH 164/289] ios: v6.3.1, build 271 using XCode 15 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 366bfd167a..5e6148758c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2013,7 +2013,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2055,7 +2055,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2075,7 +2075,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2100,7 +2100,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2137,7 +2137,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2174,7 +2174,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2225,7 +2225,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2310,7 +2310,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 271; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 3fb09d3def3d6ed369509939626c12985a54f0ac Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 3 Apr 2025 16:27:40 +0100 Subject: [PATCH 165/289] core: fix types for PostgreSQL database (#5800) * core: fix types for PostgreSQL database * option to create schema * use action forks --- .github/workflows/build.yml | 34 ++++++++++++++-------------- .github/workflows/web.yml | 2 +- src/Simplex/Chat/Options/Postgres.hs | 33 ++++++++++++++++++++++----- tests/ChatClient.hs | 4 +++- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2338258d82..39973dc017 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,7 @@ jobs: - name: Build changelog id: build_changelog - uses: mikepenz/release-changelog-builder-action@v4 + uses: simplex-chat/release-changelog-builder-action@v4 with: configuration: .github/changelog_conf.json failOnError: true @@ -42,7 +42,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create release - uses: softprops/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v1 with: body: ${{ steps.build_changelog.outputs.changelog }} prerelease: true @@ -97,7 +97,7 @@ jobs: - name: Configure pagefile (Windows) if: matrix.os == 'windows-latest' - uses: al-cheb/configure-pagefile-action@v1.3 + uses: simplex-chat/configure-pagefile-action@v1.3 with: minimum-size: 16GB maximum-size: 16GB @@ -107,7 +107,7 @@ jobs: uses: actions/checkout@v3 - name: Setup Haskell - uses: haskell-actions/setup@v2 + uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ matrix.ghc }} cabal-version: "3.10.1.0" @@ -179,7 +179,7 @@ jobs: - name: Unix upload CLI binary to release if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' - uses: svenstaro/upload-release-action@v2 + uses: simplex-chat/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ${{ steps.unix_cli_build.outputs.bin_path }} @@ -188,7 +188,7 @@ jobs: - name: Unix update CLI binary hash if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' - uses: softprops/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -242,7 +242,7 @@ jobs: - name: Linux upload desktop package to release if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') - uses: svenstaro/upload-release-action@v2 + uses: simplex-chat/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ${{ steps.linux_desktop_build.outputs.package_path }} @@ -251,7 +251,7 @@ jobs: - name: Linux update desktop package hash if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') - uses: softprops/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -261,7 +261,7 @@ jobs: - name: Linux upload AppImage to release if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - uses: svenstaro/upload-release-action@v2 + uses: simplex-chat/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ${{ steps.linux_appimage_build.outputs.appimage_path }} @@ -270,7 +270,7 @@ jobs: - name: Linux update AppImage hash if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - uses: softprops/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -280,7 +280,7 @@ jobs: - name: Mac upload desktop package to release if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') - uses: svenstaro/upload-release-action@v2 + uses: simplex-chat/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ${{ steps.mac_desktop_build.outputs.package_path }} @@ -289,7 +289,7 @@ jobs: - name: Mac update desktop package hash if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') - uses: softprops/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -319,7 +319,7 @@ jobs: - name: 'Setup MSYS2' if: matrix.os == 'windows-latest' - uses: msys2/setup-msys2@v2 + uses: simplex-chat/setup-msys2@v2 with: msystem: ucrt64 update: true @@ -357,7 +357,7 @@ jobs: - name: Windows upload CLI binary to release if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: svenstaro/upload-release-action@v2 + uses: simplex-chat/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ${{ steps.windows_build.outputs.bin_path }} @@ -366,7 +366,7 @@ jobs: - name: Windows update CLI binary hash if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: softprops/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -389,7 +389,7 @@ jobs: - name: Windows upload desktop package to release if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: svenstaro/upload-release-action@v2 + uses: simplex-chat/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ${{ steps.windows_desktop_build.outputs.package_path }} @@ -398,7 +398,7 @@ jobs: - name: Windows update desktop package hash if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: softprops/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 6839d48aeb..5fbe8293bc 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -33,7 +33,7 @@ jobs: ./website/web.sh - name: Deploy - uses: peaceiris/actions-gh-pages@v3 + uses: simplex-chat/actions-gh-pages@v3 with: publish_dir: ./website/_site github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index b174ecd02e..f7c429e93e 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -7,11 +7,14 @@ module Simplex.Chat.Options.Postgres where import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative +import Numeric.Natural (Natural) import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) data ChatDbOpts = ChatDbOpts { dbConnstr :: String, - dbSchemaPrefix :: String + dbSchemaPrefix :: String, + dbPoolSize :: Natural, + dbCreateSchema :: Bool } chatDbOptsP :: FilePath -> String -> Parser ChatDbOpts @@ -33,16 +36,32 @@ chatDbOptsP _appDir defaultDbName = do <> value "simplex_v1" <> showDefault ) - pure ChatDbOpts {dbConnstr, dbSchemaPrefix} + dbPoolSize <- + option + auto + ( long "pool-size" + <> metavar "DB_POOL_SIZE" + <> help "Database connection pool size" + <> value 1 + <> showDefault + ) + dbCreateSchema <- + switch + ( long "create-schema" + <> help "Create database schema when it does not exist" + ) + pure ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbConnstr} = dbConnstr toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix} dbSuffix _keepKey = +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey = DBOpts { connstr = B.pack dbConnstr, - schema = if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix + schema = B.pack $ if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix, + poolSize = dbPoolSize, + createSchema = dbCreateSchema } chatSuffix :: String @@ -58,11 +77,13 @@ mobileDbOpts schemaPrefix connstr = do pure $ ChatDbOpts { dbConnstr, - dbSchemaPrefix + dbSchemaPrefix, + dbPoolSize = 1, + dbCreateSchema = True } removeDbKey :: ChatDbOpts -> ChatDbOpts removeDbKey = id errorDbStr :: DBOpts -> String -errorDbStr DBOpts {schema} = schema +errorDbStr DBOpts {schema} = B.unpack schema diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c41c155697..f16bc84cfd 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -117,7 +117,9 @@ testCoreOpts = { dbConnstr = testDBConnstr, -- dbSchemaPrefix is not used in tests (except bot tests where it's redefined), -- instead different schema prefix is passed per client so that single test database is used - dbSchemaPrefix = "" + dbSchemaPrefix = "", + dbPoolSize = 3, + dbCreateSchema = True #else { dbFilePrefix = "./simplex_v1", -- dbFilePrefix is not used in tests (except bot tests where it's redefined) dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", 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 166/289] 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 167/289] 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 48b1ef764bea7ebe8cd1dbf3144153f87efa633f Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:19:24 +0000 Subject: [PATCH 168/289] ci: reproducible builds/refactor (#5808) * ci: reproducible builds/refactor * ci: fix mac desktop upload * ci: docker shell abort on error * scripts: add reproduce script * ci: add new reproduce workflow * scripts/reproduce-builds: change repo back to official --- .github/actions/prepare-build/action.yml | 52 ++ .github/actions/prepare-release/action.yml | 39 ++ .github/workflows/build.yml | 580 ++++++++++++--------- .github/workflows/reproduce-schedule.yml | 45 ++ Dockerfile.build | 84 +++ scripts/ci/linux_util_free_space.sh | 96 ++++ scripts/reproduce-builds.sh | 120 +++++ 7 files changed, 757 insertions(+), 259 deletions(-) create mode 100644 .github/actions/prepare-build/action.yml create mode 100644 .github/actions/prepare-release/action.yml create mode 100644 .github/workflows/reproduce-schedule.yml create mode 100644 Dockerfile.build create mode 100755 scripts/ci/linux_util_free_space.sh create mode 100644 scripts/reproduce-builds.sh diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml new file mode 100644 index 0000000000..6682641419 --- /dev/null +++ b/.github/actions/prepare-build/action.yml @@ -0,0 +1,52 @@ +name: "Prebuilt steps for build" +description: "Reusable steps for multiple jobs" +inputs: + java_ver: + required: true + description: "Java version to install" + ghc_ver: + required: true + description: "GHC version to install" + github_ref: + required: true + description: "Git reference" + os: + required: true + description: "Target OS" + cache_path: + required: false + default: "~/.cabal/store" + description: "Cache path" + cabal_ver: + required: false + default: 3.10.1.0 + description: "GHC version to install" +runs: + using: "composite" + steps: + - name: Skip unreliable ghc 8.10.7 build on stable branch + shell: bash + if: inputs.ghc_ver == '8.10.7' && inputs.github_ref == 'refs/heads/stable' + run: exit 0 + + - name: Setup Haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ inputs.ghc_ver }} + cabal-version: ${{ inputs.cabal_ver }} + + - name: Setup Java + if: startsWith(inputs.github_ref, 'refs/tags/v') + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: ${{ inputs.java_ver }} + cache: 'gradle' + + - name: Restore cached build + uses: actions/cache@v4 + with: + path: | + ${{ inputs.cache_path }} + dist-newstyle + key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} diff --git a/.github/actions/prepare-release/action.yml b/.github/actions/prepare-release/action.yml new file mode 100644 index 0000000000..e44e6ef0f2 --- /dev/null +++ b/.github/actions/prepare-release/action.yml @@ -0,0 +1,39 @@ +name: "Upload binary and update hash" +description: "Reusable steps for multiple jobs" +inputs: + bin_path: + required: true + description: "Path to binary to upload" + bin_name: + required: true + description: "Name of uploaded binary" + bin_hash: + required: true + description: "Message with SHA to include in release" + github_ref: + required: true + description: "Github reference" + github_token: + required: true + description: "Github token" +runs: + using: "composite" + steps: + - name: Linux upload AppImage to release + if: startsWith(inputs.github_ref, 'refs/tags/v') + uses: simplex-chat/upload-release-action@v2 + with: + repo_token: ${{ inputs.github_token }} + file: ${{ inputs.bin_path }} + asset_name: ${{ inputs.bin_name }} + tag: ${{ inputs.github_ref }} + + - name: Linux update AppImage hash + if: startsWith(inputs.github_ref, 'refs/tags/v') + uses: simplex-chat/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + with: + append_body: true + body: | + ${{ inputs.bin_hash }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39973dc017..de0b976bcc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,16 +22,57 @@ on: - "README.md" - "PRIVACY.md" +# This workflow uses custom actions (prepare-build and prepare-release) defined in: +# +# .github/actions/ +# ├── prepare-build +# │ └── action.yml +# └── prepare-release +# └── action.yml + +# Important! +# Do not use always(), it makes build unskippable. +# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753 + jobs: - prepare-release: - if: startsWith(github.ref, 'refs/tags/v') + +# ============================= +# Global variables +# ============================= + +# That is the only and less hacky way to setup global variables +# to use in strategy matrix (env:/YAML anchors doesn't work). +# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789 +# https://github.com/actions/runner/issues/1182 +# https://stackoverflow.com/a/77549656 + + variables: + runs-on: ubuntu-latest + outputs: + GHC_VER: 9.6.3 + JAVA_VER: 17 + steps: + - name: Dummy job when we have just simple variables + if: false + run: echo + +# ============================= +# Create release +# ============================= + +# Create release, but only if it's triggered by tag push. +# On pull requests/commits push, this job will always complete. + + maybe-release: runs-on: ubuntu-latest steps: - name: Clone project + if: startsWith(github.ref, 'refs/tags/v') uses: actions/checkout@v3 - name: Build changelog id: build_changelog + if: startsWith(github.ref, 'refs/tags/v') uses: simplex-chat/release-changelog-builder-action@v4 with: configuration: .github/changelog_conf.json @@ -42,6 +83,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create release + if: startsWith(github.ref, 'refs/tags/v') uses: simplex-chat/action-gh-release@v1 with: body: ${{ steps.build_changelog.outputs.changelog }} @@ -52,183 +94,259 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - build: - name: build-${{ matrix.os }}-${{ matrix.ghc }} - if: always() - needs: prepare-release - runs-on: ${{ matrix.os }} +# ========================= +# Linux Build +# ========================= + + build-linux: + name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + runs-on: ubuntu-${{ matrix.os }} strategy: fail-fast: false matrix: include: - - os: ubuntu-20.04 + - os: 20.04 ghc: "8.10.7" - cache_path: ~/.cabal/store - - os: ubuntu-20.04 - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-ubuntu-20_04-x86-64 + - os: 20.04 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-ubuntu-20_04-x86-64 desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb - - os: ubuntu-22.04 - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-ubuntu-22_04-x86-64 + - os: 22.04 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-ubuntu-22_04-x86-64 desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb - - os: macos-latest - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-macos-aarch64 - desktop_asset_name: simplex-desktop-macos-aarch64.dmg - - os: macos-13 - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-macos-x86-64 - desktop_asset_name: simplex-desktop-macos-x86_64.dmg - - os: windows-latest - ghc: "9.6.3" - cache_path: C:/cabal - asset_name: simplex-chat-windows-x86-64 - desktop_asset_name: simplex-desktop-windows-x86_64.msi - steps: - - name: Skip unreliable ghc 8.10.7 build on stable branch - if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable' - run: exit 0 - - - name: Configure pagefile (Windows) - if: matrix.os == 'windows-latest' - uses: simplex-chat/configure-pagefile-action@v1.3 - with: - minimum-size: 16GB - maximum-size: 16GB - disk-root: "C:" - - - name: Clone project + - name: Checkout Code uses: actions/checkout@v3 - - name: Setup Haskell - uses: simplex-chat/setup-haskell-action@v2 - with: - ghc-version: ${{ matrix.ghc }} - cabal-version: "3.10.1.0" + # Otherwise we run out of disk space with Docker build + - name: Free disk space + shell: bash + run: ./scripts/ci/linux_util_free_space.sh - name: Restore cached build - id: restore_cache - uses: actions/cache/restore@v3 + uses: actions/cache@v4 with: path: | - ${{ matrix.cache_path }} + ~/.cabal/store dist-newstyle - key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} + key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - # / Unix + - name: Set up Docker Buildx + uses: simplex-chat/docker-setup-buildx-action@v3 - - name: Unix prepare cabal.project.local for Mac - if: matrix.os == 'macos-latest' + - name: Build and cache Docker image + uses: simplex-chat/docker-build-push-action@v6 + with: + context: . + load: true + file: Dockerfile.build + tags: build/${{ matrix.os }}:latest + build-args: | + TAG=${{ matrix.os }} + GHC=${{ matrix.ghc }} + + # Docker needs these flags for AppImage build: + # --device /dev/fuse + # --cap-add SYS_ADMIN + # --security-opt apparmor:unconfined + - name: Start container shell: bash run: | - echo "ignore-project: False" >> cabal.project.local - echo "package simplexmq" >> cabal.project.local - echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local - echo "" >> cabal.project.local - echo "package direct-sqlcipher" >> cabal.project.local - echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local - echo " flags: +openssl" >> cabal.project.local + docker run -t -d \ + --device /dev/fuse \ + --cap-add SYS_ADMIN \ + --security-opt apparmor:unconfined \ + --name builder \ + -v ~/.cabal:/root/.cabal \ + -v /home/runner/work/_temp:/home/runner/work/_temp \ + -v ${{ github.workspace }}:/project \ + build/${{ matrix.os }}:latest - - name: Unix prepare cabal.project.local for Mac - if: matrix.os == 'macos-13' - shell: bash - run: | - echo "ignore-project: False" >> cabal.project.local - echo "package simplexmq" >> cabal.project.local - echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local - echo "" >> cabal.project.local - echo "package direct-sqlcipher" >> cabal.project.local - echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local - echo " flags: +openssl" >> cabal.project.local - - - name: Install AppImage dependencies - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - run: sudo apt install -y desktop-file-utils - - - name: Install openssl for Mac - if: matrix.os == 'macos-latest' || matrix.os == 'macos-13' - run: brew install openssl@3.0 - - - name: Unix prepare cabal.project.local for Ubuntu - if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04' + - name: Prepare cabal.project.local shell: bash run: | echo "ignore-project: False" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local - - name: Unix build CLI - id: unix_cli_build - if: matrix.os != 'windows-latest' + # chmod/git commands are used to workaround permission issues when cache is restored + - name: Build CLI + shell: docker exec -t builder sh -eu {0} + run: | + chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*' + cabal clean + cabal update + cabal build -j --enable-tests + mkdir -p /out + for i in simplex-chat simplex-chat-test; do + bin=$(find /project/dist-newstyle -name "$i" -type f -executable) + chmod +x "$bin" + mv "$bin" /out/ + done + strip /out/simplex-chat + + - name: Copy tests from container shell: bash run: | - cabal build --enable-tests - path=$(cabal list-bin simplex-chat) - echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + docker cp builder:/out/simplex-chat-test . - - name: Unix upload CLI binary to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' - uses: simplex-chat/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.unix_cli_build.outputs.bin_path }} - asset_name: ${{ matrix.asset_name }} - tag: ${{ github.ref }} - - - name: Unix update CLI binary hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' - uses: simplex-chat/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.unix_cli_build.outputs.bin_hash }} - - - name: Setup Java - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name - uses: actions/setup-java@v3 - with: - distribution: 'corretto' - java-version: '17' - cache: 'gradle' - - - name: Linux build desktop - id: linux_desktop_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') + - name: Copy CLI from container and prepare it + id: linux_cli_prepare + if: startsWith(github.ref, 'refs/tags/v') && matrix.cli_asset_name shell: bash + run: | + docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }} + path="${{ github.workspace }}/${{ matrix.cli_asset_name }}" + echo "bin_path=$path" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Upload CLI + if: startsWith(github.ref, 'refs/tags/v') && matrix.cli_asset_name + uses: ./.github/actions/prepare-release + with: + bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }} + bin_name: ${{ matrix.cli_asset_name }} + bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Desktop + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + shell: docker exec -t builder sh -eu {0} run: | scripts/desktop/build-lib-linux.sh cd apps/multiplatform ./gradlew packageDeb - path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb) + + - name: Prepare Desktop + id: linux_desktop_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + shell: bash + run: | + path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb ) echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Linux make AppImage - id: linux_appimage_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - shell: bash + - name: Upload Desktop + uses: ./.github/actions/prepare-release + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + with: + bin_path: ${{ steps.linux_desktop_build.outputs.package_path }} + bin_name: ${{ matrix.desktop_asset_name }} + bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build AppImage + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + shell: docker exec -t builder sh -eu {0} run: | scripts/desktop/make-appimage-linux.sh - path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage) + + - name: Prepare AppImage + id: linux_appimage_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + shell: bash + run: | + path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage) echo "appimage_path=$path" >> $GITHUB_OUTPUT echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Mac build desktop + - name: Upload AppImage + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + uses: ./.github/actions/prepare-release + with: + bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }} + bin_name: "simplex-desktop-x86_64.AppImage" + bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Fix permissions for cache + shell: bash + run: | + sudo chmod -R 777 dist-newstyle ~/.cabal + sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + + - name: Run tests + shell: bash + run: | + ./simplex-chat-test + +# ========================= +# MacOS Build +# ========================= + + build-macos: + name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-macos-aarch64 + desktop_asset_name: simplex-desktop-macos-aarch64.dmg + openssl_dir: "/opt/homebrew/opt" + - os: macos-13 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-macos-x86-64 + desktop_asset_name: simplex-desktop-macos-x86_64.dmg + openssl_dir: "/usr/local/opt" + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Prepare build + uses: ./.github/actions/prepare-build + with: + java_ver: ${{ needs.variables.outputs.JAVA_VER }} + ghc_ver: ${{ matrix.ghc }} + os: ${{ matrix.os }} + github_ref: ${{ github.ref }} + + - name: Install OpenSSL + run: brew install openssl@3.0 + + - name: Prepare cabal.project.local + shell: bash + run: | + echo "ignore-project: False" >> cabal.project.local + echo "package simplexmq" >> cabal.project.local + echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local + echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local + echo "" >> cabal.project.local + echo "package direct-sqlcipher" >> cabal.project.local + echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local + echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local + echo " flags: +openssl" >> cabal.project.local + + - name: Build CLI + id: mac_cli_build + shell: bash + run: | + cabal build -j --enable-tests + path=$(cabal list-bin simplex-chat) + echo "bin_path=$path" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Upload CLI + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release + with: + bin_path: ${{ steps.mac_cli_build.outputs.bin_path }} + bin_name: ${{ matrix.cli_asset_name }} + bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Desktop id: mac_desktop_build - if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') + if: startsWith(github.ref, 'refs/tags/v') shell: bash env: APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} @@ -240,85 +358,58 @@ jobs: echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Linux upload desktop package to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') - uses: simplex-chat/upload-release-action@v2 + - name: Upload Desktop + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.linux_desktop_build.outputs.package_path }} - asset_name: ${{ matrix.desktop_asset_name }} - tag: ${{ github.ref }} + bin_path: ${{ steps.mac_desktop_build.outputs.package_path }} + bin_name: ${{ matrix.desktop_asset_name }} + bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Linux update desktop package hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') - uses: simplex-chat/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.linux_desktop_build.outputs.package_hash }} - - - name: Linux upload AppImage to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - uses: simplex-chat/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.linux_appimage_build.outputs.appimage_path }} - asset_name: simplex-desktop-x86_64.AppImage - tag: ${{ github.ref }} - - - name: Linux update AppImage hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - uses: simplex-chat/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.linux_appimage_build.outputs.appimage_hash }} - - - name: Mac upload desktop package to release - if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') - uses: simplex-chat/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.mac_desktop_build.outputs.package_path }} - asset_name: ${{ matrix.desktop_asset_name }} - tag: ${{ github.ref }} - - - name: Mac update desktop package hash - if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') - uses: simplex-chat/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.mac_desktop_build.outputs.package_hash }} - - - name: Cache unix build - uses: actions/cache/save@v3 - if: matrix.os != 'windows-latest' - with: - path: | - ${{ matrix.cache_path }} - dist-newstyle - key: ${{ steps.restore_cache.outputs.cache-primary-key }} - - - name: Unix test - if: matrix.os != 'windows-latest' + - name: Run tests timeout-minutes: 40 shell: bash run: cabal test --test-show-details=direct - # Unix / +# ========================= +# Windows Build +# ========================= - # / Windows - # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing + build-windows: + name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-windows-x86-64 + desktop_asset_name: simplex-desktop-windows-x86_64.msi + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Prepare build + uses: ./.github/actions/prepare-build + with: + java_ver: ${{ needs.variables.outputs.JAVA_VER }} + ghc_ver: ${{ matrix.ghc }} + os: ${{ matrix.os }} + cache_path: "C:/cabal" + github_ref: ${{ github.ref }} + + - name: Configure pagefile (Windows) + uses: simplex-chat/configure-pagefile-action@v1.4 + with: + minimum-size: 16GB + maximum-size: 16GB + disk-root: "C:" + - name: 'Setup MSYS2' - if: matrix.os == 'windows-latest' uses: simplex-chat/setup-msys2@v2 with: msystem: ucrt64 @@ -331,10 +422,9 @@ jobs: toolchain:p cmake:p - - - name: Windows build - id: windows_build - if: matrix.os == 'windows-latest' + # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing + - name: Build CLI + id: windows_cli_build shell: msys2 {0} run: | export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) @@ -349,70 +439,42 @@ jobs: rm -rf dist-newstyle/src/direct-sq* sed -i "s/, unix /--, unix /" simplex-chat.cabal - cabal build --enable-tests + cabal build -j --enable-tests rm -rf dist-newstyle/src/direct-sq* path=$(cabal list-bin simplex-chat | tail -n 1) echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Windows upload CLI binary to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: simplex-chat/upload-release-action@v2 + - name: Upload CLI + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.windows_build.outputs.bin_path }} - asset_name: ${{ matrix.asset_name }} - tag: ${{ github.ref }} + bin_path: ${{ steps.windows_cli_build.outputs.bin_path }} + bin_name: ${{ matrix.cli_asset_name }} + bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Windows update CLI binary hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: simplex-chat/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.windows_build.outputs.bin_hash }} - - - name: Windows build desktop + - name: Build Desktop id: windows_desktop_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + if: startsWith(github.ref, 'refs/tags/v') shell: msys2 {0} run: | export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) scripts/desktop/build-lib-windows.sh cd apps/multiplatform ./gradlew packageMsi + rm -rf dist-newstyle/src/direct-sq* path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Windows upload desktop package to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: simplex-chat/upload-release-action@v2 + - name: Upload Desktop + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.windows_desktop_build.outputs.package_path }} - asset_name: ${{ matrix.desktop_asset_name }} - tag: ${{ github.ref }} - - - name: Windows update desktop package hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: simplex-chat/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.windows_desktop_build.outputs.package_hash }} - - - name: Cache windows build - uses: actions/cache/save@v3 - if: matrix.os == 'windows-latest' - with: - path: | - ${{ matrix.cache_path }} - dist-newstyle - key: ${{ steps.restore_cache.outputs.cache-primary-key }} - - # Windows / + bin_path: ${{ steps.windows_desktop_build.outputs.package_path }} + bin_name: ${{ matrix.desktop_asset_name }} + bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml new file mode 100644 index 0000000000..7de44addc7 --- /dev/null +++ b/.github/workflows/reproduce-schedule.yml @@ -0,0 +1,45 @@ +name: Reproduce latest release + +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # every day at 02:00 night + +jobs: + reproduce: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Get latest release + shell: bash + run: | + curl --proto '=https' \ + --tlsv1.2 \ + -sSf -L \ + 'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \ + 2>/dev/null | \ + grep -i "tag_name" | \ + awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV + + - name: Execute reproduce script + run: | + ${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG" + + - name: Check if build has been reproduced + env: + url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }} + user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }} + pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }} + run: | + if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then + exit 0 + else + curl --proto '=https' --tlsv1.2 -sSf \ + -u "${user}:${pass}" \ + -H 'Content-Type: application/json' \ + -d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \ + "$url" + exit 1 + fi diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000000..cd0fd22aad --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,84 @@ +# syntax=docker/dockerfile:1.7.0-labs +ARG TAG=24.04 +FROM ubuntu:${TAG} AS build + +### Build stage + +ARG GHC=9.6.3 +ARG CABAL=3.10.1.0 +ARG JAVA=17 + +ENV TZ=Etc/UTC \ + DEBIAN_FRONTEND=noninteractive + +# Install curl, git and and simplexmq dependencies +RUN apt-get update && \ + apt-get install -y curl \ + libpq-dev \ + git \ + sqlite3 \ + libsqlite3-dev \ + build-essential \ + libgmp3-dev \ + zlib1g-dev \ + llvm \ + cmake \ + llvm-dev \ + libnuma-dev \ + libssl-dev \ + desktop-file-utils \ + openjdk-${JAVA}-jdk-headless \ + patchelf \ + ca-certificates \ + zip \ + wget \ + fuse3 \ + file \ + appstream \ + gpg \ + unzip &&\ + export JAVA_HOME=$(update-java-alternatives -l | head -n 1 | awk -F ' ' '{print $NF}') &&\ + ln -s /bin/fusermount /bin/fusermount3 || : + +# Specify bootstrap Haskell versions +ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} +ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} + +# Do not install Stack +ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true +ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true + +# Install ghcup +RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh + +# Adjust PATH +ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH" + +# Set both as default +RUN ghcup set ghc "${GHC}" && \ + ghcup set cabal "${CABAL}" + +#===================== +# Install Android SDK +#===================== +ARG SDK_VERSION=13114758 + +ENV SDK_VERSION=$SDK_VERSION \ + ANDROID_HOME=/root + +RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \ + unzip tools.zip && rm tools.zip && \ + mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \ + ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest" + +ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin" + +# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded +RUN mkdir -p ~/.android ~/.gradle && \ + touch ~/.android/repositories.cfg && \ + echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\ + yes | sdkmanager --licenses >/dev/null + +ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools + +WORKDIR /project diff --git a/scripts/ci/linux_util_free_space.sh b/scripts/ci/linux_util_free_space.sh new file mode 100755 index 0000000000..ef00eb886e --- /dev/null +++ b/scripts/ci/linux_util_free_space.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Taken from: https://github.com/apache/arrow/blob/main/ci/scripts/util_free_space.sh + +set -eux + +df -h +echo "::group::/usr/local/*" +du -hsc /usr/local/* +echo "::endgroup::" +# ~1GB +sudo rm -rf \ + /usr/local/aws-sam-cil \ + /usr/local/julia* || : +echo "::group::/usr/local/bin/*" +du -hsc /usr/local/bin/* +echo "::endgroup::" +# ~1GB (From 1.2GB to 214MB) +sudo rm -rf \ + /usr/local/bin/aliyun \ + /usr/local/bin/azcopy \ + /usr/local/bin/bicep \ + /usr/local/bin/cmake-gui \ + /usr/local/bin/cpack \ + /usr/local/bin/helm \ + /usr/local/bin/hub \ + /usr/local/bin/kubectl \ + /usr/local/bin/minikube \ + /usr/local/bin/node \ + /usr/local/bin/packer \ + /usr/local/bin/pulumi* \ + /usr/local/bin/sam \ + /usr/local/bin/stack \ + /usr/local/bin/terraform || : +# 142M +sudo rm -rf /usr/local/bin/oc || : \ +echo "::group::/usr/local/share/*" +du -hsc /usr/local/share/* +echo "::endgroup::" +# 506MB +sudo rm -rf /usr/local/share/chromium || : +# 1.3GB +sudo rm -rf /usr/local/share/powershell || : +echo "::group::/usr/local/lib/*" +du -hsc /usr/local/lib/* +echo "::endgroup::" +# 15GB +sudo rm -rf /usr/local/lib/android || : +# 341MB +sudo rm -rf /usr/local/lib/heroku || : +# 1.2GB +sudo rm -rf /usr/local/lib/node_modules || : +echo "::group::/opt/*" +du -hsc /opt/* +echo "::endgroup::" +# 679MB +sudo rm -rf /opt/az || : +echo "::group::/opt/microsoft/*" +du -hsc /opt/microsoft/* +echo "::endgroup::" +# 197MB +sudo rm -rf /opt/microsoft/powershell || : +echo "::group::/opt/hostedtoolcache/*" +du -hsc /opt/hostedtoolcache/* +echo "::endgroup::" +# 5.3GB +sudo rm -rf /opt/hostedtoolcache/CodeQL || : +# 1.4GB +sudo rm -rf /opt/hostedtoolcache/go || : +# 489MB +sudo rm -rf /opt/hostedtoolcache/PyPy || : +# 376MB +sudo rm -rf /opt/hostedtoolcache/node || : +# Remove Web browser packages +sudo apt purge -y \ + firefox \ + google-chrome-stable \ + microsoft-edge-stable +df -h diff --git a/scripts/reproduce-builds.sh b/scripts/reproduce-builds.sh new file mode 100644 index 0000000000..1334fec0ec --- /dev/null +++ b/scripts/reproduce-builds.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env sh +set -eu + +TAG="$1" + +tempdir="$(mktemp -d)" +init_dir="$PWD" + +repo_name="simplex-chat" +repo="https://github.com/simplex-chat/${repo_name}" + +cabal_local='ignore-project: False +package direct-sqlcipher + flags: +openssl' + +export DOCKER_BUILDKIT=1 + +cleanup() { + docker exec -t builder sh -c 'rm -rf ./dist-newstyle' 2>/dev/null || : + rm -rf -- "$tempdir" + docker rm --force builder 2>/dev/null || : + docker image rm local 2>/dev/null || : + cd "$init_dir" +} +trap 'cleanup' EXIT INT + +mkdir -p "$init_dir/$TAG/from-source" "$init_dir/$TAG/prebuilt" + +git -C "$tempdir" clone "$repo.git" &&\ + cd "$tempdir/${repo_name}" &&\ + git checkout "$TAG" + +for os in 20.04 22.04; do + os_url="$(printf '%s' "$os" | tr '.' '_')" + + # Build image + docker build \ + --no-cache \ + --build-arg TAG=${os} \ + --build-arg GHC=9.6.3 \ + -f "$tempdir/${repo_name}/Dockerfile.build" \ + -t local \ + . + + printf '%s' "$cabal_local" > "$tempdir/${repo_name}/cabal.project.local" + + # Run container in background + docker run -t -d \ + --name builder \ + -v "$tempdir/${repo_name}:/project" \ + local + + docker exec \ + -t \ + builder \ + sh -c 'cabal clean && cabal update && cabal build -j --enable-tests && mkdir -p /out && for i in simplex-chat; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) && chmod +x "$bin" && mv "$bin" /out/; done && strip /out/simplex-chat' + + docker cp \ + builder:/out/simplex-chat \ + "$init_dir/$TAG/from-source/simplex-chat-ubuntu-${os_url}-x86-64" + + # Download prebuilt postgresql binary + curl -L \ + --output-dir "$init_dir/$TAG/prebuilt/" \ + -O \ + "$repo/releases/download/${TAG}/simplex-chat-ubuntu-${os_url}-x86-64" + + # Important! Remove dist-newstyle for the next interation + docker exec \ + -t \ + builder \ + sh -c 'rm -rf ./dist-newstyle' + + # Also restore git to previous state + git reset --hard && git clean -dfx + + # Stop containers, delete images + docker stop builder + docker rm --force builder + docker image rm local +done + +# Cleanup +rm -rf -- "$tempdir" +cd "$init_dir" + +# Final stage: compare hashes + +# Path to binaries +path_bin="$init_dir/$TAG" + +# Assume everything is okay for now +bad=0 + +# Check hashes for all binaries +for file in "$path_bin"/from-source/*; do + # Extract binary name + app="$(basename $file)" + + # Compute hash for compiled binary + compiled=$(sha256sum "$path_bin/from-source/$app" | awk '{print $1}') + # Compute hash for prebuilt binary + prebuilt=$(sha256sum "$path_bin/prebuilt/$app" | awk '{print $1}') + + # Compare + if [ "$compiled" != "$prebuilt" ]; then + # If hashes doesn't match, set bad... + bad=1 + + # ... and print affected binary + printf "%s - sha256sum hash doesn't match\n" "$app" + fi +done + +# If everything is still okay, compute checksums file +if [ "$bad" = 0 ]; then + sha256sum "$path_bin"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' > "$path_bin/_sha256sums" + + printf 'Checksums computed - %s\n' "$path_bin/_sha256sums" +fi From 090f576b65b9be64cc04267177763b8c4b506ae2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 12 Apr 2025 19:34:30 +0100 Subject: [PATCH 169/289] directory: allow admins deleting groups and registering groups with the same name as deleted one; /help commands; better support of other group owners; support link encoding and version changes (#5829) * allow admins deleting groups from directory and registering groups with same name as deleted one; /help commands * support profile changes by other owners, with/without connection to directory * profile check will succeed when group link encoding or versions change, but the link queues remain the same --- .../src/Directory/Events.hs | 35 ++- .../src/Directory/Service.hs | 121 ++++++--- .../src/Directory/Store.hs | 15 +- src/Simplex/Chat/Markdown.hs | 14 +- tests/Bots/DirectoryTests.hs | 240 ++++++++++++------ tests/MarkdownTests.hs | 11 +- 6 files changed, 296 insertions(+), 140 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index ed4204abb7..802221f976 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -11,6 +11,7 @@ module Directory.Events ( DirectoryEvent (..), DirectoryCmd (..), ADirectoryCmd (..), + DirectoryHelpSection (..), DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, @@ -25,6 +26,7 @@ import qualified Data.Attoparsec.Text as A import Data.Char (isSpace) import Data.Either (fromRight) import Data.Functor (($>)) +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -45,7 +47,7 @@ data DirectoryEvent = DEContactConnected Contact | DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} - | DEGroupUpdated {contactId :: ContactId, fromGroup :: GroupInfo, toGroup :: GroupInfo} + | DEGroupUpdated {member :: GroupMember, fromGroup :: GroupInfo, toGroup :: GroupInfo} | DEPendingMember GroupInfo GroupMember | DEPendingMemberMsg GroupInfo GroupMember ChatItemId Text | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed @@ -66,7 +68,7 @@ crDirectoryEvent = \case CRContactConnected {contact} -> Just $ DEContactConnected contact CRReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} CRUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember - CRGroupUpdated {fromGroup, toGroup, member_} -> (\contactId -> DEGroupUpdated {contactId, fromGroup, toGroup}) <$> (memberContactId =<< member_) + CRGroupUpdated {fromGroup, toGroup, member_} -> (\member -> DEGroupUpdated {member, fromGroup, toGroup}) <$> member_ CRJoinedGroupMember {groupInfo, member = m} | pending m -> Just $ DEPendingMember groupInfo m | otherwise -> Nothing @@ -137,8 +139,11 @@ deriving instance Show (DirectoryCmdTag r) data ADirectoryCmdTag = forall r. ADCT (SDirectoryRole r) (DirectoryCmdTag r) +data DirectoryHelpSection = DHSRegistration | DHSCommands + deriving (Show) + data DirectoryCmd (r :: DirectoryRole) where - DCHelp :: DirectoryCmd 'DRUser + DCHelp :: DirectoryHelpSection -> DirectoryCmd 'DRUser DCSearchGroup :: Text -> DirectoryCmd 'DRUser DCSearchNext :: DirectoryCmd 'DRUser DCAllGroups :: DirectoryCmd 'DRUser @@ -180,7 +185,7 @@ directoryCmdP = (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) <|> pure (ADC SDRUser DCUnknownCommand) tagP = - A.takeTill (== ' ') >>= \case + A.takeTill isSpace >>= \case "help" -> u DCHelp_ "h" -> u DCHelp_ "next" -> u DCSearchNext_ @@ -213,11 +218,19 @@ directoryCmdP = su = pure . ADCT SDRSuperUser cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r) cmdP = \case - DCHelp_ -> pure DCHelp + DCHelp_ -> DCHelp . fromMaybe DHSRegistration <$> optional (A.takeWhile isSpace *> helpSectionP) + where + helpSectionP = + A.takeText >>= \case + "registration" -> pure DHSRegistration + "r" -> pure DHSRegistration + "commands" -> pure DHSCommands + "c" -> pure DHSCommands + _ -> fail "bad help section" DCSearchNext_ -> pure DCSearchNext DCAllGroups_ -> pure DCAllGroups DCRecentGroups_ -> pure DCRecentGroups - DCSubmitGroup_ -> fmap DCSubmitGroup . strDecode . encodeUtf8 <$?> (A.takeWhile1 isSpace *> A.takeText) + DCSubmitGroup_ -> fmap DCSubmitGroup . strDecode . encodeUtf8 <$?> (spacesP *> A.takeText) DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup DCListUserGroups_ -> pure DCListUserGroups DCDeleteGroup_ -> gc DCDeleteGroup @@ -228,7 +241,7 @@ directoryCmdP = DCGroupFilter_ -> do (groupId, displayName_) <- gc_ (,) acceptance_ <- - (A.takeWhile (== ' ') >> A.endOfInput) $> Nothing + (A.takeWhile isSpace >> A.endOfInput) $> Nothing <|> Just <$> (acceptancePresetsP <|> acceptanceFiltersP) pure $ DCGroupFilter groupId displayName_ acceptance_ where @@ -272,15 +285,15 @@ directoryCmdP = where gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) - -- wordP = spacesP *> A.takeTill (== ' ') - spacesP = A.takeWhile1 (== ' ') + -- wordP = spacesP *> A.takeTill isSpace + spacesP = A.takeWhile1 isSpace viewName :: Text -> Text -viewName n = if T.any (== ' ') n then "'" <> n <> "'" else n +viewName n = if T.any isSpace n then "'" <> n <> "'" else n directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case - DCHelp -> "help" + DCHelp _ -> "help" DCSearchGroup _ -> "search" DCSearchNext -> "next" DCAllGroups -> "all" diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 054f261b4e..bf998840f6 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -48,6 +48,7 @@ import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core +import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) @@ -61,6 +62,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), SConnectionMode (..), sameConnReqContact) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) @@ -185,13 +187,13 @@ useMemberFilter img_ = \case Nothing -> False readBlockedWordsConfig :: DirectoryOpts -> IO BlockedWordsConfig -readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, nameSpellingFile, blockedExtensionRules} = do +readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, nameSpellingFile, blockedExtensionRules, testing} = do extensionRules <- maybe (pure []) (fmap read . readFile) blockedExtensionRules spelling <- maybe (pure M.empty) (fmap (M.fromList . read) . readFile) nameSpellingFile blockedFragments <- S.fromList <$> maybe (pure []) (fmap T.lines . T.readFile) blockedFragmentsFile bws <- maybe (pure []) (fmap lines . readFile) blockedWordsFile let blockedWords = S.fromList $ concatMap (wordVariants extensionRules) bws - putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) + unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () @@ -200,7 +202,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner - DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup + DEGroupUpdated {member, fromGroup, toGroup} -> deGroupUpdated member fromGroup toGroup DEPendingMember g m -> dePendingMember g m DEPendingMemberMsg g m ciId t -> dePendingMemberMsg g m ciId t DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role @@ -253,17 +255,25 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = getGroups fullName >>= mapM duplicateGroup where - sameGroup (GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}}, _) = - gId /= groupId && n == displayName && fn == fullName + sameGroupNotRemoved (g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}}, _) = + gId /= groupId && n == displayName && fn == fullName && not (memberRemoved $ membership g) duplicateGroup [] = pure DGUnique duplicateGroup groups = do - let gs = filter sameGroup groups + let gs = filter sameGroupNotRemoved groups if null gs then pure DGUnique else do (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) let reserved = any (\(GroupInfo {groupId = gId}, _) -> gId `S.member` lgs || gId `S.member` rgs) gs - pure $ if reserved then DGReserved else DGRegistered + if reserved + then pure DGReserved + else do + removed <- foldM (\r -> fmap (r &&) . isGroupRemoved) True gs + pure $ if removed then DGUnique else DGRegistered + isGroupRemoved (GroupInfo {groupId = gId}, _) = + getGroupReg st gId >>= \case + Just GroupReg {groupRegStatus} -> groupRemoved <$> readTVarIO groupRegStatus + Nothing -> pure True processInvitation :: Contact -> GroupInfo -> IO () processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do @@ -354,78 +364,95 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> notifyOwner gr $ unexpectedError "can't create group link" _ -> notifyOwner gr $ unexpectedError "can't create group link" - deGroupUpdated :: ContactId -> GroupInfo -> GroupInfo -> IO () - deGroupUpdated ctId fromGroup toGroup = do + deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () + deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do withGroupReg toGroup "group updated" $ \gr -> do let userGroupRef = userGroupReference gr toGroup + byMember = case memberContactId m of + Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. + _ -> " by " <> mName -- owner notification from directory will include the name. readTVarIO (groupRegStatus gr) >>= \case GRSPendingConfirmation -> pure () GRSProposed -> pure () GRSPendingUpdate -> groupProfileUpdate >>= \case GPNoServiceLink -> - when (ctId `isOwner` gr) $ notifyOwner gr $ "The profile updated for " <> userGroupRef <> ", but the group link is not added to the welcome message." - GPServiceLinkAdded - | ctId `isOwner` gr -> groupLinkAdded gr - | otherwise -> notifyOwner gr "The group link is added by another group member, your registration will not be processed.\n\nPlease update the group profile yourself." - GPServiceLinkRemoved -> when (ctId `isOwner` gr) $ notifyOwner gr $ "The group link of " <> userGroupRef <> " is removed from the welcome message, please add it." - GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr + notifyOwner gr $ "The profile updated for " <> userGroupRef <> byMember <> ", but the group link is not added to the welcome message." + GPServiceLinkAdded -> groupLinkAdded gr byMember + GPServiceLinkRemoved -> + notifyOwner gr $ + "The group link of " <> userGroupRef <> " is removed from the welcome message" <> byMember <> ", please add it." + GPHasServiceLink -> groupLinkAdded gr byMember GPServiceLinkError -> do - when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers." + notifyOwner gr $ + ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) + <> " after profile was updated" <> byMember <> ". Please report the error to the developers." logError $ "Error: no group link for " <> userGroupRef - GRSPendingApproval n -> processProfileChange gr $ n + 1 - GRSActive -> processProfileChange gr 1 - GRSSuspended -> processProfileChange gr 1 - GRSSuspendedBadRoles -> processProfileChange gr 1 + GRSPendingApproval n -> processProfileChange gr byMember $ n + 1 + GRSActive -> processProfileChange gr byMember 1 + GRSSuspended -> processProfileChange gr byMember 1 + GRSSuspendedBadRoles -> processProfileChange gr byMember 1 GRSRemoved -> pure () where - isInfix l d_ = l `T.isInfixOf` fromMaybe "" d_ GroupInfo {groupId, groupProfile = p} = fromGroup GroupInfo {groupProfile = p'} = toGroup sameProfile GroupProfile {displayName = n, fullName = fn, image = i, description = d} GroupProfile {displayName = n', fullName = fn', image = i', description = d'} = n == n' && fn == fn' && i == i' && d == d' - groupLinkAdded gr = do + groupLinkAdded gr byMember = do getDuplicateGroup toGroup >>= \case Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers." Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup _ -> do let gaId = 1 setGroupStatus st gr $ GRSPendingApproval gaId - notifyOwner gr $ "Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message.\nYou will be notified once the group is added to the directory - it may take up to 48 hours." + notifyOwner gr $ + ("Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message" <> byMember) + <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." checkRolesSendToApprove gr gaId - processProfileChange gr n' = do + processProfileChange gr byMember n' = do setGroupStatus st gr GRSPendingUpdate let userGroupRef = userGroupReference gr toGroup groupRef = groupReference toGroup groupProfileUpdate >>= \case GPNoServiceLink -> do - notifyOwner gr $ "The group profile is updated " <> userGroupRef <> ", but no link is added to the welcome message.\n\nThe group will remain hidden from the directory until the group link is added and the group is re-approved." + notifyOwner gr $ + ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") + <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." GPServiceLinkRemoved -> do - notifyOwner gr $ "The group link for " <> userGroupRef <> " is removed from the welcome message.\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." + notifyOwner gr $ + ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) + <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." GPServiceLinkAdded -> do setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ "The group link is added to " <> userGroupRef <> "!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group link is added to " <> groupRef <> "." + notifyOwner gr $ + ("The group link is added to " <> userGroupRef <> byMember) + <> "!\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." checkRolesSendToApprove gr n' GPHasServiceLink -> do setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ "The group " <> userGroupRef <> " is updated!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group " <> groupRef <> " is updated." + notifyOwner gr $ + ("The group " <> userGroupRef <> " is updated" <> byMember) + <> "!\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." checkRolesSendToApprove gr n' GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where profileUpdate = \case CRGroupLink {connReqContact} -> - let groupLink1 = strEncodeTxt connReqContact - groupLink2 = strEncodeTxt $ simplexChatContact connReqContact - hadLinkBefore = groupLink1 `isInfix` description p || groupLink2 `isInfix` description p - hasLinkNow = groupLink1 `isInfix` description p' || groupLink2 `isInfix` description p' + let hadLinkBefore = profileHasGroupLink fromGroup + hasLinkNow = profileHasGroupLink toGroup + profileHasGroupLink GroupInfo {groupProfile = gp} = + maybe False (any ftHasLink) $ parseMaybeMarkdownList =<< description gp + ftHasLink = \case + FormattedText (Just SimplexLink {simplexUri = ACR SCMContact cr'}) _ -> sameConnReqContact connReqContact cr' + _ -> False in if | hadLinkBefore && hasLinkNow -> GPHasServiceLink | hadLinkBefore -> GPServiceLinkRemoved @@ -617,7 +644,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () deUserCommand ct ciId = \case - DCHelp -> + DCHelp DHSRegistration -> sendMessage cc ct $ "You must be the owner to add the group to the directory:\n\ \1. Invite " @@ -628,7 +655,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName <> " bot will create a public group link for the new members to join even when you are offline.\n\ \3. You will then need to add this link to the group welcome message.\n\ \4. Once the link is added, service admins will approve the group (it can take up to 48 hours), and everybody will be able to find it in directory.\n\n\ - \Start from inviting the bot to your group as admin - it will guide you through the process" + \Start from inviting the bot to your group as admin - it will guide you through the process." + DCHelp DHSCommands -> + sendMessage cc ct $ + "*/help commands* - receive this help message.\n\ + \*/help* - how to register your group to be added to directory.\n\ + \*/list* - list the groups you registered.\n\ + \*/delete :* - remove the group you submitted from directory, with _ID_ and _name_ as shown by */list* command.\n\ + \*/role * - view and set default member role for your group.\n\ + \*/filter * - view and set spam filter settings for group.\n\n\ + \To search for groups, send the search text." DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case @@ -667,10 +703,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName "0 registered groups for " <> localDisplayName' ct <> " (" <> tshow (contactId' ct) <> ") out of " <> tshow total <> " registrations" void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} -> sendGroupInfo ct gr userGroupRegId Nothing - DCDeleteGroup ugrId gName -> - withUserGroupReg ugrId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do + DCDeleteGroup gId gName -> + (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do delGroupReg st gr - sendReply $ "Your group " <> displayName <> " is deleted from the directory" + sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" DCMemberRole gId gName_ mRole_ -> (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g @@ -802,9 +838,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName setGroupStatus st gr GRSActive let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ - (approved <> " and listed in directory!\n") + (approved <> " and listed in directory - please moderate it!\n") <> "Please note: if you change the group profile it will be hidden from directory until it is re-approved.\n\n" - <> ("Use */filter " <> tshow ugrId <> "* to configure anti-spam filter and */role " <> tshow ugrId <> "* to set default member role.") + <> "Supported commands:\n" + <> ("- */filter " <> tshow ugrId <> "* - to configure anti-spam filter.\n") + <> ("- */role " <> tshow ugrId <> "* - to set default member role.\n") + <> "- */help commands* - other commands." invited <- forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do inviteToOwnersGroup og gr $ \case diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index fed52f494f..031d05fd49 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -25,6 +25,7 @@ module Directory.Store filterListedGroups, groupRegStatusText, pendingApproval, + groupRemoved, fromCustomData, toCustomData, noJoinFilter, @@ -139,13 +140,19 @@ data GroupRegStatus | GRSSuspended | GRSSuspendedBadRoles | GRSRemoved + deriving (Show) pendingApproval :: GroupRegStatus -> Bool pendingApproval = \case GRSPendingApproval _ -> True _ -> False -data DirectoryStatus = DSListed | DSReserved | DSRegistered +groupRemoved :: GroupRegStatus -> Bool +groupRemoved = \case + GRSRemoved -> True + _ -> False + +data DirectoryStatus = DSListed | DSReserved | DSRegistered | DSRemoved groupRegStatusText :: GroupRegStatus -> Text groupRegStatusText = \case @@ -163,6 +170,7 @@ grDirectoryStatus = \case GRSActive -> DSListed GRSSuspended -> DSReserved GRSSuspendedBadRoles -> DSReserved + GRSRemoved -> DSRemoved _ -> DSRegistered $(JQ.deriveJSON (enumJSON $ dropPrefix "PC") ''ProfileCondition) @@ -200,8 +208,9 @@ addGroupReg st ct GroupInfo {groupId} grStatus = do | otherwise = mx delGroupReg :: DirectoryStore -> GroupReg -> IO () -delGroupReg st GroupReg {dbGroupId = gId} = do +delGroupReg st GroupReg {dbGroupId = gId, groupRegStatus} = do logGDelete st gId + atomically $ writeTVar groupRegStatus GRSRemoved atomically $ unlistGroup st gId atomically $ modifyTVar' (groupRegs st) $ filter ((gId ==) . dbGroupId) @@ -216,6 +225,7 @@ setGroupStatus st gr grStatus = do DSListed -> listGroup DSReserved -> reserveGroup DSRegistered -> unlistGroup + DSRemoved -> unlistGroup setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO () setGroupRegOwner st gr owner = do @@ -390,6 +400,7 @@ mkDirectoryStore h groups = DSListed -> (grs', S.insert gId listed, reserved) DSReserved -> (grs', listed, S.insert gId reserved) DSRegistered -> (grs', listed, reserved) + DSRemoved -> (grs, listed, reserved) mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> IO DirectoryStore mkDirectoryStore_ h (grs, listed, reserved) = do diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 0d1432c7e5..3ed2a8fa0b 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -49,7 +49,7 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} + | SimplexLink {linkType :: SimplexLinkType, simplexUri :: AConnectionRequestUri, smpHosts :: NonEmpty Text} | Mention {memberName :: Text} | Email | Phone @@ -255,12 +255,12 @@ markdownP = mconcat <$> A.many' fragmentP noFormat = pure . unmarked simplexUriFormat :: AConnectionRequestUri -> Format simplexUriFormat = \case - ACR _ (CRContactUri crData) -> - let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = SSSimplex} - in SimplexLink (linkType' crData) uri $ uriHosts crData - ACR _ (CRInvitationUri crData e2e) -> - let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = SSSimplex} e2e - in SimplexLink XLInvitation uri $ uriHosts crData + ACR m (CRContactUri crData) -> + let cReq = ACR m $ CRContactUri crData {crScheme = SSSimplex} + in SimplexLink (linkType' crData) cReq $ uriHosts crData + ACR m (CRInvitationUri crData e2e) -> + let cReq = ACR m $ CRInvitationUri crData {crScheme = SSSimplex} e2e + in SimplexLink XLInvitation cReq $ uriHosts crData where uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 6601032a79..92d70727e3 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -34,6 +34,7 @@ directoryServiceTests = do it "should register group" testDirectoryService it "should suspend and resume group, send message to owner" testSuspendResume it "should delete group registration" testDeleteGroup + it "admin should delete group registration" testDeleteGroupAdmin it "should change initial member role" testSetRole it "should join found group via link" testJoinGroup it "should support group names with spaces" testGroupNameWithSpaces @@ -52,10 +53,12 @@ directoryServiceTests = do it "should NOT allow approving if roles are incorrect" testNotApprovedBadRoles describe "should require re-approval if profile is changed by" $ do it "the registration owner" testRegOwnerChangedProfile - it "another owner" testAnotherOwnerChangedProfile -- TODO fix - doesn't work if another owner is not connected as contact + it "another owner" testAnotherOwnerChangedProfile + it "another owner not connected to directory" testNotConnectedOwnerChangedProfile describe "should require profile update if group link is removed by " $ do it "the registration owner" testRegOwnerRemovedLink - it "another owner" testAnotherOwnerRemovedLink -- TODO fix - doesn't work if another owner is not connected as contact + it "another owner" testAnotherOwnerRemovedLink + it "another owner not connected to directory" testNotConnectedOwnerRemovedLink describe "duplicate groups (same display name and full name)" $ do it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration @@ -186,10 +189,13 @@ testDirectoryService ps = superUser #> "@SimpleX-Directory /approve 1:PSA 1" superUser <# "SimpleX-Directory> > /approve 1:PSA 1" superUser <## " Group approved!" - bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory!" + bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory - please moderate it!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." bob <## "" - bob <## "Use /filter 1 to configure anti-spam filter and /role 1 to set default member role." + bob <## "Supported commands:" + bob <## "- /filter 1 - to configure anti-spam filter." + bob <## "- /role 1 - to set default member role." + bob <## "- /help commands - other commands." search bob "privacy" welcomeWithLink' search bob "security" welcomeWithLink' cath `connectVia` dsLink @@ -266,6 +272,38 @@ testDeleteGroup ps = bob <## " Your group privacy is deleted from the directory" groupNotFound bob "privacy" +testDeleteGroupAdmin :: HasCallStack => TestParams -> IO () +testDeleteGroupAdmin ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + cath `connectVia` dsLink + registerGroupId superUser cath "security" "Security" 2 1 + groupFound bob "privacy" + groupFound bob "security" + listUserGroup bob "privacy" "Privacy" + listUserGroup cath "security" "Security" + superUser #> "@SimpleX-Directory /last" + superUser <# "SimpleX-Directory> > /last" + superUser <## " 2 registered group(s)" + memberGroupListing superUser bob 1 "privacy" "Privacy" 2 "active" + memberGroupListing superUser cath 2 "security" "Security" 2 "active" + -- trying to register group with the same name + submitGroup bob "security" "Security" + bob <# "SimpleX-Directory> The group security (Security) is already listed in the directory, please choose another name." + bob ##> "/d #security" + bob <## "#security: you deleted the group" + -- admin can delete the group + superUser #> "@SimpleX-Directory /delete 2:security" + superUser <# "SimpleX-Directory> > /delete 2:security" + superUser <## " The group security is deleted from the directory" + groupFound bob "privacy" + groupNotFound bob "security" + -- another user can register the group with the same name + registerGroupId superUser bob "security" "Security" 4 1 + testSetRole :: HasCallStack => TestParams -> IO () testSetRole ps = withDirectoryService ps $ \superUser dsLink -> @@ -726,13 +764,34 @@ testAnotherOwnerChangedProfile ps = cath <## "full name changed to: Privacy and Security" bob <## "cath updated group #privacy:" bob <## "full name changed to: Privacy and Security" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath!" bob <## "It is hidden from the directory until approved." groupNotFound cath "privacy" - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath." reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" +testNotConnectedOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testNotConnectedOwnerChangedProfile ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + bob `connectVia` dsLink + dan `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + cath ##> "/gp privacy privacy Privacy and Security" + cath <## "full name changed to: Privacy and Security" + bob <## "cath updated group #privacy:" + bob <## "full name changed to: Privacy and Security" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath!" + bob <## "It is hidden from the directory until approved." + groupNotFound dan "privacy" + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath." + reapproveGroup 3 superUser bob + groupFoundN 3 dan "privacy" + testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () testRegOwnerRemovedLink ps = withDirectoryService ps $ \superUser dsLink -> @@ -758,14 +817,15 @@ testRegOwnerRemovedLink ps = cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" - bob ##> ("/set welcome #privacy " <> welcomeWithLink) + let withChangedLink = T.unpack $ T.replace "contact#/?v=2-7&" "contact#/?v=3-7&" $ T.pack welcomeWithLink + bob ##> ("/set welcome #privacy " <> withChangedLink) bob <## "description changed to:" - bob <## welcomeWithLink + bob <## withChangedLink bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." cath <## "bob updated group #privacy:" cath <## "description changed to:" - cath <## welcomeWithLink + cath <## withChangedLink reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" @@ -789,7 +849,7 @@ testAnotherOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "description changed to:" bob <## "Welcome!" - bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message." + bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message by cath." bob <## "" bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." @@ -800,20 +860,55 @@ testAnotherOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "description changed to:" bob <## welcomeWithLink - bob <# "SimpleX-Directory> The group link is added by another group member, your registration will not be processed." - bob <## "" - bob <## "Please update the group profile yourself." - bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") - bob <## "description changed to:" - bob <## (welcomeWithLink <> " - welcome!") - bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." + bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - cath <## "bob updated group #privacy:" - cath <## "description changed to:" - cath <## (welcomeWithLink <> " - welcome!") reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" +testNotConnectedOwnerRemovedLink :: HasCallStack => TestParams -> IO () +testNotConnectedOwnerRemovedLink ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + bob `connectVia` dsLink + dan `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + bob ##> "/show welcome #privacy" + bob <## "Welcome message:" + welcomeWithLink <- getTermLine bob + cath ##> "/set welcome #privacy Welcome!" + cath <## "description changed to:" + cath <## "Welcome!" + bob <## "cath updated group #privacy:" + bob <## "description changed to:" + bob <## "Welcome!" + bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message by cath." + bob <## "" + bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." + superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + groupNotFound dan "privacy" + cath ##> ("/set welcome #privacy " <> welcomeWithLink) + cath <## "description changed to:" + cath <## welcomeWithLink + bob <## "cath updated group #privacy:" + bob <## "description changed to:" + bob <## welcomeWithLink + -- bob <# "SimpleX-Directory> The group link is added by another group member, your registration will not be processed." + -- bob <## "" + -- bob <## "Please update the group profile yourself." + -- bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") + -- bob <## "description changed to:" + -- bob <## (welcomeWithLink <> " - welcome!") + bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." + bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." + -- cath <## "bob updated group #privacy:" + -- cath <## "description changed to:" + -- cath <## (welcomeWithLink <> " - welcome!") + reapproveGroup 3 superUser bob + groupFoundN 3 dan "privacy" + testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () testDuplicateAskConfirmation ps = withDirectoryService ps $ \superUser dsLink -> @@ -937,14 +1032,7 @@ testListUserGroups ps = cath <## "use @SimpleX-Directory to send messages" registerGroupId superUser bob "security" "Security" 2 2 registerGroupId superUser cath "anonymity" "Anonymity" 3 1 - cath #> "@SimpleX-Directory /list" - cath <# "SimpleX-Directory> > /list" - cath <## " 1 registered group(s)" - cath <# "SimpleX-Directory> 1. anonymity (Anonymity)" - cath <## "Welcome message:" - cath <##. "Link to join the group anonymity: " - cath <## "2 members" - cath <## "Status: active" + listUserGroup cath "anonymity" "Anonymity" -- with de-listed group groupFound cath "anonymity" cath ##> "/mr anonymity SimpleX-Directory member" @@ -1076,27 +1164,11 @@ testCaptcha _ps = do listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () listGroups superUser bob cath = do - bob #> "@SimpleX-Directory /list" - bob <# "SimpleX-Directory> > /list" - bob <## " 2 registered group(s)" - bob <# "SimpleX-Directory> 1. privacy (Privacy)" - bob <## "Welcome message:" - bob <##. "Link to join the group privacy: " - bob <## "3 members" - bob <## "Status: active" - bob <# "SimpleX-Directory> 2. security (Security)" - bob <## "Welcome message:" - bob <##. "Link to join the group security: " - bob <## "2 members" - bob <## "Status: active" - cath #> "@SimpleX-Directory /list" - cath <# "SimpleX-Directory> > /list" - cath <## " 1 registered group(s)" - cath <# "SimpleX-Directory> 1. anonymity (Anonymity)" - cath <## "Welcome message:" - cath <##. "Link to join the group anonymity: " - cath <## "2 members" - cath <## "Status: suspended because roles changed" + sendListCommand bob 2 + groupListing bob 1 "privacy" "Privacy" 3 "active" + groupListing bob 2 "security" "Security" 2 "active" + sendListCommand cath 1 + groupListing cath 1 "anonymity" "Anonymity" 2 "suspended because roles changed" -- superuser lists all groups bob #> "@SimpleX-Directory /last" bob <# "SimpleX-Directory> > /last" @@ -1104,34 +1176,42 @@ listGroups superUser bob cath = do superUser #> "@SimpleX-Directory /last" superUser <# "SimpleX-Directory> > /last" superUser <## " 3 registered group(s)" - superUser <# "SimpleX-Directory> 1. privacy (Privacy)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group privacy: " - superUser <## "Owner: bob" - superUser <## "3 members" - superUser <## "Status: active" - superUser <# "SimpleX-Directory> 2. security (Security)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group security: " - superUser <## "Owner: bob" - superUser <## "2 members" - superUser <## "Status: active" - superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group anonymity: " - superUser <## "Owner: cath" - superUser <## "2 members" - superUser <## "Status: suspended because roles changed" + memberGroupListing superUser bob 1 "privacy" "Privacy" 3 "active" + memberGroupListing superUser bob 2 "security" "Security" 2 "active" + memberGroupListing superUser cath 3 "anonymity" "Anonymity" 2 "suspended because roles changed" -- showing last 1 group superUser #> "@SimpleX-Directory /last 1" superUser <# "SimpleX-Directory> > /last 1" superUser <## " 3 registered group(s), showing the last 1" - superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group anonymity: " - superUser <## "Owner: cath" - superUser <## "2 members" - superUser <## "Status: suspended because roles changed" + memberGroupListing superUser cath 3 "anonymity" "Anonymity" 2 "suspended because roles changed" + +listUserGroup :: HasCallStack => TestCC -> String -> String -> IO () +listUserGroup u n fn = do + sendListCommand u 1 + groupListing u 1 n fn 2 "active" + +sendListCommand :: HasCallStack => TestCC -> Int -> IO () +sendListCommand u count = do + u #> "@SimpleX-Directory /list" + u <# "SimpleX-Directory> > /list" + u <## (" " <> show count <> " registered group(s)") + +groupListing :: HasCallStack => TestCC -> Int -> String -> String -> Int -> String -> IO () +groupListing u = groupListing_ u Nothing + +memberGroupListing :: HasCallStack => TestCC -> TestCC -> Int -> String -> String -> Int -> String -> IO () +memberGroupListing su owner = groupListing_ su (Just owner) + +groupListing_ :: HasCallStack => TestCC -> Maybe TestCC -> Int -> String -> String -> Int -> String -> IO () +groupListing_ su owner_ gId n fn count status = do + su <# ("SimpleX-Directory> " <> show gId <> ". " <> n <> " (" <> fn <> ")") + su <## "Welcome message:" + su <##. ("Link to join the group " <> n <> ": ") + forM_ owner_ $ \owner -> do + ownerName <- userName owner + su <## ("Owner: " <> ownerName) + su <## (show count <> " members") + su <## ("Status: " <> status) reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> IO () reapproveGroup count superUser bob = do @@ -1146,10 +1226,13 @@ reapproveGroup count superUser bob = do superUser #> "@SimpleX-Directory /approve 1:privacy 1" superUser <# "SimpleX-Directory> > /approve 1:privacy 1" superUser <## " Group approved!" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory!" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." bob <## "" - bob <## "Use /filter 1 to configure anti-spam filter and /role 1 to set default member role." + bob <## "Supported commands:" + bob <## "- /filter 1 - to configure anti-spam filter." + bob <## "- /role 1 - to set default member role." + bob <## "- /help commands - other commands." addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do @@ -1293,10 +1376,13 @@ approveRegistrationId su u n gId ugId = do su #> ("@SimpleX-Directory " <> approve) su <# ("SimpleX-Directory> > " <> approve) su <## " Group approved!" - u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory!") + u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory - please moderate it!") u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." u <## "" - u <## ("Use /filter " <> show ugId <> " to configure anti-spam filter and /role " <> show ugId <> " to set default member role.") + u <## "Supported commands:" + u <## ("- /filter " <> show ugId <> " - to configure anti-spam filter.") + u <## ("- /role " <> show ugId <> " - to set default member role.") + u <## "- /help commands - other commands." connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index ec4e336fe9..fc872f05b1 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -8,7 +8,9 @@ module MarkdownTests where import Data.List.NonEmpty (NonEmpty) import Data.Text (Text) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown +import Simplex.Messaging.Encoding.String import System.Console.ANSI.Types import Test.Hspec @@ -169,7 +171,12 @@ uri :: Text -> Markdown uri = Markdown $ Just Uri simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown -simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, smpHosts} +simplexLink linkType uriText smpHosts t = Markdown (simplexLinkFormat linkType uriText smpHosts) t + +simplexLinkFormat :: SimplexLinkType -> Text -> NonEmpty Text -> Maybe Format +simplexLinkFormat linkType uriText smpHosts = case strDecode $ encodeUtf8 uriText of + Right simplexUri -> Just SimplexLink {linkType, simplexUri, smpHosts} + Left e -> error e textWithUri :: Spec textWithUri = describe "text with Uri" do @@ -275,6 +282,6 @@ multilineMarkdownList = describe "multiline markdown" do it "multiline with simplex link" do ("https://simplex.chat" <> inv <> "\ntext") <<==>> - [ FormattedText (Just $ SimplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), + [ FormattedText (simplexLinkFormat XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), "\ntext" ] From 8766891124a23269359c725b509b4c201218ea7f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 12 Apr 2025 20:07:27 +0100 Subject: [PATCH 170/289] core: 6.3.2.0 (simplexmq 6.3.2.0) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 0554d14ec8..14cccb4f1f 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: aace3fd2fb146097304b09ef07ff613a8b255f67 + tag: 9abc0fa88dd70a7e30a041697335bb663c1140b7 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 41b45e9ddc..326306cf85 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."aace3fd2fb146097304b09ef07ff613a8b255f67" = "0iqdarkvlakk4xmrqsg62z0vhs3kwm02l8vpr383vf8q2hd7ky75"; + "https://github.com/simplex-chat/simplexmq.git"."9abc0fa88dd70a7e30a041697335bb663c1140b7" = "0gaqqvhb5s9xw5mq2iy8swp7w34zrkwkbjlyhggz2q9nr5680z84"; "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 f3095e7fce..ae7294a39d 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.1.1 +version: 6.3.2.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From eae281df60a8d92ea774c769304cbb9da2dc5efe Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 12 Apr 2025 21:53:53 +0100 Subject: [PATCH 171/289] 6.3.2: ios 272, android 283, desktop 98 --- 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 5e6148758c..096d65cd64 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.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.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 */; }; @@ -531,8 +531,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.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */, ); path = Libraries; sourceTree = ""; @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1988,7 +1988,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2013,7 +2013,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2038,7 +2038,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2055,11 +2055,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2075,11 +2075,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2100,7 +2100,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2115,7 +2115,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2137,7 +2137,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2152,7 +2152,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2174,7 +2174,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2200,7 +2200,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2225,7 +2225,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2251,7 +2251,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2310,7 +2310,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 271; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2325,7 +2325,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; 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 50e49c7b26..1e320972be 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.1 -android.version_code=281 +android.version_name=6.3.2 +android.version_code=283 -desktop.version_name=6.3.1 -desktop.version_code=97 +desktop.version_name=6.3.2 +desktop.version_code=98 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 14d9240995b8d1ea3990cc2f9660d5098e648f05 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 13 Apr 2025 10:50:06 +0100 Subject: [PATCH 172/289] docs: correction to command --- docs/SERVER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 4ddfb68e63..57f49b5588 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -531,7 +531,7 @@ To verify server binaries after you downloaded them: 3. Import the key with `gpg --import FB44AF81A45BDE327319797C85107E357D4A17FC`. Key filename should be the same as its fingerprint, but please change it if necessary. -4. Run `gpg --verify --trusted-key _sha256sums.asc _sha256sums`. It should print: +4. Run `gpg --verify _sha256sums.asc _sha256sums`. It should print: > Good signature from "SimpleX Chat " From 38c2529d8b2919f7966f593dbdbf5d7c35d54ee2 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 14 Apr 2025 16:01:22 +0000 Subject: [PATCH 173/289] kotlin: refactor chat contexts 1 (remove functions creating indirection) (#5827) * kotlin: refactor chat contexts 1 * remove withChats * comment * remove withReportChatsIfOpen * remove comment * fix desktop --- .../main/java/chat/simplex/app/SimplexApp.kt | 6 +- .../simplex/common/platform/UI.android.kt | 11 +- .../chat/simplex/common/model/ChatModel.kt | 86 ++-- .../chat/simplex/common/model/SimpleXAPI.kt | 382 ++++++++++-------- .../simplex/common/views/chat/ChatInfoView.kt | 44 +- .../common/views/chat/ChatItemsLoader.kt | 38 +- .../simplex/common/views/chat/ChatView.kt | 186 +++++---- .../simplex/common/views/chat/ComposeView.kt | 25 +- .../common/views/chat/ContactPreferences.kt | 8 +- .../views/chat/SelectableChatItemToolbars.kt | 4 +- .../views/chat/group/AddGroupMembersView.kt | 13 +- .../views/chat/group/GroupChatInfoView.kt | 22 +- .../views/chat/group/GroupMemberInfoView.kt | 103 +++-- .../views/chat/group/GroupPreferences.kt | 11 +- .../views/chat/group/GroupProfileView.kt | 9 +- .../views/chat/group/GroupReportsView.kt | 2 +- .../views/chat/group/WelcomeMessageView.kt | 11 +- .../views/chat/item/CIChatFeatureView.kt | 4 +- .../common/views/chat/item/ChatItemView.kt | 9 +- .../views/chat/item/MarkedDeletedItemView.kt | 3 +- .../views/chatlist/ChatListNavLinkView.kt | 70 ++-- .../common/views/chatlist/ChatListView.kt | 5 +- .../common/views/chatlist/TagListView.kt | 19 +- .../views/contacts/ContactListNavView.kt | 1 - .../common/views/database/DatabaseView.kt | 25 +- .../simplex/common/views/helpers/ModalView.kt | 2 +- .../common/views/newchat/AddGroupView.kt | 10 +- .../common/views/newchat/ConnectPlan.kt | 6 +- .../newchat/ContactConnectionInfoView.kt | 7 +- .../common/views/newchat/NewChatView.kt | 18 +- .../common/views/usersettings/Preferences.kt | 8 +- .../views/usersettings/PrivacySettings.kt | 18 +- .../kotlin/chat/simplex/common/DesktopApp.kt | 12 +- 33 files changed, 598 insertions(+), 580 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1c8209334d..5545595dc6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -24,7 +24,6 @@ import chat.simplex.app.views.call.CallActivity import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -33,7 +32,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* -import kotlinx.coroutines.flow.map import java.io.* import java.util.* import java.util.concurrent.TimeUnit @@ -94,7 +92,7 @@ class SimplexApp: Application(), LifecycleEventObserver { Lifecycle.Event.ON_START -> { isAppOnForeground = true if (chatModel.chatRunning.value == true) { - withChats { + withContext(Dispatchers.Main) { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId())) @@ -107,7 +105,7 @@ class SimplexApp: Application(), LifecycleEventObserver { /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) } - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } }.onFailure { Log.e(TAG, it.stackTraceToString()) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index a1698ae28a..f56563a1cb 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -14,12 +14,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.clear import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 import chat.simplex.res.MR +import kotlinx.coroutines.* actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() @@ -76,13 +75,13 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { ModalManager.start.closeModal() } else if (chatModel.chatId.value != null) { withApi { - withChats { + withContext(Dispatchers.Main) { // Since no modals are open, the problem is probably in ChatView chatModel.chatId.value = null - chatItems.clearAndNotify() + chatModel.chatsContext.chatItems.clearAndNotify() } - withChats { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() } } } else { 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 13cdc9f19a..59343cd326 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 @@ -67,7 +67,7 @@ object ChatModel { val chatId = mutableStateOf(null) val openAroundItemId: MutableState = mutableStateOf(null) val chatsContext = ChatsContext(null) - val reportsChatsContext = ChatsContext(MsgContentTag.Report) + val secondaryChatsContext = ChatsContext(MsgContentTag.Report) // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions val chats: State> = chatsContext.chats // rhId, chatId @@ -170,36 +170,6 @@ object ChatModel { // return true if you handled the click var centerPanelBackgroundClickHandler: (() -> Boolean)? = null - fun chatsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { - null -> chatsContext.chats - MsgContentTag.Report -> reportsChatsContext.chats - else -> TODO() - } - - fun chatItemsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { - null -> chatsContext.chatItems - MsgContentTag.Report -> reportsChatsContext.chatItems - else -> TODO() - } - - fun chatStateForContent(contentTag: MsgContentTag?): ActiveChatState = when(contentTag) { - null -> chatsContext.chatState - MsgContentTag.Report -> reportsChatsContext.chatState - else -> TODO() - } - - fun chatItemsChangesListenerForContent(contentTag: MsgContentTag?): ChatItemsChangesListener? = when(contentTag) { - null -> chatsContext.chatItemsChangesListener - MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener - else -> TODO() - } - - fun setChatItemsChangeListenerForContent(listener: ChatItemsChangesListener?, contentTag: MsgContentTag?) = when(contentTag) { - null -> chatsContext.chatItemsChangesListener = listener - MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener = listener - else -> TODO() - } - fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -324,21 +294,6 @@ object ChatModel { } } - // running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread - suspend fun withChats(contentTag: MsgContentTag? = null, action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { - when { - contentTag == null -> chatsContext.action() - contentTag == MsgContentTag.Report -> reportsChatsContext.action() - else -> TODO() - } - } - - suspend fun withReportsChatsIfOpen(action: suspend ChatsContext.() -> T) = withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { - reportsChatsContext.action() - } - } - class ChatsContext(private val contentTag: MsgContentTag?) { val chats = mutableStateOf(SnapshotStateList()) /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on. @@ -659,8 +614,9 @@ object ChatModel { subject .throttleLatest(2000) .collect { - withChats(contentTag) { - chats.replaceAll(popCollectedChats()) + withContext(Dispatchers.Main) { + val chatsCtx = if (contentTag == null) chatsContext else secondaryChatsContext + chatsCtx.chats.replaceAll(popCollectedChats()) } } } @@ -960,17 +916,17 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) - withChats { - chatItems.addAndNotify(cItem, contentTag = null) + withContext(Dispatchers.Main) { + chatsContext.chatItems.addAndNotify(cItem, contentTag = null) } return cItem } fun removeLiveDummy() { - if (chatItemsForContent(null).value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + if (chatsContext.chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { withApi { - withChats { - chatItems.removeLastAndNotify(contentTag = null) + withContext(Dispatchers.Main) { + chatsContext.chatItems.removeLastAndNotify(contentTag = null) } } } @@ -1042,9 +998,11 @@ object ChatModel { fun replaceConnReqView(id: String, withId: String) { if (id == showingInvitation.value?.connId) { withApi { - withChats { + withContext(Dispatchers.Main) { showingInvitation.value = null - chatItems.clearAndNotify() + // TODO [contexts] - why does clearAndNotify operates with listeners for both contexts? + // TODO - should it be called for both contexts here instead? + chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = withId } } @@ -1055,9 +1013,10 @@ object ChatModel { fun dismissConnReqView(id: String) = withApi { if (id == showingInvitation.value?.connId) { - withChats { + withContext(Dispatchers.Main) { showingInvitation.value = null - chatItems.clearAndNotify() + // TODO [contexts] see replaceConnReqView + chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = null } // Close NewChatView @@ -2739,7 +2698,8 @@ fun MutableState>.add(index: Int, elem: Chat) { } fun MutableState>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) { - value = SnapshotStateList().apply { addAll(value); add(index, elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, index) } + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + value = SnapshotStateList().apply { addAll(value); add(index, elem); chatsCtx.chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) } } fun MutableState>.add(elem: Chat) { @@ -2751,7 +2711,8 @@ fun MutableList.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmp // Adds item to chatItems and notifies a listener about newly added item fun MutableState>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) { - value = SnapshotStateList().apply { addAll(value); add(elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, lastIndex) } + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + value = SnapshotStateList().apply { addAll(value); add(elem); chatsCtx.chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) } } fun MutableState>.addAll(index: Int, elems: List) { @@ -2781,7 +2742,7 @@ fun MutableState>.removeAllAndNotify(block: (ChatIte } if (toRemove.isNotEmpty()) { chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value) - chatModel.reportsChatsContext.chatItemsChangesListener?.removed(toRemove, value) + chatModel.secondaryChatsContext.chatItemsChangesListener?.removed(toRemove, value) } } @@ -2801,7 +2762,8 @@ fun MutableState>.removeLastAndNotify(contentTag: Ms val rem = removeLast() removed = Triple(rem.id, remIndex, rem.isRcvNew) } - chatModel.chatItemsChangesListenerForContent(contentTag)?.removed(listOf(removed), value) + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + chatsCtx.chatItemsChangesListener?.removed(listOf(removed), value) } fun MutableState>.replaceAll(elems: List) { @@ -2816,7 +2778,7 @@ fun MutableState>.clear() { fun MutableState>.clearAndNotify() { value = SnapshotStateList() chatModel.chatsContext.chatItemsChangesListener?.cleared() - chatModel.reportsChatsContext.chatItemsChangesListener?.cleared() + chatModel.secondaryChatsContext.chatItemsChangesListener?.cleared() } fun State>.asReversed(): MutableList = value.asReversed() 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 b04a08e0e1..a090919e6d 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 @@ -17,8 +17,6 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.model.MsgContent.MCUnknown import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* @@ -544,9 +542,9 @@ object ChatController { } Log.d(TAG, "startChat: started") } else { - withChats { + withContext(Dispatchers.Main) { val chats = apiGetChats(null) - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } Log.d(TAG, "startChat: running") } @@ -627,9 +625,9 @@ object ChatController { val hasUser = chatModel.currentUser.value != null chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None - withChats { + withContext(Dispatchers.Main) { val chats = apiGetChats(rhId) - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() chatModel.activeChatTagFilter.value = null @@ -1490,8 +1488,8 @@ object ChatController { suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { val cInfo = chat.chatInfo if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) { - withChats { - removeChat(chat.remoteHostId, cInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(chat.remoteHostId, cInfo.id) } } } @@ -1539,11 +1537,11 @@ object ChatController { withBGApi { val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId) if (updatedChatInfo != null) { - withChats { - clearChat(chat.remoteHostId, updatedChatInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.clearChat(chat.remoteHostId, updatedChatInfo) } - withChats(MsgContentTag.Report) { - clearChat(chat.remoteHostId, updatedChatInfo) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.clearChat(chat.remoteHostId, updatedChatInfo) } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() @@ -1975,12 +1973,14 @@ object ChatController { val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) when (r) { is CR.UserAcceptedGroupSent -> - withChats { - updateGroup(rh, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rh, r.groupInfo) } is CR.ChatCmdError -> { val e = r.chatError - suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { withChats { removeChat(rh, "#$groupId") } } } + suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { + withContext(Dispatchers.Main) { chatModel.chatsContext.removeChat(rh, "#$groupId") } } + } if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) { deleteGroup() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.alert_title_group_invitation_expired), generalGetString(MR.strings.alert_message_group_invitation_expired)) @@ -2134,8 +2134,8 @@ object ChatController { val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param) val toContact = apiSetContactPrefs(rh, contact.contactId, prefs) if (toContact != null) { - withChats { - updateContact(rh, toContact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rh, toContact) } } } @@ -2406,19 +2406,19 @@ object ChatController { when (r) { is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } @@ -2429,24 +2429,24 @@ object ChatController { } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } } is CR.ContactSndReady -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } @@ -2456,11 +2456,11 @@ object ChatController { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { - withChats { - if (hasChat(rhId, contactRequest.id)) { - updateChatInfo(rhId, cInfo) + withContext(Dispatchers.Main) { + if (chatModel.chatsContext.hasChat(rhId, contactRequest.id)) { + chatModel.chatsContext.updateChatInfo(rhId, cInfo) } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) + chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) } } } @@ -2469,18 +2469,20 @@ object ChatController { is CR.ContactUpdated -> { if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) - withChats { - updateChatInfo(rhId, cInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatInfo(rhId, cInfo) } } } is CR.GroupMemberUpdated -> { if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.toMember) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.toMember) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.toMember) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.toMember) + } } } } @@ -2489,8 +2491,8 @@ object ChatController { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } - withChats { - removeChat(rhId, r.mergedContact.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, r.mergedContact.id) } } } @@ -2501,8 +2503,8 @@ object ChatController { is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { - withChats { - updateContact(rhId, sub.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, sub.contact) } } val err = sub.contactError @@ -2528,20 +2530,22 @@ object ChatController { val cInfo = chatItem.chatInfo val cItem = chatItem.chatItem if (active(r.user)) { - withChats { - addChatItem(rhId, cInfo, cItem) + withContext(Dispatchers.Main) { + chatModel.chatsContext.addChatItem(rhId, cInfo, cItem) if (cItem.isActiveReport) { - increaseGroupReportsCounter(rhId, cInfo.id) + chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } } - withReportsChatsIfOpen { - if (cItem.isReport) { - addChatItem(rhId, cInfo, cItem) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (cItem.isReport) { + chatModel.secondaryChatsContext.addChatItem(rhId, cInfo, cItem) + } } } } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { - withChats { - increaseUnreadCounter(rhId, r.user) + withContext(Dispatchers.Main) { + chatModel.chatsContext.increaseUnreadCounter(rhId, r.user) } } val file = cItem.file @@ -2562,12 +2566,14 @@ object ChatController { val cInfo = chatItem.chatInfo val cItem = chatItem.chatItem if (!cItem.isDeletedContent && active(r.user)) { - withChats { - updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } - withReportsChatsIfOpen { - if (cItem.isReport) { - updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (cItem.isReport) { + chatModel.secondaryChatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + } } } } @@ -2576,12 +2582,14 @@ object ChatController { chatItemUpdateNotify(rhId, r.user, r.chatItem) is CR.ChatItemReaction -> { if (active(r.user)) { - withChats { - updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } - withReportsChatsIfOpen { - if (r.reaction.chatReaction.chatItem.isReport) { - updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (r.reaction.chatReaction.chatItem.isReport) { + chatModel.secondaryChatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + } } } } @@ -2590,8 +2598,8 @@ object ChatController { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled(deletedChatItem.chatItem)) { - withChats { - decreaseUnreadCounter(rhId, r.user) + withContext(Dispatchers.Main) { + chatModel.chatsContext.decreaseUnreadCounter(rhId, r.user) } } } @@ -2614,22 +2622,24 @@ object ChatController { generalGetString(if (toChatItem != null) MR.strings.marked_deleted_description else MR.strings.deleted_description) ) } - withChats { + withContext(Dispatchers.Main) { if (toChatItem == null) { - removeChatItem(rhId, cInfo, cItem) + chatModel.chatsContext.removeChatItem(rhId, cInfo, cItem) } else { - upsertChatItem(rhId, cInfo, toChatItem.chatItem) + chatModel.chatsContext.upsertChatItem(rhId, cInfo, toChatItem.chatItem) } if (cItem.isActiveReport) { - decreaseGroupReportsCounter(rhId, cInfo.id) + chatModel.chatsContext.decreaseGroupReportsCounter(rhId, cInfo.id) } } - withReportsChatsIfOpen { - if (cItem.isReport) { - if (toChatItem == null) { - removeChatItem(rhId, cInfo, cItem) - } else { - upsertChatItem(rhId, cInfo, toChatItem.chatItem) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (cItem.isReport) { + if (toChatItem == null) { + chatModel.secondaryChatsContext.removeChatItem(rhId, cInfo, cItem) + } else { + chatModel.secondaryChatsContext.upsertChatItem(rhId, cInfo, toChatItem.chatItem) + } } } } @@ -2640,9 +2650,9 @@ object ChatController { } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { - withChats { + withContext(Dispatchers.Main) { // update so that repeat group invitations are not duplicated - updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } // TODO NtfManager.shared.notifyGroupInvitation } @@ -2650,137 +2660,149 @@ object ChatController { is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) val conn = r.hostContact?.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "#${r.groupInfo.groupId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } is CR.GroupLinkConnecting -> { if (!active(r.user)) return - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") - removeChat(rhId, hostConn.id) + chatModel.chatsContext.removeChat(rhId, hostConn.id) } } } is CR.BusinessLinkConnecting -> { if (!active(r.user)) return - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } if (chatModel.chatId.value == r.fromContact.id) { openGroupChat(rhId, r.groupInfo.groupId) } - withChats { - removeChat(rhId, r.fromContact.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, r.fromContact.id) } } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) if (r.withMessages) { - removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + chatModel.chatsContext.removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) } } - withReportsChatsIfOpen { - if (r.withMessages) { - removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (r.withMessages) { + chatModel.secondaryChatsContext.removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + } } } } is CR.DeletedMember -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) if (r.withMessages) { - removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) + chatModel.chatsContext.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) } } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.deletedMember) - if (r.withMessages) { - removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + if (r.withMessages) { + chatModel.secondaryChatsContext.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) + } } } } is CR.LeftMember -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + } } } is CR.MemberRole -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + } } } is CR.MembersRoleUser -> if (active(r.user)) { - withChats { + withContext(Dispatchers.Main) { r.members.forEach { member -> - upsertGroupMember(rhId, r.groupInfo, member) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, member) } } - withReportsChatsIfOpen { - r.members.forEach { member -> - upsertGroupMember(rhId, r.groupInfo, member) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + r.members.forEach { member -> + chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, member) + } } } } is CR.MemberBlockedForAll -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + } } } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } } is CR.UserJoinedGroup -> if (active(r.user)) { - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } } is CR.JoinedGroupMember -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.ConnectedToGroupMember -> { if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } if (r.memberContact != null) { @@ -2789,14 +2811,14 @@ object ChatController { } is CR.GroupUpdated -> if (active(r.user)) { - withChats { - updateGroup(rhId, r.toGroup) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.toGroup) } } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } is CR.RcvFileStart -> @@ -2897,26 +2919,26 @@ object ChatController { } is CR.ContactSwitch -> if (active(r.user)) { - withChats { - updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) } } is CR.GroupMemberSwitch -> if (active(r.user)) { - withChats { - updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) } } is CR.ContactRatchetSync -> if (active(r.user)) { - withChats { - updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) } } is CR.GroupMemberRatchetSync -> if (active(r.user)) { - withChats { - updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) } } is CR.RemoteHostSessionCode -> { @@ -2930,8 +2952,8 @@ object ChatController { } is CR.ContactDisabled -> { if (active(r.user)) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } } @@ -3055,8 +3077,8 @@ object ChatController { } is CR.ContactPQEnabled -> if (active(r.user)) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } is CR.ChatRespError -> when { @@ -3120,8 +3142,8 @@ object ChatController { suspend fun leaveGroup(rh: Long?, groupId: Long) { val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { - withChats { - updateGroup(rh, groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rh, groupInfo) } } } @@ -3130,10 +3152,12 @@ object ChatController { if (activeUser(rh, user)) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem - withChats { upsertChatItem(rh, cInfo, cItem) } - withReportsChatsIfOpen { - if (cItem.isReport) { - upsertChatItem(rh, cInfo, cItem) + withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (cItem.isReport) { + chatModel.secondaryChatsContext.upsertChatItem(rh, cInfo, cItem) + } } } } @@ -3147,15 +3171,16 @@ object ChatController { return } val cInfo = ChatInfo.Group(r.groupInfo) - withChats { + withContext(Dispatchers.Main) { + val chatsCtx = chatModel.chatsContext r.chatItemIDs.forEach { itemId -> - decreaseGroupReportsCounter(rhId, cInfo.id) - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + chatsCtx.decreaseGroupReportsCounter(rhId, cInfo.id) + val cItem = chatsCtx.chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach if (chatModel.chatId.value != null) { // Stop voice playback only inside a chat, allow to play in a chat list AudioPlayer.stop(cItem) } - val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + val isLastChatItem = chatsCtx.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { ntfManager.cancelNotificationsForChat(cInfo.id) ntfManager.displayNotification( @@ -3170,22 +3195,25 @@ object ChatController { } else { CIDeleted.Deleted(Clock.System.now()) } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + chatsCtx.upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) } } - withReportsChatsIfOpen { - r.chatItemIDs.forEach { itemId -> - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach - if (chatModel.chatId.value != null) { - // Stop voice playback only inside a chat, allow to play in a chat list - AudioPlayer.stop(cItem) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + val chatsCtx = chatModel.secondaryChatsContext + r.chatItemIDs.forEach { itemId -> + val cItem = chatsCtx.chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + chatsCtx.upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { - CIDeleted.Moderated(Clock.System.now(), r.member_) - } else { - CIDeleted.Deleted(Clock.System.now()) - } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) } } } @@ -3197,8 +3225,14 @@ object ChatController { if (!activeUser(rh, user)) { notify() } else { - val createdChat = withChats { upsertChatItem(rh, cInfo, cItem) } - withReportsChatsIfOpen { if (cItem.content.msgContent is MsgContent.MCReport) { upsertChatItem(rh, cInfo, cItem) } } + val createdChat = withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (cItem.content.msgContent is MsgContent.MCReport) { + chatModel.secondaryChatsContext.upsertChatItem(rh, cInfo, cItem) + } + } + } if (createdChat) { notify() } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { @@ -3243,15 +3277,17 @@ object ChatController { chatModel.users.addAll(users) chatModel.currentUser.value = user if (user == null) { - withChats { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() + chatModel.chatsContext.chats.clear() + chatModel.chatsContext.popChatCollector.clear() } - withReportsChatsIfOpen { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.chatItems.clearAndNotify() + chatModel.secondaryChatsContext.chats.clear() + chatModel.secondaryChatsContext.popChatCollector.clear() + } } } val statuses = apiGetNetworkStatuses(rhId) 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 a730bd1b71..d5a99d9acb 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 @@ -19,7 +19,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* @@ -37,17 +36,16 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats 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.ProgressIndicator 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.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -126,8 +124,8 @@ fun ChatInfoView( val cStats = chatModel.controller.apiSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - withChats { - updateContactConnectionStats(chatRh, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, cStats) } } close.invoke() @@ -140,8 +138,8 @@ fun ChatInfoView( val cStats = chatModel.controller.apiAbortSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - withChats { - updateContactConnectionStats(chatRh, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, cStats) } } } @@ -171,8 +169,8 @@ fun ChatInfoView( verify = { code -> chatModel.controller.apiVerifyContact(chatRh, ct.contactId, code)?.let { r -> val (verified, existingCode) = r - withChats { - updateContact( + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact( chatRh, ct.copy( activeConn = ct.activeConn?.copy( @@ -200,8 +198,8 @@ suspend fun syncContactConnection(rhId: Long?, contact: Contact, connectionStats val cStats = chatModel.controller.apiSyncContactRatchet(rhId, contact.contactId, force = force) connectionStats.value = cStats if (cStats != null) { - withChats { - updateContactConnectionStats(rhId, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(rhId, contact, cStats) } } } @@ -475,14 +473,14 @@ fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDe val chatRh = chat.remoteHostId val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode) if (ct != null) { - withChats { + withContext(Dispatchers.Main) { when (chatDeleteMode) { is ChatDeleteMode.Full -> - removeChat(chatRh, chatInfo.id) + chatModel.chatsContext.removeChat(chatRh, chatInfo.id) is ChatDeleteMode.Entity -> - updateContact(chatRh, ct) + chatModel.chatsContext.updateContact(chatRh, ct) is ChatDeleteMode.Messages -> - clearChat(chatRh, ChatInfo.Direct(ct)) + chatModel.chatsContext.clearChat(chatRh, ChatInfo.Direct(ct)) } } if (chatModel.chatId.value == chatInfo.id) { @@ -1270,11 +1268,11 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c wallpaperFilesToDelete.forEach(::removeWallpaperFile) if (controller.apiSetChatUIThemes(chat.remoteHostId, chat.id, changedThemes)) { - withChats { + withContext(Dispatchers.Main) { if (chat.chatInfo is ChatInfo.Direct) { - updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes))) + chatModel.chatsContext.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes))) } else if (chat.chatInfo is ChatInfo.Group) { - updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes))) + chatModel.chatsContext.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes))) } } } @@ -1283,8 +1281,8 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { val chatRh = chat.remoteHostId chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { - withChats { - updateContact(chatRh, it) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(chatRh, it) } } } @@ -1386,10 +1384,10 @@ private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressInd val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, 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 - withChats { - val oldChat = getChat(chat.id) + withContext(Dispatchers.Main) { + val oldChat = chatModel.chatsContext.getChat(chat.id) if (oldChat != null) { - replaceChat(oldChat.remoteHostId, oldChat.id, oldChat.copy(chatItems = emptyList())) + chatModel.chatsContext.replaceChat(oldChat.remoteHostId, oldChat.id, oldChat.copy(chatItems = emptyList())) } } } 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 385bf42397..51a6d24e21 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 @@ -2,7 +2,6 @@ package chat.simplex.common.views.chat import androidx.compose.runtime.snapshots.SnapshotStateList import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.chatModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.StateFlow @@ -48,29 +47,30 @@ suspend fun processLoadedChat( openAroundItemId: Long?, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) { - val chatState = chatModel.chatStateForContent(contentTag) + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + val chatState = chatsCtx.chatState val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState - val oldItems = chatModel.chatItemsForContent(contentTag).value + val oldItems = chatsCtx.chatItems.value val newItems = SnapshotStateList() when (pagination) { is ChatPagination.Initial -> { val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList() if (contentTag == null) { // update main chats, not content tagged - withChats { - val oldChat = getChat(chat.id) + withContext(Dispatchers.Main) { + val oldChat = chatModel.chatsContext.getChat(chat.id) if (oldChat == null) { - addChat(chat) + chatModel.chatsContext.addChat(chat) } else { - updateChatInfo(chat.remoteHostId, chat.chatInfo) + chatModel.chatsContext.updateChatInfo(chat.remoteHostId, chat.chatInfo) // unreadChat is currently not actual in getChat query (always false) - updateChatStats(chat.remoteHostId, chat.id, chat.chatStats.copy(unreadChat = oldChat.chatStats.unreadChat)) + chatModel.chatsContext.updateChatStats(chat.remoteHostId, chat.id, chat.chatStats.copy(unreadChat = oldChat.chatStats.unreadChat)) } } } - withChats(contentTag) { - chatItemStatuses.clear() - chatItems.replaceAll(chat.chatItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItemStatuses.clear() + chatsCtx.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { @@ -93,8 +93,8 @@ suspend fun processLoadedChat( ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) } @@ -112,8 +112,8 @@ suspend fun processLoadedChat( val indexToAdd = min(indexInCurrentItems + 1, newItems.size) val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) // loading clear bottom area, updating number of unread items after the newest loaded item @@ -134,8 +134,8 @@ suspend fun processLoadedChat( newItems.addAll(itemIndex, chat.chatItems) newSplits.add(splitIndex, chat.chatItems.last().id) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal @@ -157,8 +157,8 @@ suspend fun processLoadedChat( val newSplits = removeDuplicatesAndUnusedSplits(newItems, chat, chatState.splits.value) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) chatState.splits.value = newSplits unreadAfterNewestLoaded.value = 0 } 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 94a5fd3549..c948437ac3 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 @@ -32,8 +32,6 @@ import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.group.* @@ -114,9 +112,10 @@ fun ChatView( val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext val unreadCount = remember { derivedStateOf { - chatModel.chatsForContent(contentTag).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 + chatsCtx.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current @@ -389,23 +388,25 @@ fun ChatView( if (deleted != null) { deletedChatItem = deleted.deletedChatItem.chatItem toChatItem = deleted.toChatItem?.chatItem - withChats { + withContext(Dispatchers.Main) { if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) + chatModel.chatsContext.upsertChatItem(chatRh, chatInfo, toChatItem) } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) + chatModel.chatsContext.removeChatItem(chatRh, chatInfo, deletedChatItem) } val deletedItem = deleted.deletedChatItem.chatItem if (deletedItem.isActiveReport) { - decreaseGroupReportsCounter(chatRh, chatInfo.id) + chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id) } } - withReportsChatsIfOpen { - if (deletedChatItem.isReport) { - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (deletedChatItem.isReport) { + if (toChatItem != null) { + chatModel.secondaryChatsContext.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.secondaryChatsContext.removeChatItem(chatRh, chatInfo, deletedChatItem) + } } } } @@ -463,8 +464,8 @@ fun ChatView( if (r != null) { val contactStats = r.first if (contactStats != null) - withChats { - updateContactConnectionStats(chatRh, contact, contactStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, contactStats) } } } @@ -475,8 +476,8 @@ fun ChatView( if (r != null) { val memStats = r.second if (memStats != null) { - withChats { - updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) } } } @@ -486,8 +487,8 @@ fun ChatView( withBGApi { val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) if (cStats != null) { - withChats { - updateContactConnectionStats(chatRh, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, cStats) } } } @@ -496,8 +497,8 @@ fun ChatView( withBGApi { val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { - withChats { - updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) } } } @@ -519,12 +520,14 @@ fun ChatView( reaction = reaction ) if (updatedCI != null) { - withChats { - updateChatItem(cInfo, updatedCI) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(cInfo, updatedCI) } - withReportsChatsIfOpen { - if (cItem.isReport) { - updateChatItem(cInfo, updatedCI) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + if (cItem.isReport) { + chatModel.secondaryChatsContext.updateChatItem(cInfo, updatedCI) + } } } } @@ -544,7 +547,7 @@ fun ChatView( groupMembersJob.cancel() groupMembersJob = scope.launch(Dispatchers.Default) { var initialCiInfo = loadChatItemInfo() ?: return@launch - if (!ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + if (!ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(endButtons = { @@ -578,11 +581,8 @@ fun ChatView( openGroupLink = { groupInfo -> openGroupLink(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) }, markItemsRead = { itemsIds -> withBGApi { - withChats { - // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace - withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo.id, itemsIds) - } + withContext(Dispatchers.Main) { + chatModel.chatsContext.markChatItemsRead(chatRh, chatInfo.id, itemsIds) ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatItemsRead( chatRh, @@ -591,18 +591,17 @@ fun ChatView( itemsIds ) } - withReportsChatsIfOpen { - markChatItemsRead(chatRh, chatInfo.id, itemsIds) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.markChatItemsRead(chatRh, chatInfo.id, itemsIds) + } } } }, markChatRead = { withBGApi { - withChats { - // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace - withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo.id) - } + withContext(Dispatchers.Main) { + chatModel.chatsContext.markChatItemsRead(chatRh, chatInfo.id) ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatRead( chatRh, @@ -610,8 +609,10 @@ fun ChatView( chatInfo.apiId ) } - withReportsChatsIfOpen { - markChatItemsRead(chatRh, chatInfo.id) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.markChatItemsRead(chatRh, chatInfo.id) + } } } }, @@ -636,8 +637,8 @@ fun ChatView( LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - withChats { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() } } } @@ -649,8 +650,8 @@ fun ChatView( LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - withChats { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() } } } @@ -1206,12 +1207,13 @@ fun BoxScope.ChatItemsList( val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } val contentTag = LocalContentTag.current // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext val mergedItems = remember { derivedStateOf { - MergedItems.create(chatModel.chatItemsForContent(contentTag).value.asReversed(), unreadCount, revealedItems.value, chatModel.chatStateForContent(contentTag)) + MergedItems.create(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) } } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value.asReversed() } } + val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } val reportsCount = reportsCount(chatInfo.id) val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0) val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) @@ -1297,11 +1299,11 @@ fun BoxScope.ChatItemsList( DisposableEffectOnGone( always = { - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) + chatsCtx.chatItemsChangesListener = recalculateChatStatePositions(chatsCtx.chatState) }, whenGone = { VideoPlayerHolder.releaseAll() - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) + chatsCtx.chatItemsChangesListener = recalculateChatStatePositions(chatsCtx.chatState) } ) @@ -1648,8 +1650,9 @@ private suspend fun loadLastItems(chatId: State, contentTag: MsgContentT } private fun lastItemsLoaded(contentTag: MsgContentTag?): Boolean { - val chatState = chatModel.chatStateForContent(contentTag) - return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + val chatState = chatsCtx.chatState + return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatsCtx.chatItems.value.lastOrNull()?.id } // TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message @@ -1740,7 +1743,8 @@ fun BoxScope.FloatingButtons( fun scrollToTopUnread() { scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { - if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) { + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + if (chatsCtx.chatState.splits.value.isNotEmpty()) { val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) val oldSize = reversedChatItems.value.size loadMessages(chatInfo.value.id, pagination) { @@ -2129,7 +2133,7 @@ private fun SaveReportsStateOnDispose(listState: State) { val contentTag = LocalContentTag.current DisposableEffect(Unit) { onDispose { - reportsListState = if (contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null + reportsListState = if (contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) listState.value else null } } } @@ -2240,8 +2244,10 @@ fun reportsCount(staleChatId: String?): Int { } } -private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List = - chatModel.chatItemsForContent(contentTag).value.asReversed() +private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List { + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + return chatsCtx.chatItems.value.asReversed() +} private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State, mergedItems: State, listState: State): ListItem? { val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value @@ -2326,11 +2332,13 @@ private fun findQuotedItemFromItem( scope.launch(Dispatchers.Default) { val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId, contentTag) if (item != null) { - withChats { - updateChatItem(chatInfo.value, item) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(chatInfo.value, item) } - withReportsChatsIfOpen { - updateChatItem(chatInfo.value, item) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.updateChatItem(chatInfo.value, item) + } } if (item.quotedItem?.itemId != null) { scrollToItem(item.quotedItem.itemId) @@ -2510,28 +2518,30 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List, chatInfo: ChatInfo) { chatModel.chatId.value = null chatModel.sharedContent.value = SharedContent.Forward( - chatModel.chatItemsForContent(null).value.filter { chatItemsIds.contains(it.id) }, + chatModel.chatsContext.chatItems.value.filter { chatItemsIds.contains(it.id) }, chatInfo ) } 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 b48b32030f..de9fc26905 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 @@ -25,7 +25,6 @@ import androidx.compose.ui.util.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* @@ -473,8 +472,8 @@ fun ComposeView( ) if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> - withChats { - addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + withContext(Dispatchers.Main) { + chatModel.chatsContext.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -505,9 +504,9 @@ fun ComposeView( ttl = ttl ) - withChats { + withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - addChatItem(rhId, chat.chatInfo, chatItem) + chatModel.chatsContext.addChatItem(rhId, chat.chatInfo, chatItem) } } @@ -567,9 +566,9 @@ 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) { - withChats { + withContext(Dispatchers.Main) { cItems.forEach { chatItem -> - addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + chatModel.chatsContext.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) } } } @@ -581,8 +580,8 @@ fun ComposeView( val mc = checkLinkPreview() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { - withChats { - updateContact(chat.remoteHostId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(chat.remoteHostId, contact) } } } @@ -599,8 +598,10 @@ fun ComposeView( updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) - if (updatedItem != null) withChats { - upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + if (updatedItem != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + } } return updatedItem?.chatItem } @@ -890,7 +891,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatItemsForContent(null).value.findLast { it.meta.editable } + val lastEditable = chatModel.chatsContext.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index b1e9bf750e..7c04c30f67 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -12,16 +12,16 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.res.MR +import kotlinx.coroutines.* @Composable fun ContactPreferencesView( @@ -41,8 +41,8 @@ fun ContactPreferencesView( val prefs = contactFeaturesAllowedToPrefs(featuresAllowed) val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs) if (toContact != null) { - withChats { - updateContact(rhId, toContact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, toContact) currentFeaturesAllowed = featuresAllowed } } 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 50e6f73bca..70210778ac 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.* import dev.icerock.moko.resources.compose.stringResource import chat.simplex.res.MR @@ -121,7 +122,8 @@ fun SelectedItemsButtonsToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - val chatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value } } + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + val chatItems = remember { derivedStateOf { chatsCtx.chatItems.value } } LaunchedEffect(chatInfo, chatItems.value, selectedChatItems.value) { recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } 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 abfb3895d9..a6d009f76a 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 @@ -25,8 +25,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ChatInfoToolbarTitle import chat.simplex.common.views.helpers.* @@ -35,6 +33,7 @@ import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.* @Composable fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { @@ -62,11 +61,13 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea for (contactId in selectedContacts) { val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { - withChats { - upsertGroupMember(rhId, groupInfo, member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, member) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, member) + } } } else { break 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 f38cd972f3..b80e46eeb6 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 @@ -32,8 +32,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -182,8 +180,8 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl withBGApi { val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId) if (r) { - withChats { - removeChat(chat.remoteHostId, chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(chat.remoteHostId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -957,8 +955,8 @@ private fun SearchRowView( private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { val chatRh = chat.remoteHostId chatModel.controller.apiSetGroupAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { - withChats { - updateGroup(chatRh, it) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(chatRh, it) } } } @@ -967,14 +965,16 @@ fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSu withBGApi { val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) if (updatedMembers != null) { - withChats { + withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, groupInfo, updatedMember) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, updatedMember) } } - withReportsChatsIfOpen { - updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, groupInfo, updatedMember) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + updatedMembers.forEach { updatedMember -> + chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, 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 38163f9b6e..638722463d 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 @@ -27,8 +27,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -40,6 +38,7 @@ import chat.simplex.common.views.chatlist.openLoadedChat import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock +import kotlinx.coroutines.* @Composable fun GroupMemberInfoView( @@ -63,11 +62,13 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } } close.invoke() } @@ -100,8 +101,8 @@ fun GroupMemberInfoView( val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId) if (memberContact != null) { val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) - withChats { - addChat(memberChat) + withContext(Dispatchers.Main) { + chatModel.chatsContext.addChat(memberChat) } openLoadedChat(memberChat) closeAll() @@ -149,11 +150,13 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } } close.invoke() } @@ -166,11 +169,13 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiAbortSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } } close.invoke() } @@ -186,11 +191,13 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = true) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } } close.invoke() } @@ -212,11 +219,13 @@ fun GroupMemberInfoView( connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null ) ) - withChats { - upsertGroupMember(rhId, groupInfo, copy) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, copy) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, copy) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, copy) + } } r } @@ -247,14 +256,16 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c withBGApi { val removedMembers = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) if (removedMembers != null) { - withChats { + withContext(Dispatchers.Main) { removedMembers.forEach { removedMember -> - upsertGroupMember(rhId, groupInfo, removedMember) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, removedMember) } } - withReportsChatsIfOpen { - removedMembers.forEach { removedMember -> - upsertGroupMember(rhId, groupInfo, removedMember) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + removedMembers.forEach { removedMember -> + chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, removedMember) + } } } } @@ -697,14 +708,16 @@ fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInf withBGApi { kotlin.runCatching { val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, memberIds, newRole) - withChats { + withContext(Dispatchers.Main) { members.forEach { member -> - upsertGroupMember(rhId, groupInfo, member) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, member) } } - withReportsChatsIfOpen { - members.forEach { member -> - upsertGroupMember(rhId, groupInfo, member) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + members.forEach { member -> + chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, member) + } } } onSuccess() @@ -798,11 +811,13 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem withBGApi { val success = ChatController.apiSetMemberSettings(rhId, gInfo.groupId, member.groupMemberId, memberSettings) if (success) { - withChats { - upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + } } } } @@ -857,14 +872,16 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onS fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, memberIds: List, blocked: Boolean, onSuccess: () -> Unit = {}) { withBGApi { val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, memberIds, blocked) - withChats { + withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, gInfo, updatedMember) + chatModel.chatsContext.upsertGroupMember(rhId, gInfo, updatedMember) } } - withReportsChatsIfOpen { - updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, gInfo, updatedMember) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + updatedMembers.forEach { updatedMember -> + chatModel.secondaryChatsContext.upsertGroupMember(rhId, gInfo, updatedMember) + } } } onSuccess() 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 e1b0f30423..12c5b65769 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 @@ -15,9 +15,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.res.MR +import kotlinx.coroutines.* private val featureRoles: List> = listOf( null to generalGetString(MR.strings.feature_roles_all_members), @@ -42,12 +43,12 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) if (g != null) { - withChats { - updateGroup(rhId, g) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, g) currentPreferences = preferences } - withChats { - updateGroup(rhId, g) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, g) } } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 3163c109e6..fb24c028b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -17,8 +17,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* @@ -27,8 +25,7 @@ import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.net.URI @Composable @@ -40,8 +37,8 @@ fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl withBGApi { val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) if (gInfo != null) { - withChats { - updateGroup(rhId, gInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, gInfo) } close.invoke() } 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 058ee59a3b..b41d190ffe 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 @@ -72,7 +72,7 @@ private fun ItemsReload(contentTag: MsgContentTag?) { suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report) - ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close -> + ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> 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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 703d74f225..1e99c7f527 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -26,13 +26,10 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.model.GroupInfo -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.chatJsonLength +import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.delay +import kotlinx.coroutines.* private const val maxByteCount = 1200 @@ -51,8 +48,8 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) if (res != null) { gInfo = res - withChats { - updateGroup(rhId, res) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, res) } welcomeText.value = welcome ?: "" } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 7711ee73af..6a57912296 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull +import chat.simplex.common.platform.chatModel import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.chat.group.LocalContentTag @@ -76,7 +77,8 @@ private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List = arrayListOf() val icons: MutableSet = mutableSetOf() - val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed() + val chatsCtx = if (LocalContentTag.current == null) m.chatsContext else m.secondaryChatsContext + val reversedChatItems = chatsCtx.chatItems.value.asReversed() var i = getChatItemIndexOrNull(chatItem, reversedChatItems) if (i != null) { while (i < reversedChatItems.size) { 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 4eb7f56837..d08fa574ef 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 @@ -10,7 +10,6 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* @@ -632,7 +631,8 @@ fun ChatItemView( } @Composable fun EventItemView() { - val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + val chatsCtx = if (LocalContentTag.current == null) chatModel.chatsContext else chatModel.secondaryChatsContext + val reversedChatItems = chatsCtx.chatItems.value.asReversed() CIEventView(eventItemViewText(reversedChatItems)) } @@ -839,13 +839,14 @@ fun DeleteItemAction( buttonText: String = stringResource(MR.strings.delete_verb), ) { val contentTag = LocalContentTag.current + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext ItemAction( buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false if (!revealed.value) { - val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed() + val reversedChatItems = chatsCtx.chatItems.value.asReversed() val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems) val ciCategory = cItem.mergeCategory if (currIndex != null && ciCategory != null) { @@ -1314,7 +1315,7 @@ fun shapeStyle(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVisible: Bo } private fun closeReportsIfNeeded() { - if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) { + if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModals() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index f731db2df9..af9df2cb9a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -44,7 +44,8 @@ fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: In @Composable private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { - val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + val chatsCtx = if (LocalContentTag.current == null) chatModel.chatsContext else chatModel.secondaryChatsContext + val reversedChatItems = chatsCtx.chatItems.value.asReversed() var i = getChatItemIndexOrNull(chatItem, reversedChatItems) val ciCategory = chatItem.mergeCategory val text = if (!revealed.value && ciCategory != null && i != null) { 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 8b4b2bb1d2..fb2349d2b8 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 @@ -20,8 +20,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -233,17 +231,19 @@ suspend fun openChat( ) suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { - withChats(contentTag) { - chatItemStatuses.clear() - chatItems.replaceAll(chat.chatItems) + withContext(Dispatchers.Main) { + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + chatsCtx.chatItemStatuses.clear() + chatsCtx.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id - chatModel.chatStateForContent(contentTag).clear() + chatsCtx.chatState.clear() } } suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) { - withChats(contentTag) { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext + chatsCtx.chatItems.clearAndNotify() } apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) } @@ -604,11 +604,13 @@ fun markChatRead(c: Chat) { var chat = c withApi { if (chat.chatStats.unreadCount > 0) { - withChats { - markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } - withReportsChatsIfOpen { - markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + } } chatModel.controller.apiChatRead( chat.remoteHostId, @@ -625,9 +627,9 @@ fun markChatRead(c: Chat) { false ) if (success) { - withChats { - replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) - markChatTagRead(chat) + withContext(Dispatchers.Main) { + chatModel.chatsContext.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + chatModel.chatsContext.markChatTagRead(chat) } } } @@ -647,9 +649,9 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { true ) if (success) { - withChats { - replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) - updateChatTagReadNoContentTag(chat, wasUnread) + withContext(Dispatchers.Main) { + chatModel.chatsContext.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + chatModel.chatsContext.updateChatTagReadNoContentTag(chat, wasUnread) } } } @@ -690,8 +692,8 @@ fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRe val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId) if (contact != null && isCurrentUser && contactRequest != null) { val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) - withChats { - replaceChat(rhId, contactRequest.id, chat) + withContext(Dispatchers.Main) { + chatModel.chatsContext.replaceChat(rhId, contactRequest.id, chat) } chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) close?.invoke(chat) @@ -702,8 +704,8 @@ fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRe fun rejectContactRequest(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { withBGApi { chatModel.controller.apiRejectContactRequest(rhId, contactRequest.apiId) - withChats { - removeChat(rhId, contactRequest.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, contactRequest.id) } } } @@ -720,8 +722,8 @@ fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnecti withBGApi { AlertManager.shared.hideAlert() if (chatModel.controller.apiDeleteChat(rhId, ChatType.ContactConnection, connection.apiId)) { - withChats { - removeChat(rhId, connection.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, connection.id) } onSuccess() } @@ -741,8 +743,8 @@ fun pendingContactAlertDialog(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatMo withBGApi { val r = chatModel.controller.apiDeleteChat(rhId, chatInfo.chatType, chatInfo.apiId) if (r) { - withChats { - removeChat(rhId, chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, chatInfo.id) } if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null @@ -805,8 +807,8 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactId: Long, incognito: Boolean): Boolean { val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { - withChats { - updateContact(rhId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, contact) } AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), @@ -848,8 +850,8 @@ fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { withBGApi { val r = chatModel.controller.apiDeleteChat(rhId, ChatType.Group, groupInfo.apiId) if (r) { - withChats { - removeChat(rhId, groupInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, groupInfo.id) } if (chatModel.chatId.value == groupInfo.id) { chatModel.chatId.value = null @@ -903,16 +905,16 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch val wasUnread = chat?.unreadTag ?: false val wasFavorite = chatInfo.chatSettings?.favorite ?: false chatModel.updateChatFavorite(favorite = chatSettings.favorite, wasFavorite) - withChats { - updateChatInfo(remoteHostId, newChatInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatInfo(remoteHostId, newChatInfo) } if (chatSettings.enableNtfs == MsgFilter.None) { ntfManager.cancelNotificationsForChat(chatInfo.id) } val updatedChat = chatModel.getChat(chatInfo.id) if (updatedChat != null) { - withChats { - updateChatTagReadNoContentTag(updatedChat, wasUnread) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatTagReadNoContentTag(updatedChat, wasUnread) } } val current = currentState?.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3538d41f01..87c02f038c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -38,8 +37,6 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton -import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -139,7 +136,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow Unit, reorderMode: Boolean) { @@ -417,15 +416,15 @@ private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) { when (val cInfo = chat.chatInfo) { is ChatInfo.Direct -> { val contact = cInfo.contact.copy(chatTags = result.second) - withChats { - updateContact(rhId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, contact) } } is ChatInfo.Group -> { val group = cInfo.groupInfo.copy(chatTags = result.second) - withChats { - updateGroup(rhId, group) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, group) } } @@ -453,14 +452,14 @@ private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState) when (val cInfo = c.chatInfo) { is ChatInfo.Direct -> { val contact = cInfo.contact.copy(chatTags = cInfo.contact.chatTags.filter { it != tagId }) - withChats { - updateContact(rhId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, contact) } } is ChatInfo.Group -> { val group = cInfo.groupInfo.copy(chatTags = cInfo.groupInfo.chatTags.filter { it != tagId }) - withChats { - updateGroup(rhId, group) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, group) } } else -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index da70aef621..6ea7e9fc02 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 4a3e1cda54..a2fcae9d7d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -21,8 +21,6 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -36,6 +34,7 @@ import java.nio.file.StandardCopyOption import java.text.SimpleDateFormat import java.util.* import kotlin.collections.ArrayList +import kotlinx.coroutines.* @Composable fun DatabaseView() { @@ -538,15 +537,17 @@ fun deleteChatDatabaseFilesAndState() { // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null withLongRunningApi { - withChats { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() + chatModel.chatsContext.chats.clear() + chatModel.chatsContext.popChatCollector.clear() } - withReportsChatsIfOpen { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.chatItems.clearAndNotify() + chatModel.secondaryChatsContext.chats.clear() + chatModel.secondaryChatsContext.popChatCollector.clear() + } } } chatModel.users.clear() @@ -785,10 +786,10 @@ private fun afterSetCiTTL( appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) withApi { try { - withChats { + withContext(Dispatchers.Main) { // this is using current remote host on purpose - if it changes during update, it will load correct chats val chats = m.controller.apiGetChats(m.remoteHostId()) - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } } catch (e: Exception) { Log.e(TAG, "apiGetChats error: ${e.message}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 564e96945c..4848e791e1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -85,7 +85,7 @@ class ModalData(val keyboardCoversBar: Boolean = true) { } enum class ModalViewId { - GROUP_REPORTS + SECONDARY_CHAT } class ModalManager(private val placement: ModalPlacement? = null) { 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 2380c64a4c..8205299583 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 @@ -18,7 +18,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers @@ -30,6 +29,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.net.URI @Composable @@ -42,10 +42,10 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c withBGApi { val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) if (groupInfo != null) { - withChats { - updateGroup(rhId = rhId, groupInfo) - chatItems.clearAndNotify() - chatItemStatuses.clear() + 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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 1b5b475b35..6af7ec8134 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -7,13 +7,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* -import java.net.URI enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -359,8 +357,8 @@ suspend fun connectViaUri( val pcc = chatModel.controller.apiConnect(rhId, incognito, uri) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (pcc != null) { - withChats { - updateContactConnection(rhId, pcc) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, pcc) } close?.invoke() AlertManager.privacySensitive.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 1623f8510d..1328523033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -5,7 +5,6 @@ import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -22,11 +21,11 @@ import chat.simplex.common.views.chat.LocalAliasEditor import chat.simplex.common.views.chatlist.deleteContactConnectionAlert import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR +import kotlinx.coroutines.* @Composable fun ContactConnectionInfoView( @@ -185,8 +184,8 @@ fun DeleteButton(onClick: () -> Unit) { private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withBGApi { chatModel.controller.apiSetConnectionAlias(rhId, contactConnection.pccConnId, localAlias)?.let { - withChats { - updateContactConnection(rhId, it) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, it) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 923c0256a8..edc54a8d2e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.topPaddingToContent @@ -39,7 +38,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.* -import java.net.URI enum class NewChatOption { INVITE, CONNECT @@ -315,8 +313,8 @@ fun ActiveProfilePicker( if (contactConnection != null) { updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) if (updatedConn != null) { - withChats { - updateContactConnection(rhId, updatedConn) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, updatedConn) updateShownConnection(updatedConn) } } @@ -338,8 +336,8 @@ fun ActiveProfilePicker( } if (updatedConn != null) { - withChats { - updateContactConnection(user.remoteHostId, updatedConn) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(user.remoteHostId, updatedConn) } } @@ -368,8 +366,8 @@ fun ActiveProfilePicker( appPreferences.incognito.set(true) val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true) if (conn != null) { - withChats { - updateContactConnection(rhId, conn) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, conn) updateShownConnection(conn) } close() @@ -685,8 +683,8 @@ private fun createInvitation( withBGApi { val (r, alert) = controller.apiAddContact(rhId, incognito = controller.appPrefs.incognito.get()) if (r != null) { - withChats { - updateContactConnection(rhId, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, r.second) chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false, conn = r.second) contactConnection.value = r.second } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 5132516669..72fa45b936 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -11,13 +11,13 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.res.MR +import kotlinx.coroutines.* @Composable fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { @@ -34,8 +34,8 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { if (updated != null) { val (updatedProfile, updatedContacts) = updated m.updateCurrentUser(user.remoteHostId, updatedProfile, preferences) - withChats { - updatedContacts.forEach { updateContact(user.remoteHostId, it) } + withContext(Dispatchers.Main) { + updatedContacts.forEach { chatModel.chatsContext.updateContact(user.remoteHostId, it) } } currentPreferences = preferences } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index c411eb0d78..02446ae982 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -30,8 +30,8 @@ import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.localauth.SetAppPasscodeView import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* +import kotlinx.coroutines.* enum class LAMode { SYSTEM, @@ -119,15 +119,15 @@ fun PrivacySettingsView( chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable) if (clearOverrides) { // For loop here is to prevent ConcurrentModificationException that happens with forEach - withChats { - for (i in 0 until chats.size) { - val chat = chats[i] + withContext(Dispatchers.Main) { + for (i in 0 until chatModel.chatsContext.chats.size) { + val chat = chatModel.chatsContext.chats[i] if (chat.chatInfo is ChatInfo.Direct) { var contact = chat.chatInfo.contact val sendRcpts = contact.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) - updateContact(currentUser.remoteHostId, contact) + chatModel.chatsContext.updateContact(currentUser.remoteHostId, contact) } } } @@ -143,16 +143,16 @@ fun PrivacySettingsView( chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable) if (clearOverrides) { - withChats { + withContext(Dispatchers.Main) { // For loop here is to prevent ConcurrentModificationException that happens with forEach - for (i in 0 until chats.size) { - val chat = chats[i] + for (i in 0 until chatModel.chatsContext.chats.size) { + val chat = chatModel.chatsContext.chats[i] if (chat.chatInfo is ChatInfo.Group) { var groupInfo = chat.chatInfo.groupInfo val sendRcpts = groupInfo.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) - updateGroup(currentUser.remoteHostId, groupInfo) + chatModel.chatsContext.updateGroup(currentUser.remoteHostId, groupInfo) } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 9d747206ab..1d0a873c7d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -14,8 +14,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme @@ -58,12 +56,14 @@ fun showApp() { } else { // The last possible cause that can be closed withApi { - withChats { + withContext(Dispatchers.Main) { chatModel.chatId.value = null - chatItems.clearAndNotify() + chatModel.chatsContext.chatItems.clearAndNotify() } - withReportsChatsIfOpen { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { + chatModel.secondaryChatsContext.chatItems.clearAndNotify() + } } } } From 45e395d35a09344c93e6adbcac55283d29b42ab3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 14 Apr 2025 21:25:32 +0100 Subject: [PATCH 174/289] core, ui: short connection links with stored data (#5824) * core, ui: optionally use short links (#5799) * core: optionally use short links * update test * update simplexmq, short group links * fix query * fix parser for _connect * ios: use short links * shorten links to remove fingerprint and onion hosts from known servers * fix parser * tests * nix * update query plans * update simplexmq, simplex: schema for short links * simplexmq * update ios * fix short links in ios * android: use short links * fix short group links, test short link connection plans * core: fix connection plan to recognize own short links * update simplexmq * space * all tests * relative symlinks in simplexmq to fix windows build * core: improve connection plan for short links (#5825) * core: improve connection plan for short links * improve connection plans * update UI * update simplexmq * ios: add preset server domains to entitlements, add short link paths to .well-known/apple-app-site-association * update simplexmq * fix group short link in iOS, fix simplex:/ scheme saved to database or used for connection plans * update simplexmq * ios: delay opening URI from outside until the app is started * update simplexmq --- apps/ios/Shared/ContentView.swift | 10 +- apps/ios/Shared/Model/SimpleXAPI.swift | 89 +++--- apps/ios/Shared/SimpleXApp.swift | 16 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 2 +- .../Views/Chat/Group/GroupLinkView.swift | 18 +- .../ChatList/ContactConnectionInfo.swift | 30 +- .../Shared/Views/NewChat/AddGroupView.swift | 2 +- .../Shared/Views/NewChat/NewChatView.swift | 298 ++++++++++-------- apps/ios/Shared/Views/NewChat/QRCode.swift | 17 +- .../Onboarding/CreateSimpleXAddress.swift | 10 +- .../Views/UserSettings/PrivacySettings.swift | 7 + .../Views/UserSettings/SettingsView.swift | 2 + .../Views/UserSettings/UserAddressView.swift | 41 ++- apps/ios/SimpleX (iOS).entitlements | 4 + apps/ios/SimpleXChat/API.swift | 2 +- apps/ios/SimpleXChat/APITypes.swift | 74 +++-- apps/ios/SimpleXChat/ChatTypes.swift | 4 +- .../android/src/main/AndroidManifest.xml | 25 ++ .../chat/simplex/common/model/ChatModel.kt | 8 +- .../chat/simplex/common/model/SimpleXAPI.kt | 138 +++++--- .../simplex/common/views/chat/ChatView.kt | 6 +- .../views/chat/group/GroupChatInfoView.kt | 6 +- .../common/views/chat/group/GroupLinkView.kt | 25 +- .../views/chatlist/ChatListNavLinkView.kt | 2 +- .../simplex/common/views/helpers/Section.kt | 18 ++ .../common/views/newchat/AddGroupView.kt | 2 +- .../common/views/newchat/ConnectPlan.kt | 109 +++---- .../newchat/ContactConnectionInfoView.kt | 43 +-- .../common/views/newchat/NewChatView.kt | 59 ++-- .../simplex/common/views/newchat/QRCode.kt | 30 +- .../views/usersettings/PrivacySettings.kt | 7 + .../views/usersettings/UserAddressView.kt | 18 +- .../commonMain/resources/MR/base/strings.xml | 6 + .../src/Directory/Service.hs | 25 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 82 +---- src/Simplex/Chat/Bot.hs | 14 +- src/Simplex/Chat/Controller.hs | 37 ++- src/Simplex/Chat/Library/Commands.hs | 254 +++++++++------ src/Simplex/Chat/Library/Internal.hs | 4 +- src/Simplex/Chat/Library/Subscriber.hs | 4 +- src/Simplex/Chat/Markdown.hs | 43 ++- src/Simplex/Chat/Operators.hs | 7 + src/Simplex/Chat/Operators/Presets.hs | 117 +++++++ src/Simplex/Chat/Store/Connections.hs | 24 +- src/Simplex/Chat/Store/Direct.hs | 38 +-- src/Simplex/Chat/Store/Groups.hs | 44 ++- src/Simplex/Chat/Store/Messages.hs | 6 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20250402_short_links.hs | 23 ++ src/Simplex/Chat/Store/Profiles.hs | 63 ++-- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20250402_short_links.hs | 23 ++ .../SQLite/Migrations/agent_query_plans.txt | 77 ++++- .../SQLite/Migrations/chat_query_plans.txt | 81 +++-- .../Store/SQLite/Migrations/chat_schema.sql | 3 + src/Simplex/Chat/Store/Shared.hs | 20 +- src/Simplex/Chat/Terminal.hs | 3 +- src/Simplex/Chat/Types.hs | 18 +- src/Simplex/Chat/View.hs | 74 +++-- tests/ChatClient.hs | 21 +- tests/ChatTests/Direct.hs | 2 + tests/ChatTests/Profiles.hs | 164 ++++++++++ tests/ChatTests/Utils.hs | 30 +- tests/OperatorTests.hs | 1 + tests/ProtocolTests.hs | 22 +- .../apple-app-site-association/index.json | 24 ++ 71 files changed, 1676 insertions(+), 819 deletions(-) create mode 100644 src/Simplex/Chat/Operators/Presets.hs create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250402_short_links.hs diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 305ad0a601..e8b494724a 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -443,12 +443,12 @@ struct ContentView: View { } func connectViaUrl() { - dismissAllSheets() { - let m = ChatModel.shared - if let url = m.appOpenUrl { - m.appOpenUrl = nil + let m = ChatModel.shared + if let url = m.appOpenUrl { + m.appOpenUrl = nil + dismissAllSheets() { var path = url.path - if (path == "/contact" || path == "/invitation") { + if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { path.removeFirst() let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") planAndConnect( diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 495209499c..2de818abb2 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -839,13 +839,14 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo return nil } -func apiAddContact(incognito: Bool) async -> ((String, PendingContactConnection)?, Alert?) { +func apiAddContact(incognito: Bool) async -> ((CreatedConnLink, PendingContactConnection)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiAddContact: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false) - if case let .invitation(_, connReqInvitation, connection) = r { return ((connReqInvitation, connection), nil) } + let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) + let r = await chatSendCmd(.apiAddContact(userId: userId, short: short, incognito: incognito), bgTask: false) + if case let .invitation(_, connLinkInv, connection) = r { return ((connLinkInv, connection), nil) } let alert = connectionErrorAlert(r) return (nil, alert) } @@ -856,23 +857,26 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P throw r } -func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? { +func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection { let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection} throw r } -func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { - let userId = try currentUserId("apiConnectPlan") - let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) - if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } - logger.error("apiConnectPlan error: \(responseError(r))") - throw r +func apiConnectPlan(connLink: String) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiConnectPlan: no current user") + return (nil, nil) + } + let r = await chatSendCmd(.apiConnectPlan(userId: userId, connLink: connLink)) + if case let .connectionPlan(_, connLink, connPlan) = r { return ((connLink, connPlan), nil) } + let alert = apiConnectResponseAlert(r) ?? connectionErrorAlert(r) + return (nil, alert) } -func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? { - let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq) +func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqType, PendingContactConnection)? { + let (r, alert) = await apiConnect_(incognito: incognito, connLink: connLink) if let alert = alert { AlertManager.shared.showAlert(alert) return nil @@ -881,12 +885,12 @@ func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, Pending } } -func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) { +func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqType, PendingContactConnection)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnect: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) + let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink)) let m = ChatModel.shared switch r { case let .sentConfirmation(_, connection): @@ -899,20 +903,31 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi } let alert = contactAlreadyExistsAlert(contact) return (nil, alert) + default: () + } + let alert = apiConnectResponseAlert(r) ?? connectionErrorAlert(r) + return (nil, alert) +} + +private func apiConnectResponseAlert(_ r: ChatResponse) -> Alert? { + switch r { case .chatCmdError(_, .error(.invalidConnReq)): - let alert = mkAlert( + mkAlert( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) - return (nil, alert) + case .chatCmdError(_, .error(.unsupportedConnReq)): + mkAlert( + title: "Unsupported connection link", + message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." + ) case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): - let alert = mkAlert( + mkAlert( title: "Connection error (AUTH)", message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) - return (nil, alert) case let .chatCmdError(_, .errorAgent(.SMP(_, .BLOCKED(info)))): - let alert = Alert( + Alert( title: Text("Connection blocked"), message: Text("Connection is blocked by server operator:\n\(info.reason.text)"), primaryButton: .default(Text("Ok")), @@ -922,25 +937,22 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi } } ) - return (nil, alert) case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))): - let alert = mkAlert( + mkAlert( title: "Undelivered messages", message: "The connection reached the limit of undelivered messages, your contact may be offline." ) - return (nil, alert) case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))): if internalErr == "SEUniqueID" { - let alert = mkAlert( + mkAlert( title: "Already connected?", message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))." ) - return (nil, alert) + } else { + nil } - default: () + default: nil } - let alert = connectionErrorAlert(r) - return (nil, alert) } func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { @@ -1130,10 +1142,10 @@ func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bo } -func apiCreateUserAddress() async throws -> String { +func apiCreateUserAddress(short: Bool) async throws -> CreatedConnLink { let userId = try currentUserId("apiCreateUserAddress") - let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) - if case let .userContactLinkCreated(_, connReq) = r { return connReq } + let r = await chatSendCmd(.apiCreateMyAddress(userId: userId, short: short)) + if case let .userContactLinkCreated(_, connLink) = r { return connLink } throw r } @@ -1642,15 +1654,16 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws throw r } -func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { - let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) - if case let .groupLinkCreated(_, _, connReq, memberRole) = r { return (connReq, memberRole) } +func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { + let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) + let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short)) + if case let .groupLinkCreated(_, _, connLink, memberRole) = r { return (connLink, memberRole) } throw r } -func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { +func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) - if case let .groupLink(_, _, connReq, memberRole) = r { return (connReq, memberRole) } + if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } throw r } @@ -1660,11 +1673,11 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { throw r } -func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? { +func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRole)? { let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, _, connReq, memberRole): - return (connReq, memberRole) + case let .groupLink(_, _, connLink, memberRole): + return (connLink, memberRole) case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): return nil default: throw r diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 10120db185..f8d69c5fc8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -19,6 +19,7 @@ struct SimpleXApp: App { @Environment(\.scenePhase) var scenePhase @State private var enteredBackgroundAuthenticated: TimeInterval? = nil + @State private var appOpenUrlLater: URL? init() { DispatchQueue.global(qos: .background).sync { @@ -42,7 +43,11 @@ struct SimpleXApp: App { .environmentObject(AppTheme.shared) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") - chatModel.appOpenUrl = url + if AppChatState.shared.value == .active { + chatModel.appOpenUrl = url + } else { + appOpenUrlLater = url + } } .onAppear() { // Present screen for continue migration if it wasn't finished yet @@ -93,7 +98,16 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } + if let url = appOpenUrlLater { + await MainActor.run { + appOpenUrlLater = nil + chatModel.appOpenUrl = url + } + } } + } else if let url = appOpenUrlLater { + appOpenUrlLater = nil + chatModel.appOpenUrl = url } } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 8fe4260a1e..8194c8fe6f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import SimpleXChat +@preconcurrency import SimpleXChat func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View { HStack { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 693efcfbb5..eae28b76be 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -45,7 +45,7 @@ struct ChatView: View { @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @State private var showGroupLinkSheet: Bool = false - @State private var groupLink: String? + @State private var groupLink: CreatedConnLink? @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var forwardedChatItems: [ChatItem] = [] @State private var selectedChatItems: Set? = nil diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 56d994b397..9fa07bc391 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -21,7 +21,7 @@ struct GroupChatInfoView: View { @State var localAlias: String @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil - @State private var groupLink: String? + @State private var groupLink: CreatedConnLink? @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkNavLinkActive: Bool = false @State private var addMembersNavLinkActive: Bool = false diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 39288e2d52..a11c073a42 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -10,12 +10,14 @@ import SwiftUI import SimpleXChat struct GroupLinkView: View { + @EnvironmentObject var theme: AppTheme var groupId: Int64 - @Binding var groupLink: String? + @Binding var groupLink: CreatedConnLink? @Binding var groupLinkMemberRole: GroupMemberRole var showTitle: Bool = false var creatingGroup: Bool = false var linkCreatedCb: (() -> Void)? = nil + @State private var showShortLink = true @State private var creatingLink = false @State private var alert: GroupLinkAlert? @State private var shouldCreate = true @@ -69,10 +71,10 @@ struct GroupLinkView: View { } } .frame(height: 36) - SimpleXLinkQRCode(uri: groupLink) - .id("simplex-qrcode-view-for-\(groupLink)") + SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink) + .id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))") Button { - showShareSheet(items: [simplexChatLink(groupLink)]) + showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)]) } label: { Label("Share link", systemImage: "square.and.arrow.up") } @@ -93,6 +95,10 @@ struct GroupLinkView: View { .frame(maxWidth: .infinity) } } + } header: { + if let groupLink, groupLink.connShortLink != nil { + ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink) + } } .alert(item: $alert) { alert in switch alert { @@ -158,8 +164,8 @@ struct GroupLinkView: View { struct GroupLinkView_Previews: PreviewProvider { static var previews: some View { - @State var groupLink: String? = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" - @State var noGroupLink: String? = nil + @State var groupLink: CreatedConnLink? = CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil) + @State var noGroupLink: CreatedConnLink? = nil return Group { GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member)) diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 0f64b632dc..b9f5b984e1 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -14,6 +14,7 @@ struct ContactConnectionInfo: View { @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction @State var contactConnection: PendingContactConnection + @State private var showShortLink: Bool = true @State private var alert: CCInfoAlert? @State private var localAlias = "" @State private var showIncognitoSheet = false @@ -61,14 +62,19 @@ struct ContactConnectionInfo: View { } if contactConnection.initiated, - let connReqInv = contactConnection.connReqInv { - SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) + let connLinkInv = contactConnection.connLinkInv { + SimpleXCreatedLinkQRCode(link: connLinkInv, short: $showShortLink) + .id("simplex-invitation-qrcode-\(connLinkInv.simplexChatUri(short: showShortLink))") incognitoEnabled() - shareLinkButton(connReqInv, theme.colors.secondary) - oneTimeLinkLearnMoreButton(theme.colors.secondary) + shareLinkButton(connLinkInv, short: showShortLink) + oneTimeLinkLearnMoreButton() } else { incognitoEnabled() - oneTimeLinkLearnMoreButton(theme.colors.secondary) + oneTimeLinkLearnMoreButton() + } + } header: { + if let connLinkInv = contactConnection.connLinkInv, connLinkInv.connShortLink != nil { + ToggleShortLinkHeader(text: Text(""), link: connLinkInv, short: $showShortLink) } } footer: { sharedProfileInfo(contactConnection.incognito) @@ -167,26 +173,22 @@ struct ContactConnectionInfo: View { } } -private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View { +private func shareLinkButton(_ connLinkInvitation: CreatedConnLink, short: Bool) -> some View { Button { - showShareSheet(items: [simplexChatLink(connReqInvitation)]) + showShareSheet(items: [connLinkInvitation.simplexChatUri(short: short)]) } label: { - settingsRow("square.and.arrow.up", color: secondaryColor) { - Text("Share 1-time link") - } + Label("Share 1-time link", systemImage: "square.and.arrow.up") } } -private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View { +private func oneTimeLinkLearnMoreButton() -> some View { NavigationLink { AddContactLearnMore(showTitle: false) .navigationTitle("One-time invitation link") .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.large) } label: { - settingsRow("info.circle", color: secondaryColor) { - Text("Learn more") - } + Label("Learn more", systemImage: "info.circle") } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 0fe0f2644d..87c0b80372 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -23,7 +23,7 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false - @State private var groupLink: String? + @State private var groupLink: CreatedConnLink? @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 7a7b91880c..2524b5e682 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -81,7 +81,8 @@ struct NewChatView: View { @State var selection: NewChatOption @State var showQRCodeScanner = false @State private var invitationUsed: Bool = false - @State private var connReqInvitation: String = "" + @State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil) + @State private var showShortLink = true @State private var creatingConnReq = false @State var choosingProfile = false @State private var pastedLink: String = "" @@ -174,11 +175,12 @@ struct NewChatView: View { private func prepareAndInviteView() -> some View { ZStack { // ZStack is needed for views to not make transitions between each other - if connReqInvitation != "" { + if connLinkInvitation.connFullLink != "" { InviteView( invitationUsed: $invitationUsed, contactConnection: $contactConnection, - connReqInvitation: $connReqInvitation, + connLinkInvitation: $connLinkInvitation, + showShortLink: $showShortLink, choosingProfile: $choosingProfile ) } else if creatingConnReq { @@ -190,16 +192,16 @@ struct NewChatView: View { } private func createInvitation() { - if connReqInvitation == "" && contactConnection == nil && !creatingConnReq { + if connLinkInvitation.connFullLink == "" && contactConnection == nil && !creatingConnReq { creatingConnReq = true Task { _ = try? await Task.sleep(nanoseconds: 250_000000) let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get()) - if let (connReq, pcc) = r { + if let (connLink, pcc) = r { await MainActor.run { m.updateContactConnection(pcc) m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false) - connReqInvitation = connReq + connLinkInvitation = connLink contactConnection = pcc } } else { @@ -243,7 +245,8 @@ private struct InviteView: View { @EnvironmentObject var theme: AppTheme @Binding var invitationUsed: Bool @Binding var contactConnection: PendingContactConnection? - @Binding var connReqInvitation: String + @Binding var connLinkInvitation: CreatedConnLink + @Binding var showShortLink: Bool @Binding var choosingProfile: Bool @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @@ -261,7 +264,7 @@ private struct InviteView: View { NavigationLink { ActiveProfilePicker( contactConnection: $contactConnection, - connReqInvitation: $connReqInvitation, + connLinkInvitation: $connLinkInvitation, incognitoEnabled: $incognitoDefault, choosingProfile: $choosingProfile, selectedProfile: selectedProfile @@ -296,7 +299,7 @@ private struct InviteView: View { private func shareLinkView() -> some View { HStack { - let link = simplexChatLink(connReqInvitation) + let link = connLinkInvitation.simplexChatUri(short: showShortLink) linkTextView(link) Button { showShareSheet(items: [link]) @@ -310,9 +313,9 @@ private struct InviteView: View { } private func qrCodeView() -> some View { - Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) { - SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed) - .id("simplex-qrcode-view-for-\(connReqInvitation)") + Section { + SimpleXCreatedLinkQRCode(link: connLinkInvitation, short: $showShortLink, onShare: setInvitationUsed) + .id("simplex-qrcode-view-for-\(connLinkInvitation.simplexChatUri(short: showShortLink))") .padding() .background( RoundedRectangle(cornerRadius: 12, style: .continuous) @@ -322,6 +325,8 @@ private struct InviteView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } header: { + ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) } } @@ -343,7 +348,7 @@ private struct ActiveProfilePicker: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var contactConnection: PendingContactConnection? - @Binding var connReqInvitation: String + @Binding var connLinkInvitation: CreatedConnLink @Binding var incognitoEnabled: Bool @Binding var choosingProfile: Bool @State private var alert: SomeAlert? @@ -415,12 +420,11 @@ private struct ActiveProfilePicker: View { } Task { do { - if let contactConn = contactConnection, - let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) { - + if let contactConn = contactConnection { + let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) await MainActor.run { contactConnection = conn - connReqInvitation = conn.connReqInv ?? "" + connLinkInvitation = conn.connLinkInv ?? CreatedConnLink(connFullLink: "", connShortLink: nil) incognitoEnabled = false chatModel.updateContactConnection(conn) } @@ -836,23 +840,25 @@ func sharedProfileInfo(_ incognito: Bool) -> Text { } enum PlanAndConnectAlert: Identifiable { - case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case invitationLinkConnecting(connectionLink: String) - case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) + case ownInvitationLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case invitationLinkConnecting(connectionLink: CreatedConnLink) + case ownContactAddressConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case contactAddressConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnecting(connectionLink: CreatedConnLink, groupInfo: GroupInfo?) + case error(shortOrFullLink: String, alert: Alert) var id: String { switch self { - case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" - case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" - case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" - case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)" - case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" - case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)" - case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" + case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink.connFullLink)" + case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink.connFullLink)" + case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink.connFullLink)" + case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink.connFullLink)" + case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink.connFullLink)" + case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink.connFullLink)" + case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink.connFullLink)" + case let .error(shortOrFullLink, alert): return "error \(shortOrFullLink)" } } } @@ -935,21 +941,22 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: ( dismissButton: .default(Text("OK")) { cleanup?() } ) } + case let .error(_, alert): return alert } } enum PlanAndConnectActionSheet: Identifiable { - case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) - case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case askCurrentOrIncognitoProfile(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) + case askCurrentOrIncognitoProfileDestructive(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, title: LocalizedStringKey) case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact) - case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) + case ownGroupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) var id: String { switch self { - case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" - case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" + case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink.connFullLink)" + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink.connFullLink)" case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)" - case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" + case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink.connFullLink)" } } } @@ -1008,7 +1015,7 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool } func planAndConnect( - _ connectionLink: String, + _ shortOrFullLink: String, showAlert: @escaping (PlanAndConnectAlert) -> Void, showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, dismiss: Bool, @@ -1018,8 +1025,8 @@ func planAndConnect( filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { Task { - do { - let connectionPlan = try await apiConnectPlan(connReq: connectionLink) + let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink) + if let (connectionLink, connectionPlan) = result { switch connectionPlan { case let .invitationLink(ilp): switch ilp { @@ -1028,32 +1035,40 @@ func planAndConnect( if let incognito = incognito { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + await MainActor.run { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + } } case .ownLink: logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + await MainActor.run { + if let incognito = incognito { + showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + } } case let .connecting(contact_): logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") - if let contact = contact_ { - if let f = filterKnownContact { - f(contact) + await MainActor.run { + if let contact = contact_ { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) } - } else { - showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) } case let .known(contact): logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownContact { - f(contact) - } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } } } case let .contactAddress(cap): @@ -1063,83 +1078,109 @@ func planAndConnect( if let incognito = incognito { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + await MainActor.run { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + } } case .ownLink: logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + await MainActor.run { + if let incognito = incognito { + showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + } } case .connectingConfirmReconnect: logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) + await MainActor.run { + if let incognito = incognito { + showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) + } } case let .connectingProhibit(contact): logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownContact { - f(contact) - } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } } case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownContact { - f(contact) - } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } } case let .contactViaAddress(contact): logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")") if let incognito = incognito { connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } else { - showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact)) + await MainActor.run { + showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact)) + } } } case let .groupLink(glp): switch glp { case .ok: - if let incognito = incognito { - showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + await MainActor.run { + if let incognito = incognito { + showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + } } case let .ownLink(groupInfo): logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownGroup { - f(groupInfo) + await MainActor.run { + if let f = filterKnownGroup { + f(groupInfo) + } + showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) } - showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) case .connectingConfirmReconnect: logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) + await MainActor.run { + if let incognito = incognito { + showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) + } } case let .connectingProhibit(groupInfo_): logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") - showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + await MainActor.run { + showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + } case let .known(groupInfo): logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownGroup { - f(groupInfo) - } else { - openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + await MainActor.run { + if let f = filterKnownGroup { + f(groupInfo) + } else { + openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + } } } + case let .error(chatError): + logger.debug("planAndConnect, .error \(chatErrorString(chatError))") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) + } } - } catch { - logger.debug("planAndConnect, plan error") - if let incognito = incognito { - connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup) - } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) + } else if let alert { + await MainActor.run { + showAlert(.error(shortOrFullLink: shortOrFullLink, alert: alert)) } } } @@ -1161,22 +1202,22 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn } private func connectViaLink( - _ connectionLink: String, + _ connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? ) { Task { - if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) { + if let (connReqType, pcc) = await apiConnect(incognito: incognito, connLink: connectionLink) { await MainActor.run { ChatModel.shared.updateContactConnection(pcc) } let crt: ConnReqType - if let plan = connectionPlan { - crt = planToConnReqType(plan) + crt = if let plan = connectionPlan { + planToConnReqType(plan) ?? connReqType } else { - crt = connReqType + connReqType } DispatchQueue.main.async { if dismiss { @@ -1199,43 +1240,35 @@ private func connectViaLink( } func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { - Task { - let m = ChatModel.shared - if let c = m.getContactChat(contact.contactId) { - DispatchQueue.main.async { - if dismiss { - dismissAllSheets(animated: true) { - ItemsModel.shared.loadOpenChat(c.id) { - showAlreadyExistsAlert?() - } - } - } else { - ItemsModel.shared.loadOpenChat(c.id) { - showAlreadyExistsAlert?() - } + let m = ChatModel.shared + if let c = m.getContactChat(contact.contactId) { + if dismiss { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(c.id) { + showAlreadyExistsAlert?() } } + } else { + ItemsModel.shared.loadOpenChat(c.id) { + showAlreadyExistsAlert?() + } } } } func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { - Task { - let m = ChatModel.shared - if let g = m.getGroupChat(groupInfo.groupId) { - DispatchQueue.main.async { - if dismiss { - dismissAllSheets(animated: true) { - ItemsModel.shared.loadOpenChat(g.id) { - showAlreadyExistsAlert?() - } - } - } else { - ItemsModel.shared.loadOpenChat(g.id) { - showAlreadyExistsAlert?() - } + let m = ChatModel.shared + if let g = m.getGroupChat(groupInfo.groupId) { + if dismiss { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(g.id) { + showAlreadyExistsAlert?() } } + } else { + ItemsModel.shared.loadOpenChat(g.id) { + showAlreadyExistsAlert?() + } } } } @@ -1273,11 +1306,12 @@ enum ConnReqType: Equatable { } } -private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { +private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? { switch connectionPlan { - case .invitationLink: return .invitation - case .contactAddress: return .contact - case .groupLink: return .groupLink + case .invitationLink: .invitation + case .contactAddress: .contact + case .groupLink: .groupLink + case .error: nil } } diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index bc1dc4b5bc..453149198b 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -8,6 +8,7 @@ import SwiftUI import CoreImage.CIFilterBuiltins +import SimpleXChat struct MutableQRCode: View { @Binding var uri: String @@ -20,6 +21,16 @@ struct MutableQRCode: View { } } +struct SimpleXCreatedLinkQRCode: View { + let link: CreatedConnLink + @Binding var short: Bool + var onShare: (() -> Void)? = nil + + var body: some View { + QRCode(uri: link.simplexChatUri(short: short), onShare: onShare) + } +} + struct SimpleXLinkQRCode: View { let uri: String var withLogo: Bool = true @@ -31,12 +42,6 @@ struct SimpleXLinkQRCode: View { } } -func simplexChatLink(_ uri: String) -> String { - uri.starts(with: "simplex:/") - ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") - : uri -} - struct QRCode: View { let uri: String var withLogo: Bool = true diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index befb34b318..a2f5db7f03 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View { Spacer() if let userAddress = m.userAddress { - SimpleXLinkQRCode(uri: userAddress.connReqContact) + SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: Binding.constant(false)) .frame(maxHeight: g.size.width) shareQRCodeButton(userAddress) .frame(maxWidth: .infinity) @@ -77,9 +77,9 @@ struct CreateSimpleXAddress: View { progressIndicator = true Task { do { - let connReqContact = try await apiCreateUserAddress() + let connLinkContact = try await apiCreateUserAddress(short: false) DispatchQueue.main.async { - m.userAddress = UserContactLink(connReqContact: connReqContact) + m.userAddress = UserContactLink(connLinkContact: connLinkContact) } await MainActor.run { progressIndicator = false } } catch let error { @@ -121,7 +121,7 @@ struct CreateSimpleXAddress: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) + showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))]) } label: { Label("Share", systemImage: "square.and.arrow.up") } @@ -189,7 +189,7 @@ struct SendAddressMailView: View { let messageBody = String(format: NSLocalizedString("""

Hi!

Connect to me via SimpleX Chat

- """, comment: "email text"), simplexChatLink(userAddress.connReqContact)) + """, comment: "email text"), simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))) MailView( isShowing: self.$showMailView, result: $mailViewResult, diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 0b9d1ef76c..1a17b9d661 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -20,6 +20,8 @@ struct PrivacySettings: View { @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @AppStorage(DEFAULT_PRIVACY_SHORT_LINKS) private var shortSimplexLinks = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var currentLAMode = privacyLocalAuthModeDefault.get() @@ -111,6 +113,11 @@ struct PrivacySettings: View { .onChange(of: simplexLinkMode) { mode in privacySimplexLinkModeDefault.set(mode) } + if developerTools { + settingsRow("link.badge.plus", color: theme.colors.secondary) { + Toggle("Use short links (BETA)", isOn: $shortSimplexLinks) + } + } } header: { Text("Chats") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 80e2a537da..961cad128f 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -33,6 +33,7 @@ let DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS = "privacyChatListOpenLinks" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" +let DEFAULT_PRIVACY_SHORT_LINKS = "privacyShortLinks" let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen" let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet" let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius" @@ -99,6 +100,7 @@ let appDefaults: [String: Any] = [ DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue, DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true, DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true, + DEFAULT_PRIVACY_SHORT_LINKS: false, DEFAULT_PRIVACY_PROTECT_SCREEN: false, DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false, DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0, diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 7965215b49..4813edf96c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -8,7 +8,7 @@ import SwiftUI import MessageUI -import SimpleXChat +@preconcurrency import SimpleXChat struct UserAddressView: View { @Environment(\.dismiss) var dismiss: DismissAction @@ -16,6 +16,7 @@ struct UserAddressView: View { @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false @State var autoCreate = false + @State private var showShortLink = true @State private var aas = AutoAcceptState() @State private var savedAAS = AutoAcceptState() @State private var showMailView = false @@ -135,8 +136,8 @@ struct UserAddressView: View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { Section { - SimpleXLinkQRCode(uri: userAddress.connReqContact) - .id("simplex-contact-address-qrcode-\(userAddress.connReqContact)") + SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink) + .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))") shareQRCodeButton(userAddress) // if MFMailComposeViewController.canSendMail() { // shareViaEmailButton(userAddress) @@ -153,8 +154,7 @@ struct UserAddressView: View { } addressSettingsButton(userAddress) } header: { - Text("For social media") - .foregroundColor(theme.colors.secondary) + ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) } footer: { if aas.business { Text("Add your team members to the conversations.") @@ -193,9 +193,10 @@ struct UserAddressView: View { progressIndicator = true Task { do { - let connReqContact = try await apiCreateUserAddress() + let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) + let connLinkContact = try await apiCreateUserAddress(short: short) DispatchQueue.main.async { - chatModel.userAddress = UserContactLink(connReqContact: connReqContact) + chatModel.userAddress = UserContactLink(connLinkContact: connLinkContact) alert = .shareOnCreate progressIndicator = false } @@ -231,7 +232,7 @@ struct UserAddressView: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) + showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: showShortLink))]) } label: { settingsRow("square.and.arrow.up", color: theme.colors.secondary) { Text("Share address") @@ -294,6 +295,28 @@ struct UserAddressView: View { } } +struct ToggleShortLinkHeader: View { + @EnvironmentObject var theme: AppTheme + let text: Text + var link: CreatedConnLink + @Binding var short: Bool + + var body: some View { + if link.connShortLink == nil { + text.foregroundColor(theme.colors.secondary) + } else { + HStack { + text.foregroundColor(theme.colors.secondary) + Spacer() + Text(short ? "Full link" : "Short link") + .textCase(.none) + .foregroundColor(theme.colors.primary) + .onTapGesture { short.toggle() } + } + } + } +} + private struct AutoAcceptState: Equatable { var enable = false var incognito = false @@ -542,7 +565,7 @@ private func saveAAS(_ aas: Binding, _ savedAAS: Bindingapplinks:simplex.chat applinks:www.simplex.chat applinks:simplex.chat?mode=developer + applinks:*.simplex.im + applinks:*.simplex.im?mode=developer + applinks:*.simplexonflux.com + applinks:*.simplexonflux.com?mode=developer com.apple.security.application-groups diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index e439cd337b..869dffea31 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -324,7 +324,7 @@ public func responseError(_ err: Error) -> String { } } -func chatErrorString(_ err: ChatError) -> String { +public func chatErrorString(_ err: ChatError) -> String { if case let .invalidJSON(json) = err { return json } return String(describing: err) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 6db0478ab3..a9de0df01b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -77,7 +77,7 @@ public enum ChatCommand { case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) - case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole) + case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole, short: Bool) case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) @@ -116,11 +116,11 @@ public enum ChatCommand { case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) - case apiAddContact(userId: Int64, incognito: Bool) + case apiAddContact(userId: Int64, short: Bool, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiChangeConnectionUser(connId: Int64, userId: Int64) - case apiConnectPlan(userId: Int64, connReq: String) - case apiConnect(userId: Int64, incognito: Bool, connReq: String) + case apiConnectPlan(userId: Int64, connLink: String) + case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink) case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode) case apiClearChat(type: ChatType, id: Int64) @@ -132,7 +132,7 @@ public enum ChatCommand { case apiSetConnectionAlias(connId: Int64, localAlias: String) case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) - case apiCreateMyAddress(userId: Int64) + case apiCreateMyAddress(userId: Int64, short: Bool) case apiDeleteMyAddress(userId: Int64) case apiShowMyAddress(userId: Int64) case apiSetProfileAddress(userId: Int64, on: Bool) @@ -256,7 +256,7 @@ public enum ChatCommand { case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" - case let .apiCreateGroupLink(groupId, memberRole): return "/_create link #\(groupId) \(memberRole)" + case let .apiCreateGroupLink(groupId, memberRole, short): return "/_create link #\(groupId) \(memberRole) short=\(onOff(short))" case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" @@ -305,11 +305,11 @@ public enum ChatCommand { case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" - case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" + case let .apiAddContact(userId, short, incognito): return "/_connect \(userId) short=\(onOff(short)) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" - case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" - case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" + case let .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))" @@ -321,7 +321,7 @@ public enum ChatCommand { case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")" case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")" - case let .apiCreateMyAddress(userId): return "/_address \(userId)" + case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))" case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" case let .apiShowMyAddress(userId): return "/_show_address \(userId)" case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" @@ -629,10 +629,10 @@ public enum ChatResponse: Decodable, Error { case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64]) - case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) + case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) - case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) + case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) @@ -649,7 +649,7 @@ public enum ChatResponse: Decodable, Error { case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) case userContactLink(user: User, contactLink: UserContactLink) case userContactLinkUpdated(user: User, contactLink: UserContactLink) - case userContactLinkCreated(user: User, connReqContact: String) + case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink) case userContactLinkDeleted(user: User) case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case contactConnecting(user: UserRef, contact: Contact) @@ -702,8 +702,8 @@ public enum ChatResponse: Decodable, Error { case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) case groupRemoved(user: UserRef, groupInfo: GroupInfo) // unused case groupUpdated(user: UserRef, toGroup: GroupInfo) - case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) - case groupLink(user: UserRef, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) + case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) + case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) @@ -989,10 +989,10 @@ public enum ChatResponse: Decodable, Error { case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))") - case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)") + case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)") case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) - case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\newUserId: \(String(describing: newUser.userId))") - case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) + case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))") + case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) @@ -1009,7 +1009,7 @@ public enum ChatResponse: Decodable, Error { case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) - case let .userContactLinkCreated(u, connReq): return withUser(u, connReq) + case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) case .userContactLinkDeleted: return noDetails case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) @@ -1069,8 +1069,8 @@ public enum ChatResponse: Decodable, Error { case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .groupLinkCreated(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") - case let .groupLink(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") + case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") + case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") @@ -1173,10 +1173,31 @@ public enum ChatDeleteMode: Codable { } } +public struct CreatedConnLink: Decodable, Hashable { + public var connFullLink: String + public var connShortLink: String? + + public init(connFullLink: String, connShortLink: String?) { + self.connFullLink = connFullLink + self.connShortLink = connShortLink + } + + public func simplexChatUri(short: Bool = true) -> String { + short ? (connShortLink ?? simplexChatLink(connFullLink)) : simplexChatLink(connFullLink) + } +} + +public func simplexChatLink(_ uri: String) -> String { + uri.starts(with: "simplex:/") + ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") + : uri +} + public enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan) case groupLink(groupLinkPlan: GroupLinkPlan) + case error(chatError: ChatError) } public enum InvitationLinkPlan: Decodable, Hashable { @@ -2183,16 +2204,16 @@ public enum RatchetSyncState: String, Decodable { } public struct UserContactLink: Decodable, Hashable { - public var connReqContact: String + public var connLinkContact: CreatedConnLink public var autoAccept: AutoAccept? - public init(connReqContact: String, autoAccept: AutoAccept? = nil) { - self.connReqContact = connReqContact + public init(connLinkContact: CreatedConnLink, autoAccept: AutoAccept? = nil) { + self.connLinkContact = connLinkContact self.autoAccept = autoAccept } var responseDetails: String { - "connReqContact: \(connReqContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" + "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" } } @@ -2404,8 +2425,8 @@ public enum ChatErrorType: Decodable, Hashable { case chatNotStarted case chatNotStopped case chatStoreChanged - case connectionPlan(connectionPlan: ConnectionPlan) case invalidConnReq + case unsupportedConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) case contactNotActive(contact: Contact) @@ -2521,6 +2542,7 @@ public enum StoreError: Decodable, Hashable { case hostMemberIdNotFound(groupId: Int64) case contactNotFoundByFileId(fileId: Int64) case noGroupSndStatus(itemId: Int64, groupMemberId: Int64) + case dBException(message: String) } public enum DatabaseError: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 51feb623e2..0c47442987 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1853,7 +1853,7 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable { public var viaContactUri: Bool public var groupLinkId: String? public var customUserProfileId: Int64? - public var connReqInv: String? + public var connLinkInv: CreatedConnLink? public var localAlias: String var createdAt: Date public var updatedAt: Date @@ -4063,12 +4063,14 @@ public enum SimplexLinkType: String, Decodable, Hashable { case contact case invitation case group + case channel public var description: String { switch self { case .contact: return NSLocalizedString("SimpleX contact address", comment: "simplex link type") case .invitation: return NSLocalizedString("SimpleX one-time invitation", comment: "simplex link type") case .group: return NSLocalizedString("SimpleX group link", comment: "simplex link type") + case .channel: return NSLocalizedString("SimpleX channel link", comment: "simplex link type") } } } diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 48dfba11cc..0470977bcd 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -77,8 +77,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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 59343cd326..7ad26c3726 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 @@ -1078,7 +1078,7 @@ interface ChatItemsChangesListener { data class ShowingInvitation( val connId: String, - val connReq: String, + val connLink: CreatedConnLink, val connChatUsed: Boolean, val conn: PendingContactConnection ) @@ -2198,7 +2198,7 @@ class PendingContactConnection( val viaContactUri: Boolean, val groupLinkId: String? = null, val customUserProfileId: Long? = null, - val connReqInv: String? = null, + val connLinkInv: CreatedConnLink? = null, override val localAlias: String, override val createdAt: Instant, override val updatedAt: Instant @@ -3968,12 +3968,14 @@ sealed class Format { enum class SimplexLinkType(val linkType: String) { contact("contact"), invitation("invitation"), - group("group"); + group("group"), + channel("channel"); val description: String get() = generalGetString(when (this) { contact -> MR.strings.simplex_link_contact invitation -> MR.strings.simplex_link_invitation group -> MR.strings.simplex_link_group + channel -> MR.strings.simplex_link_channel }) } 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 a090919e6d..b5f44fdebc 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 @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -119,6 +121,7 @@ class AppPreferences { ) val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true) val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true) + val privacyShortLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SHORT_LINKS, false) val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false) val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true) val privacyAskToApproveRelays = mkBoolPreference(SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS, true) @@ -378,6 +381,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" + private const val SHARED_PREFS_PRIVACY_SHORT_LINKS = "PrivacyShortLinks" private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles" private const val SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS = "PrivacyAskToApproveRelays" @@ -1364,11 +1368,12 @@ object ChatController { - suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> { + suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> { val userId = try { currentUserId("apiAddContact") } catch (e: Exception) { return null to null } - val r = sendCmd(rh, CC.APIAddContact(userId, incognito)) + val short = appPrefs.privacyShortLinks.get() + val r = sendCmd(rh, CC.APIAddContact(userId, short = short, incognito = incognito)) return when (r) { - is CR.Invitation -> (r.connReqInvitation to r.connection) to null + is CR.Invitation -> (r.connLinkInvitation to r.connection) to null else -> { if (!(networkErrorAlert(r))) { return null to { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) } @@ -1406,34 +1411,45 @@ object ChatController { } } - suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { + suspend fun apiConnectPlan(rh: Long?, connLink: String): Pair? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmd(rh, CC.APIConnectPlan(userId, connReq)) - if (r is CR.CRConnectionPlan) return r.connectionPlan - Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}") + val r = sendCmd(rh, CC.APIConnectPlan(userId, connLink)) + if (r is CR.CRConnectionPlan) return r.connLink to r.connectionPlan + apiConnectResponseAlert(r) return null } - suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? { + suspend fun apiConnect(rh: Long?, incognito: Boolean, connLink: CreatedConnLink): PendingContactConnection? { val userId = try { currentUserId("apiConnect") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) + val r = sendCmd(rh, CC.APIConnect(userId, incognito, connLink)) when { r is CR.SentConfirmation -> return r.connection r is CR.SentInvitation -> return r.connection - r is CR.ContactAlreadyExists -> { + r is CR.ContactAlreadyExists -> AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) ) - return null - } + else -> apiConnectResponseAlert(r) + } + return null + } + + private fun apiConnectResponseAlert(r: CR) { + when { r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.invalid_connection_link), generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one) ) - return null + } + r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat + && r.chatError.errorType is ChatErrorType.UnsupportedConnReq -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.unsupported_connection_link), + generalGetString(MR.strings.link_requires_newer_app_version_please_upgrade) + ) } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -1442,7 +1458,6 @@ object ChatController { generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.connection_error_auth_desc) ) - return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -1451,7 +1466,6 @@ object ChatController { generalGetString(MR.strings.connection_error_blocked), generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text), ) - return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -1460,13 +1474,11 @@ object ChatController { generalGetString(MR.strings.connection_error_quota), generalGetString(MR.strings.connection_error_quota_desc) ) - return null } else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r) } - return null } } } @@ -1619,11 +1631,11 @@ object ChatController { return false } - suspend fun apiCreateUserAddress(rh: Long?): String? { + suspend fun apiCreateUserAddress(rh: Long?, short: Boolean): CreatedConnLink? { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } - val r = sendCmd(rh, CC.ApiCreateMyAddress(userId)) + val r = sendCmd(rh, CC.ApiCreateMyAddress(userId, short)) return when (r) { - is CR.UserContactLinkCreated -> r.connReqContact + is CR.UserContactLinkCreated -> r.connLinkContact else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) @@ -2060,9 +2072,10 @@ object ChatController { } } - suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole))) { - is CR.GroupLinkCreated -> r.connReqContact to r.memberRole + suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + val short = appPrefs.privacyShortLinks.get() + return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole, short))) { + is CR.GroupLinkCreated -> r.connLinkContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) @@ -2072,9 +2085,9 @@ object ChatController { } } - suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { return when (val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole))) { - is CR.GroupLink -> r.connReqContact to r.memberRole + is CR.GroupLink -> r.connLinkContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiGroupLinkMemberRole", generalGetString(MR.strings.error_updating_link_for_group), r) @@ -2096,9 +2109,9 @@ object ChatController { } } - suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { + suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { return when (val r = sendCmd(rh, CC.APIGetGroupLink(groupId))) { - is CR.GroupLink -> r.connReqContact to r.memberRole + is CR.GroupLink -> r.connLinkContact to r.memberRole else -> { Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") null @@ -3466,7 +3479,7 @@ sealed class CC { class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() - class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC() + class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole, val short: Boolean): CC() class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC() class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() @@ -3505,11 +3518,11 @@ sealed class CC { class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() - class APIAddContact(val userId: Long, val incognito: Boolean): CC() + class APIAddContact(val userId: Long, val short: Boolean, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() - class APIConnectPlan(val userId: Long, val connReq: String): CC() - class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() + class APIConnectPlan(val userId: Long, val connLink: String): CC() + class APIConnect(val userId: Long, val incognito: Boolean, val connLink: CreatedConnLink): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() @@ -3521,7 +3534,7 @@ sealed class CC { class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC() class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC() - class ApiCreateMyAddress(val userId: Long): CC() + class ApiCreateMyAddress(val userId: Long, val short: Boolean): CC() class ApiDeleteMyAddress(val userId: Long): CC() class ApiShowMyAddress(val userId: Long): CC() class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC() @@ -3651,7 +3664,7 @@ sealed class CC { is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" - is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()}" + is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()} short=${onOff(short)}" is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" @@ -3690,11 +3703,11 @@ sealed class CC { is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" - is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" + is APIAddContact -> "/_connect $userId short=${onOff(short)} incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" - is APIConnectPlan -> "/_connect plan $userId $connReq" - is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" + 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)}" @@ -3706,7 +3719,7 @@ sealed class CC { is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}" is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}" - is ApiCreateMyAddress -> "/_address $userId" + is ApiCreateMyAddress -> "/_address $userId short=${onOff(short)}" is ApiDeleteMyAddress -> "/_delete_address $userId" is ApiShowMyAddress -> "/_show_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" @@ -5799,10 +5812,10 @@ sealed class CR { @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("tagsUpdated") class TagsUpdated(val user: UserRef, val userTags: List, val chatTags: List): CR() - @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() + @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connLinkInvitation: CreatedConnLink, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() - @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() + @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @@ -5819,7 +5832,7 @@ sealed class CR { @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR() @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val user: User, val contactLink: UserContactLinkRec): CR() - @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connReqContact: String): CR() + @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connLinkContact: CreatedConnLink): CR() @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted(val user: User): CR() @Serializable @SerialName("contactConnected") class ContactConnected(val user: UserRef, val contact: Contact, val userCustomProfile: Profile? = null): CR() @Serializable @SerialName("contactConnecting") class ContactConnecting(val user: UserRef, val contact: Contact): CR() @@ -5876,8 +5889,8 @@ sealed class CR { @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: UserRef, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() - @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR() - @Serializable @SerialName("groupLink") class GroupLink(val user: UserRef, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val connLinkContact: CreatedConnLink, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("groupLink") class GroupLink(val user: UserRef, val groupInfo: GroupInfo, val connLinkContact: CreatedConnLink, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("newMemberContact") class NewMemberContact(val user: UserRef, val contact: Contact, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("newMemberContactSentInv") class NewMemberContactSentInv(val user: UserRef, val contact: Contact, val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -6165,10 +6178,10 @@ sealed class CR { is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is TagsUpdated -> withUser(user, "userTags: ${json.encodeToString(userTags)}\nchatTags: ${json.encodeToString(chatTags)}") - is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") + is Invitation -> withUser(user, "connLinkInvitation: ${json.encodeToString(connLinkInvitation)}\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" ) - is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) + is CRConnectionPlan -> withUser(user, "connLink: ${json.encodeToString(connLink)}\nconnectionPlan: ${json.encodeToString(connectionPlan)}") is SentConfirmation -> withUser(user, json.encodeToString(connection)) is SentInvitation -> withUser(user, json.encodeToString(connection)) is SentInvitationToContact -> withUser(user, json.encodeToString(contact)) @@ -6185,7 +6198,7 @@ sealed class CR { is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") is UserContactLink -> withUser(user, contactLink.responseDetails) is UserContactLinkUpdated -> withUser(user, contactLink.responseDetails) - is UserContactLinkCreated -> withUser(user, connReqContact) + is UserContactLinkCreated -> withUser(user, json.encodeToString(connLinkContact)) is UserContactLinkDeleted -> withUser(user, noDetails()) is ContactConnected -> withUser(user, json.encodeToString(contact)) is ContactConnecting -> withUser(user, json.encodeToString(contact)) @@ -6239,8 +6252,8 @@ sealed class CR { is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) - is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole") - is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole") + is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnLinkContact: $connLinkContact\nmemberRole: $memberRole") + is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnLinkContact: $connLinkContact\nmemberRole: $memberRole") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) is NewMemberContact -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is NewMemberContactSentInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") @@ -6350,11 +6363,34 @@ sealed class ChatDeleteMode { } } +@Serializable +data class CreatedConnLink(val connFullLink: String, val connShortLink: String?) { + fun simplexChatUri(short: Boolean): String = + if (short) connShortLink ?: simplexChatLink(connFullLink) + else simplexChatLink(connFullLink) + + companion object { + val nullableStateSaver: Saver> = Saver( + save = { link -> link?.connFullLink to link?.connShortLink }, + restore = { saved -> + val connFullLink = saved.first + if (connFullLink == null) null + else CreatedConnLink(connFullLink = connFullLink, connShortLink = saved.second) + } + ) + } +} + +fun simplexChatLink(uri: String): String = + if (uri.startsWith("simplex:/")) uri.replace("simplex:/", "https://simplex.chat/") + else uri + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() + @Serializable @SerialName("error") class Error(val chatError: ChatError): ConnectionPlan() } @Serializable @@ -6487,8 +6523,8 @@ enum class RatchetSyncState { } @Serializable -class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) { - val responseDetails: String get() = "connReqContact: ${connReqContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}" +class UserContactLinkRec(val connLinkContact: CreatedConnLink, val autoAccept: AutoAccept? = null) { + val responseDetails: String get() = "connLinkContact: ${connLinkContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}" } @Serializable @@ -6580,6 +6616,7 @@ sealed class ChatErrorType { is ChatStoreChanged -> "chatStoreChanged" is ConnectionPlanChatError -> "connectionPlan" is InvalidConnReq -> "invalidConnReq" + is UnsupportedConnReq -> "unsupportedConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" is ContactNotActive -> "contactNotActive" @@ -6658,6 +6695,7 @@ sealed class ChatErrorType { @Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType() @Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() + @Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType() @@ -6777,6 +6815,7 @@ sealed class StoreError { is ContactNotFoundByFileId -> "contactNotFoundByFileId" is NoGroupSndStatus -> "noGroupSndStatus" is LargeMsg -> "largeMsg" + is DBException -> "dBException" } @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @@ -6837,6 +6876,7 @@ sealed class StoreError { @Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError() @Serializable @SerialName("largeMsg") object LargeMsg: StoreError() + @Serializable @SerialName("dBException") class DBException(val message: String): StoreError() } @Serializable 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 c948437ac3..2a18419746 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 @@ -263,7 +263,7 @@ fun ChatView( // The idea is to preload information before showing a modal because large groups can take time to load all members var preloadedContactInfo: Pair? = null var preloadedCode: String? = null - var preloadedLink: Pair? = null + var preloadedLink: Pair? = null if (chatInfo is ChatInfo.Direct) { preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second @@ -291,7 +291,7 @@ fun ChatView( showSearch.value = true } } else if (chatInfo is ChatInfo.Group) { - var link: Pair? by remember(chatInfo.id) { mutableStateOf(preloadedLink) } + var link: Pair? by remember(chatInfo.id) { mutableStateOf(preloadedLink) } KeyChangeEffect(chatInfo.id) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) @@ -632,7 +632,7 @@ fun ChatView( is ChatInfo.ContactConnection -> { val close = { chatModel.chatId.value = null } ModalView(close, showClose = appPlatform.isAndroid, content = { - ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, false, close) + ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, false, close) }) LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) 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 b80e46eeb6..1551c49b47 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 @@ -53,12 +53,12 @@ val MEMBER_ROW_VERTICAL_PADDING = 8.dp fun ModalData.GroupChatInfoView( rhId: Long?, chatId: String, - groupLink: String?, + groupLink: CreatedConnLink?, groupLinkMemberRole: GroupMemberRole?, selectedItems: MutableState?>, appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, - onGroupLinkUpdated: (Pair?) -> Unit, + onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit ) { @@ -328,7 +328,7 @@ fun ModalData.GroupChatInfoLayout( activeSortedMembers: List, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, - groupLink: String?, + groupLink: CreatedConnLink?, selectedItems: MutableState?>, appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 987a80e7c0..6e1b9a731d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer +import SectionViewWithButton import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -15,11 +16,11 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.shareText +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR @Composable @@ -27,13 +28,13 @@ fun GroupLinkView( chatModel: ChatModel, rhId: Long?, groupInfo: GroupInfo, - connReqContact: String?, + connLinkContact: CreatedConnLink?, memberRole: GroupMemberRole?, - onGroupLinkUpdated: ((Pair?) -> Unit)?, + onGroupLinkUpdated: ((Pair?) -> Unit)?, creatingGroup: Boolean = false, close: (() -> Unit)? = null ) { - var groupLink by rememberSaveable { mutableStateOf(connReqContact) } + var groupLink by rememberSaveable(stateSaver = CreatedConnLink.nullableStateSaver) { mutableStateOf(connLinkContact) } val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } var creatingLink by rememberSaveable { mutableStateOf(false) } fun createLink() { @@ -99,7 +100,7 @@ fun GroupLinkView( @Composable fun GroupLinkLayout( - groupLink: String?, + groupLink: CreatedConnLink?, groupInfo: GroupInfo, groupLinkMemberRole: MutableState, creatingLink: Boolean, @@ -150,7 +151,15 @@ fun GroupLinkLayout( } initialLaunch = false } - SimpleXLinkQRCode(groupLink) + val showShortLink = remember { mutableStateOf(true) } + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + if (groupLink.connShortLink == null) { + SimpleXCreatedLinkQRCode(groupLink, short = false) + } else { + SectionViewWithButton(titleButton = { ToggleShortLinkButton(showShortLink) }) { + SimpleXCreatedLinkQRCode(groupLink, short = showShortLink.value) + } + } Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -160,7 +169,7 @@ fun GroupLinkLayout( SimpleButton( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), - click = { clipboard.shareText(simplexChatLink(groupLink)) } + click = { clipboard.shareText(groupLink.simplexChatUri(short = showShortLink.value)) } ) if (creatingGroup && close != null) { ContinueButton(close) 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 fb2349d2b8..e8350e606a 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 @@ -547,7 +547,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) + ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 37bf5b10b1..0d188bb73c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -53,6 +53,24 @@ fun SectionView( } } +@Composable +fun SectionViewWithButton(title: String? = null, titleButton: (@Composable () -> Unit)?, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, content: (@Composable ColumnScope.() -> Unit)) { + Column { + if (title != null || titleButton != null) { + Row(modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = headerBottomPadding).fillMaxWidth()) { + if (title != null) { + Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp) + } + if (titleButton != null) { + Spacer(modifier = Modifier.weight(1f)) + titleButton() + } + } + } + Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + } +} + @Composable fun SectionViewSelectable( title: String?, 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 8205299583..3d913cf957 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 @@ -57,7 +57,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, rhId, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, connLinkContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 6af7ec8134..330c80b7a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -19,7 +19,7 @@ enum class ConnectionLinkType { suspend fun planAndConnect( rhId: Long?, - uri: String, + shortOrFullLink: String, incognito: Boolean?, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, @@ -27,18 +27,19 @@ suspend fun planAndConnect( filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { val completable = CompletableDeferred() - val close: (() -> Unit)? = { + val close: (() -> Unit) = { close?.invoke() // if close was called, it means the connection was created completable.complete(true) } - val cleanup: (() -> Unit)? = { + val cleanup: (() -> Unit) = { cleanup?.invoke() completable.complete(!completable.isActive) } - val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri) - if (connectionPlan != null) { - val link = strHasSingleSimplexLink(uri.trim()) + val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink) + if (result != null) { + val (connectionLink, connectionPlan) = result + val link = strHasSingleSimplexLink(shortOrFullLink.trim()) val linkText = if (link?.format is Format.SimplexLink) "

${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}" else @@ -48,10 +49,10 @@ suspend fun planAndConnect( InvitationLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_via_invitation_link), text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, @@ -66,7 +67,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, destructive = true, @@ -74,7 +75,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link) + linkText, connectDestructive = true, @@ -95,7 +96,7 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } else { AlertManager.privacySensitive.showAlertMsg( @@ -103,7 +104,7 @@ suspend fun planAndConnect( generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } is InvitationLinkPlan.Known -> { @@ -118,7 +119,7 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } } @@ -126,10 +127,10 @@ suspend fun planAndConnect( ContactAddressPlan.Ok -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_via_contact_link), text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, @@ -144,7 +145,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, destructive = true, onDismiss = cleanup, onDismissRequest = cleanup, @@ -152,7 +153,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText, connectDestructive = true, @@ -167,7 +168,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, destructive = true, @@ -175,7 +176,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText, connectDestructive = true, @@ -195,7 +196,7 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } is ContactAddressPlan.Known -> { @@ -210,19 +211,19 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } is ContactAddressPlan.ContactViaAddress -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact if (incognito != null) { - close?.invoke() + close() connectContactViaAddress(chatModel, rhId, contact.contactId, incognito) } else { askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false) } - cleanup?.invoke() + cleanup() } } is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { @@ -233,14 +234,14 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group) + linkText, confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group) + linkText, connectDestructive = false, @@ -254,7 +255,7 @@ suspend fun planAndConnect( if (filterKnownGroup != null) { filterKnownGroup(groupInfo) } else { - ownGroupLinkConfirmConnect(chatModel, rhId, uri, linkText, incognito, connectionPlan, groupInfo, close, cleanup) + ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, incognito, connectionPlan, groupInfo, close, cleanup) } } GroupLinkPlan.ConnectingConfirmReconnect -> { @@ -264,7 +265,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText, confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, destructive = true, @@ -272,7 +273,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText, connectDestructive = true, @@ -302,7 +303,7 @@ suspend fun planAndConnect( hostDevice = hostDevice(rhId), ) } - cleanup?.invoke() + cleanup() } is GroupLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") @@ -324,22 +325,23 @@ suspend fun planAndConnect( hostDevice = hostDevice(rhId), ) } - cleanup?.invoke() + cleanup() } } } - } - } else { - Log.d(TAG, "planAndConnect, plan error") - if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan = null, close, cleanup) - } else { - askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan = null, close, - title = generalGetString(MR.strings.connect_plan_connect_via_link), - connectDestructive = false, - cleanup = cleanup, - ) + is ConnectionPlan.Error -> { + Log.d(TAG, "planAndConnect, error ${connectionPlan.chatError}") + if (incognito != null) { + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan = null, close, cleanup) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, rhId, connectionLink, connectionPlan = null, close, + title = generalGetString(MR.strings.connect_plan_connect_via_link), + connectDestructive = false, + cleanup = cleanup, + ) + } + } } } return completable @@ -348,14 +350,14 @@ suspend fun planAndConnect( suspend fun connectViaUri( chatModel: ChatModel, rhId: Long?, - uri: String, + connLink: CreatedConnLink, incognito: Boolean, connectionPlan: ConnectionPlan?, close: (() -> Unit)?, cleanup: (() -> Unit)?, ): Boolean { - val pcc = chatModel.controller.apiConnect(rhId, incognito, uri) - val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION + val pcc = chatModel.controller.apiConnect(rhId, incognito, connLink) + val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) ?: ConnectionLinkType.INVITATION else ConnectionLinkType.INVITATION if (pcc != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateContactConnection(rhId, pcc) @@ -376,18 +378,19 @@ suspend fun connectViaUri( return pcc != null } -fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { +fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType? { return when(connectionPlan) { is ConnectionPlan.InvitationLink -> ConnectionLinkType.INVITATION is ConnectionPlan.ContactAddress -> ConnectionLinkType.CONTACT is ConnectionPlan.GroupLink -> ConnectionLinkType.GROUP + is ConnectionPlan.Error -> null } } fun askCurrentOrIncognitoProfileAlert( chatModel: ChatModel, rhId: Long?, - uri: String, + connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, close: (() -> Unit)?, title: String, @@ -404,7 +407,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -412,7 +415,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -443,7 +446,7 @@ fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, co fun ownGroupLinkConfirmConnect( chatModel: ChatModel, rhId: Long?, - uri: String, + connectionLink: CreatedConnLink, linkText: String, incognito: Boolean?, connectionPlan: ConnectionPlan?, @@ -469,7 +472,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }) { Text( @@ -482,7 +485,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -491,7 +494,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 1328523033..0f299b5187 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -4,6 +4,7 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionTextFooter import SectionView +import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -31,14 +32,14 @@ import kotlinx.coroutines.* fun ContactConnectionInfoView( chatModel: ChatModel, rhId: Long?, - connReqInvitation: String?, + connLinkInvitation: CreatedConnLink?, contactConnection: PendingContactConnection, focusAlias: Boolean, close: () -> Unit ) { - LaunchedEffect(connReqInvitation) { - if (connReqInvitation != null) { - chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false, conn = contactConnection) + LaunchedEffect(connLinkInvitation) { + if (connLinkInvitation != null) { + chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connLinkInvitation, false, conn = contactConnection) } } /** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation]. @@ -53,16 +54,16 @@ fun ContactConnectionInfoView( } } } - val clipboard = LocalClipboardManager.current + val showShortLink = remember { mutableStateOf(true) } ContactConnectionInfoLayout( chatModel = chatModel, - connReq = connReqInvitation, + connLink = connLinkInvitation, + showShortLink = showShortLink, contactConnection = contactConnection, focusAlias = focusAlias, rhId = rhId, deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, - share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.end.showModalCloseable { close -> AddContactLearnMore(close) @@ -74,13 +75,13 @@ fun ContactConnectionInfoView( @Composable private fun ContactConnectionInfoLayout( chatModel: ChatModel, - connReq: String?, + connLink: CreatedConnLink?, + showShortLink: MutableState, contactConnection: PendingContactConnection, focusAlias: Boolean, rhId: Long?, deleteConnection: () -> Unit, onLocalAliasChanged: (String) -> Unit, - share: () -> Unit, learnMore: () -> Unit, ) { @Composable fun incognitoEnabled() { @@ -126,13 +127,19 @@ private fun ContactConnectionInfoLayout( LocalAliasEditor(contactConnection.id, contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged) } - SectionView { - if (!connReq.isNullOrEmpty() && contactConnection.initiated) { - SimpleXLinkQRCode(connReq) + if (connLink != null && connLink.connFullLink.isNotEmpty() && contactConnection.initiated) { + Spacer(Modifier.height(DEFAULT_PADDING)) + SectionViewWithButton( + stringResource(MR.strings.one_time_link).uppercase(), + titleButton = if (connLink.connShortLink == null) null else {{ ToggleShortLinkButton(showShortLink) }} + ) { + SimpleXCreatedLinkQRCode(connLink, short = showShortLink.value) incognitoEnabled() - ShareLinkButton(connReq) + ShareLinkButton(connLink.simplexChatUri(short = showShortLink.value)) OneTimeLinkLearnMoreButton(learnMore) - } else { + } + } else { + SectionView { incognitoEnabled() OneTimeLinkLearnMoreButton(learnMore) } @@ -148,14 +155,14 @@ private fun ContactConnectionInfoLayout( } @Composable -fun ShareLinkButton(connReqInvitation: String) { +fun ShareLinkButton(linkUri: String) { val clipboard = LocalClipboardManager.current SettingsActionItem( painterResource(MR.images.ic_share), stringResource(MR.strings.share_invitation_link), click = { chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy(connChatUsed = true) - clipboard.shareText(simplexChatLink(connReqInvitation)) + clipboard.shareText(simplexChatLink(linkUri)) }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, @@ -200,13 +207,13 @@ private fun PreviewContactConnectionInfoView() { SimpleXTheme { ContactConnectionInfoLayout( chatModel = ChatModel, - connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", + connLink = CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null), + showShortLink = remember { mutableStateOf(true) }, contactConnection = PendingContactConnection.getSampleData(), focusAlias = false, rhId = null, deleteConnection = {}, onLocalAliasChanged = {}, - share = {}, learnMore = {} ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index edc54a8d2e..1b3138d21c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -4,6 +4,7 @@ import SectionBottomSpacer import SectionItemView import SectionTextFooter import SectionView +import SectionViewWithButton import TextIconSpaced import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -48,17 +49,17 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC val selection = remember { stateGetOrPut("selection") { selection } } val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) } - val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } } + val connLinkInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connLink ?: CreatedConnLink("", null) } } val creatingConnReq = rememberSaveable { mutableStateOf(false) } val pastedLink = rememberSaveable { mutableStateOf("") } LaunchedEffect(selection.value) { if ( selection.value == NewChatOption.INVITE - && connReqInvitation.isEmpty() + && connLinkInvitation.connFullLink.isEmpty() && contactConnection.value == null && !creatingConnReq.value ) { - createInvitation(rh?.remoteHostId, creatingConnReq, connReqInvitation, contactConnection) + createInvitation(rh?.remoteHostId, creatingConnReq, connLinkInvitation, contactConnection) } } DisposableEffect(Unit) { @@ -143,12 +144,12 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC Modifier .fillMaxWidth() .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), - verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connReqInvitation.isEmpty()) Arrangement.Center else Arrangement.Top + verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top ) { Spacer(Modifier.height(DEFAULT_PADDING)) when (index) { NewChatOption.INVITE.ordinal -> { - PrepareAndInviteView(rh?.remoteHostId, contactConnection, connReqInvitation, creatingConnReq) + PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) } NewChatOption.CONNECT.ordinal -> { ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) @@ -162,17 +163,17 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } @Composable -private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connReqInvitation: String, creatingConnReq: MutableState) { - if (connReqInvitation.isNotEmpty()) { +private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState) { + if (connLinkInvitation.connFullLink.isNotEmpty()) { InviteView( rhId, - connReqInvitation = connReqInvitation, + connLinkInvitation = connLinkInvitation, contactConnection = contactConnection, ) } else if (creatingConnReq.value) { CreatingLinkProgressView() } else { - RetryButton { createInvitation(rhId, creatingConnReq, connReqInvitation, contactConnection) } + RetryButton { createInvitation(rhId, creatingConnReq, connLinkInvitation, contactConnection) } } } @@ -185,7 +186,7 @@ private fun updateShownConnection(conn: PendingContactConnection) { chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy( conn = conn, connId = conn.id, - connReq = conn.connReqInv ?: "", + connLink = conn.connLinkInv ?: CreatedConnLink("", null), connChatUsed = true ) } @@ -449,15 +450,21 @@ fun ActiveProfilePicker( } @Composable -private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState) { - SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { - LinkTextView(connReqInvitation, true) - } - +private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState) { + val showShortLink = remember { mutableStateOf(true) } Spacer(Modifier.height(10.dp)) - SectionView(stringResource(MR.strings.or_show_this_qr_code).uppercase(), headerBottomPadding = 5.dp) { - SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() }) + SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) + } + + Spacer(Modifier.height(DEFAULT_PADDING)) + + SectionViewWithButton( + stringResource(MR.strings.or_show_this_qr_code).uppercase(), + titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) } Spacer(Modifier.height(DEFAULT_PADDING)) @@ -528,6 +535,18 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection } } +@Composable +fun ToggleShortLinkButton(short: MutableState) { + Text( + stringResource(if (short.value) MR.strings.full_link_button_text else MR.strings.short_link_button_text), + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { short.value = !short.value }, + style = MaterialTheme.typography.body2, fontSize = 14.sp, color = MaterialTheme.colors.primary + ) +} + @Composable fun AddContactLearnMoreButton() { IconButton( @@ -675,17 +694,17 @@ private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanu private fun createInvitation( rhId: Long?, creatingConnReq: MutableState, - connReqInvitation: String, + connLinkInvitation: CreatedConnLink, contactConnection: MutableState ) { - if (connReqInvitation.isNotEmpty() || contactConnection.value != null || creatingConnReq.value) return + if (connLinkInvitation.connFullLink.isNotEmpty() || contactConnection.value != null || creatingConnReq.value) return creatingConnReq.value = true withBGApi { val (r, alert) = controller.apiAddContact(rhId, incognito = controller.appPrefs.incognito.get()) if (r != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateContactConnection(rhId, r.second) - chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false, conn = r.second) + chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connLink = r.first, connChatUsed = false, conn = r.second) contactConnection.value = r.second } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index e38c983487..bacb5ab802 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -12,13 +12,33 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import boofcv.alg.drawing.FiducialImageEngine import boofcv.alg.fiducial.qrcode.* -import chat.simplex.common.model.CryptoFile +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch +@Composable +fun SimpleXCreatedLinkQRCode( + connLink: CreatedConnLink, + short: Boolean, + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING * 2f, vertical = DEFAULT_PADDING_HALF), + tintColor: Color = Color(0xff062d56), + withLogo: Boolean = true, + onShare: (() -> Unit)? = null, +) { + QRCode( + connLink.simplexChatUri(short), + modifier, + padding, + tintColor, + withLogo, + onShare, + ) +} + @Composable fun SimpleXLinkQRCode( connReq: String, @@ -38,14 +58,6 @@ fun SimpleXLinkQRCode( ) } -fun simplexChatLink(uri: String): String { - return if (uri.startsWith("simplex:/")) { - uri.replace("simplex:/", "https://simplex.chat/") - } else { - uri - } -} - @Composable fun QRCode( connReq: String, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 02446ae982..24978ecf7c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -88,6 +88,13 @@ fun PrivacySettingsView( simplexLinkMode.set(it) chatModel.simplexLinkMode.value = it }) + if (appPrefs.developerTools.get()) { + SettingsPreferenceItem( + null, + stringResource(MR.strings.privacy_short_links), + chatModel.controller.appPrefs.privacyShortLinks + ) + } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 7bc35bc0de..8c7c2d8416 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -5,6 +5,7 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView +import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -61,7 +62,8 @@ fun UserAddressView( fun createAddress() { withBGApi { progressIndicator = true - val connReqContact = chatModel.controller.apiCreateUserAddress(user?.value?.remoteHostId) + val short = appPreferences.privacyShortLinks.get() + val connReqContact = chatModel.controller.apiCreateUserAddress(user.value?.remoteHostId, short = short) if (connReqContact != null) { chatModel.userAddress.value = UserContactLinkRec(connReqContact) @@ -102,7 +104,7 @@ fun UserAddressView( sendEmail = { userAddress -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(simplexChatLink(userAddress.connReqContact)) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink(userAddress.connLinkContact.connFullLink)) // TODO [short links] replace with short link ) }, setProfileAddress = ::setProfileAddress, @@ -198,10 +200,14 @@ private fun UserAddressLayout( } else { val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) } val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) } + val showShortLink = remember { mutableStateOf(true) } - SectionView(stringResource(MR.strings.for_social_media).uppercase()) { - SimpleXLinkQRCode(userAddress.connReqContact) - ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } + SectionViewWithButton( + stringResource(MR.strings.for_social_media).uppercase(), + titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) + ShareAddressButton { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } // ShareViaEmailButton { sendEmail(userAddress) } BusinessAddressToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAas) @@ -584,7 +590,7 @@ fun PreviewUserAddressLayoutAddressCreated() { SimpleXTheme { UserAddressLayout( user = User.sampleData, - userAddress = UserContactLinkRec("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"), + userAddress = UserContactLinkRec(CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null)), createAddress = {}, share = { _ -> }, deleteAddress = {}, 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 d905ab71ea..90a176658d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -91,12 +91,14 @@ SimpleX contact address SimpleX one-time invitation SimpleX group link + SimpleX channel link via %1$s SimpleX links Description Full link Via browser Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. + Use short links (BETA) Spam @@ -168,6 +170,8 @@ You are already connected to %1$s. Invalid connection link Please check that you used the correct link or ask your contact to send you another one. + Unsupported connection link + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Connection error (AUTH) Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. Connection blocked @@ -795,6 +799,8 @@ 1-time link SimpleX address Or show this code + Full link + Short link Share profile Select chat profile Error switching profile diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index bf998840f6..c5c41e39be 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -61,8 +61,9 @@ import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..)) import Simplex.Messaging.Agent.Store.Common (withTransaction) -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), SConnectionMode (..), sameConnReqContact) +import Simplex.Messaging.Agent.Protocol (SConnectionMode (..), sameConnReqContact, sameShortLinkContact) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) @@ -347,15 +348,15 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName setGroupRegOwner st gr owner let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - CRGroupLinkCreated {connReqContact} -> do + sendChatCmd cc (APICreateGroupLink groupId GRMember False) >>= \case + CRGroupLinkCreated {connLinkContact = CCLink gLink _} -> do setGroupStatus st gr GRSPendingUpdate notifyOwner gr "Created the public link to join the group via this directory service that is always online.\n\n\ \Please add it to the group welcome message.\n\ \For example, add:" - notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> strEncodeTxt (simplexChatContact connReqContact) + notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> strEncodeTxt (simplexChatContact gLink) CRChatCmdError _ (ChatError e) -> case e of CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." @@ -445,13 +446,15 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where profileUpdate = \case - CRGroupLink {connReqContact} -> + CRGroupLink {connLinkContact = CCLink cr sl_} -> let hadLinkBefore = profileHasGroupLink fromGroup hasLinkNow = profileHasGroupLink toGroup profileHasGroupLink GroupInfo {groupProfile = gp} = maybe False (any ftHasLink) $ parseMaybeMarkdownList =<< description gp ftHasLink = \case - FormattedText (Just SimplexLink {simplexUri = ACR SCMContact cr'}) _ -> sameConnReqContact connReqContact cr' + FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of + CLFull cr' -> sameConnReqContact cr' cr + CLShort sl' -> maybe False (sameShortLinkContact sl') sl_ _ -> False in if | hadLinkBefore && hasLinkNow -> GPHasServiceLink @@ -713,7 +716,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName case mRole_ of Nothing -> getGroupLinkRole cc user g >>= \case - Just (_, gLink, mRole) -> do + Just (_, CCLink gLink _, mRole) -> do let anotherRole = case mRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ initialRole n mRole @@ -893,10 +896,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ _ -> sendChatCmd cc (APIGetGroupLink groupId) >>= \case - CRGroupLink {connReqContact, memberRole} -> + CRGroupLink {connLinkContact = CCLink cReq _, memberRole} -> sendReply $ T.unlines [ "The link to join the group " <> groupRef <> ":", - strEncodeTxt $ simplexChatContact connReqContact, + strEncodeTxt $ simplexChatContact cReq, "New member role: " <> strEncodeTxt memberRole ] CRChatCmdError _ (ChatErrorStore (SEGroupLinkNotFound _)) -> @@ -1039,7 +1042,7 @@ vr :: ChatController -> VersionRangeChat vr ChatController {config = ChatConfig {chatVRange}} = chatVRange {-# INLINE vr #-} -getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, ConnReqContact, GroupMemberRole)) +getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, CreatedLinkContact, GroupMemberRole)) getGroupLinkRole cc user gInfo = withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo @@ -1047,7 +1050,7 @@ setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where resp = \case - CRGroupLink _ _ gLink _ -> Just gLink + CRGroupLink _ _ (CCLink gLink _) _ -> Just gLink _ -> Nothing unexpectedError :: Text -> Text diff --git a/cabal.project b/cabal.project index 14cccb4f1f..1bf1289900 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: 9abc0fa88dd70a7e30a041697335bb663c1140b7 + tag: 305f79d2a66a8d122bf457e023988200bb7fe00c source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 326306cf85..e2df71ae0f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9abc0fa88dd70a7e30a041697335bb663c1140b7" = "0gaqqvhb5s9xw5mq2iy8swp7w34zrkwkbjlyhggz2q9nr5680z84"; + "https://github.com/simplex-chat/simplexmq.git"."305f79d2a66a8d122bf457e023988200bb7fe00c" = "1lawc5pf4hgc6wym2xz8gi92izi1vk98ppv3ldrpajz1mq62ifpc"; "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 ae7294a39d..3eb75d5cf6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -55,6 +55,7 @@ library Simplex.Chat.Mobile.WebRTC Simplex.Chat.Operators Simplex.Chat.Operators.Conditions + Simplex.Chat.Operators.Presets Simplex.Chat.Options Simplex.Chat.Options.DB Simplex.Chat.ProfileGenerator @@ -102,6 +103,7 @@ library Simplex.Chat.Options.Postgres Simplex.Chat.Store.Postgres.Migrations Simplex.Chat.Store.Postgres.Migrations.M20241220_initial + Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links else exposed-modules: Simplex.Chat.Archive @@ -231,6 +233,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.M20250402_short_links other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 001e2fde1b..02a765bb19 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -30,6 +30,7 @@ import Data.Time.Clock (getCurrentTime) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Operators +import Simplex.Chat.Operators.Presets import Simplex.Chat.Options import Simplex.Chat.Options.DB import Simplex.Chat.Protocol @@ -39,7 +40,7 @@ import Simplex.Chat.Types import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent as Agent -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -51,34 +52,6 @@ import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E import UnliftIO.STM -operatorSimpleXChat :: NewServerOperator -operatorSimpleXChat = - ServerOperator - { operatorId = DBNewEntity, - operatorTag = Just OTSimplex, - tradeName = "SimpleX Chat", - legalName = Just "SimpleX Chat Ltd", - serverDomains = ["simplex.im"], - conditionsAcceptance = CARequired Nothing, - enabled = True, - smpRoles = allRoles, - xftpRoles = allRoles - } - -operatorFlux :: NewServerOperator -operatorFlux = - ServerOperator - { operatorId = DBNewEntity, - operatorTag = Just OTFlux, - tradeName = "Flux", - legalName = Just "InFlux Technologies Limited", - serverDomains = ["simplexonflux.com"], - conditionsAcceptance = CARequired Nothing, - enabled = False, - smpRoles = ServerRoles {storage = False, proxy = True}, - xftpRoles = ServerRoles {storage = False, proxy = True} - } - defaultChatConfig :: ChatConfig defaultChatConfig = ChatConfig @@ -112,6 +85,10 @@ defaultChatConfig = ntf = _defaultNtfServers, netCfg = defaultNetworkConfig }, + -- please note: if these servers are changed, this option needs to be split to two, + -- to have a different set of servers on the receiving end and on the sending end. + -- To preserve backward compatibility receiving end should update before the sending. + shortLinkPresetServers = allPresetServers, tbqSize = 1024, fileChunkSize = 15780, -- do not change xftpDescrPartSize = 14000, @@ -133,53 +110,6 @@ defaultChatConfig = chatHooks = defaultChatHooks } -simplexChatSMPServers :: [NewUserServer 'PSMP] -simplexChatSMPServers = - map - (presetServer True) - [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", - "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", - "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", - "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", - "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", - "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", - "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", - "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", - "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", - "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", - "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" - ] - <> map - (presetServer False) - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" - ] - -fluxSMPServers :: [NewUserServer 'PSMP] -fluxSMPServers = - map - (presetServer True) - [ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion", - "smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion", - "smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion", - "smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion", - "smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion", - "smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion" - ] - -fluxXFTPServers :: [NewUserServer 'PXFTP] -fluxXFTPServers = - map - (presetServer True) - [ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion", - "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion", - "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion", - "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion", - "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion", - "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion" - ] - logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 54e7baa194..727d7f9ac5 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -15,6 +15,7 @@ import qualified Data.ByteString.Char8 as B import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M +import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Controller @@ -24,6 +25,7 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Store import Simplex.Chat.Types (Contact (..), ContactId, IsContact (..), User (..)) +import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..)) import Simplex.Messaging.Encoding.String (strEncode) import System.Exit (exitFailure) @@ -49,16 +51,18 @@ initializeBotAddress = initializeBotAddress' True initializeBotAddress' :: Bool -> ChatController -> IO () initializeBotAddress' logAddress cc = do sendChatCmd cc ShowMyAddress >>= \case - CRUserContactLink _ UserContactLink {connReqContact} -> showBotAddress connReqContact + CRUserContactLink _ UserContactLink {connLinkContact} -> showBotAddress connLinkContact CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do when logAddress $ putStrLn "No bot address, creating..." - sendChatCmd cc CreateMyAddress >>= \case - CRUserContactLinkCreated _ uri -> showBotAddress uri + -- TODO [short links] create short link by default + sendChatCmd cc (CreateMyAddress False) >>= \case + CRUserContactLinkCreated _ ccLink -> showBotAddress ccLink _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where - showBotAddress uri = do - when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) + showBotAddress (CCLink uri shortUri) = do + when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (maybe (strEncode uri) strEncode shortUri) + when (isJust shortUri) $ putStrLn $ "Full contact address for old clients: " <> B.unpack (strEncode uri) void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {businessAddress = False, acceptIncognito = False, autoReply = Nothing} sendMessage :: ChatController -> Contact -> Text -> IO () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 49ff263f6f..21d5f1041b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -138,6 +138,7 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, + shortLinkPresetServers :: NonEmpty SMPServer, tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -364,7 +365,7 @@ data ChatCommand | APILeaveGroup GroupId | APIListMembers GroupId | APIUpdateGroupProfile GroupId GroupProfile - | APICreateGroupLink GroupId GroupMemberRole + | APICreateGroupLink GroupId GroupMemberRole CreateShortLink | APIGroupLinkMemberRole GroupId GroupMemberRole | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId @@ -437,21 +438,21 @@ data ChatCommand | EnableGroupMember GroupName ContactName | ChatHelp HelpSection | Welcome - | APIAddContact UserId IncognitoEnabled - | AddContact IncognitoEnabled + | APIAddContact UserId CreateShortLink IncognitoEnabled + | AddContact CreateShortLink IncognitoEnabled | APISetConnectionIncognito Int64 IncognitoEnabled | APIChangeConnectionUser Int64 UserId -- new user id to switch connection to - | APIConnectPlan UserId AConnectionRequestUri - | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) - | Connect IncognitoEnabled (Maybe AConnectionRequestUri) + | APIConnectPlan UserId AConnectionLink + | APIConnect UserId IncognitoEnabled (Maybe ACreatedConnLink) + | Connect IncognitoEnabled (Maybe AConnectionLink) | APIConnectContactViaAddress UserId IncognitoEnabled ContactId | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) | DeleteContact ContactName ChatDeleteMode | ClearContact ContactName | APIListContacts UserId | ListContacts - | APICreateMyAddress UserId - | CreateMyAddress + | APICreateMyAddress UserId CreateShortLink + | CreateMyAddress CreateShortLink | APIDeleteMyAddress UserId | DeleteMyAddress | APIShowMyAddress UserId @@ -492,7 +493,7 @@ data ChatCommand | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) | ShowGroupDescription GroupName - | CreateGroupLink GroupName GroupMemberRole + | CreateGroupLink GroupName GroupMemberRole CreateShortLink | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName | ShowGroupLink GroupName @@ -674,10 +675,10 @@ data ChatResponse | CRUserProfileNoChange {user :: User} | CRUserPrivacy {user :: User, updatedUser :: User} | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} - | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} + | CRInvitation {user :: User, connLinkInvitation :: CreatedLinkInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} | CRConnectionUserChanged {user :: User, fromConnection :: PendingContactConnection, toConnection :: PendingContactConnection, newUser :: User} - | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} + | CRConnectionPlan {user :: User, connLink :: ACreatedConnLink, connectionPlan :: ConnectionPlan} | CRSentConfirmation {user :: User, connection :: PendingContactConnection} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} @@ -687,7 +688,7 @@ data ChatResponse | CRContactDeleted {user :: User, contact :: Contact} | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} - | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} + | CRUserContactLinkCreated {user :: User, connLinkContact :: CreatedLinkContact} | CRUserContactLinkDeleted {user :: User} | CRReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} | CRAcceptingContactRequest {user :: User, contact :: Contact} @@ -765,8 +766,8 @@ data ChatResponse | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} | CRGroupDescription {user :: User, groupInfo :: GroupInfo} -- only used in CLI - | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} - | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} + | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} + | CRGroupLink {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI @@ -941,6 +942,7 @@ data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} | CPGroupLink {groupLinkPlan :: GroupLinkPlan} + | CPError {chatError :: ChatError} deriving (Show) data InvitationLinkPlan @@ -984,6 +986,7 @@ connectionPlanProceed = \case GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True _ -> False + CPError _ -> True data ForwardConfirmation = FCFilesNotAccepted {fileIds :: [FileTransferId]} @@ -1247,8 +1250,8 @@ data ChatErrorType | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged - | CEConnectionPlan {connectionPlan :: ConnectionPlan} | CEInvalidConnReq + | CEUnsupportedConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} | CEContactNotReady {contact :: Contact} @@ -1583,8 +1586,6 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) @@ -1599,6 +1600,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) + $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) $(JQ.deriveJSON defaultJSON ''ContactSubStatus) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 54d3cd9143..890a27f573 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1647,16 +1647,18 @@ processChatCommand' vr = \case EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - APIAddContact userId incognito -> withUserId userId $ \user -> procCmd $ do + APIAddContact userId short incognito -> withUserId userId $ \user -> procCmd $ do -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOn subMode + let userData = shortLinkUserData short + (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation userData Nothing IKPQOn subMode + ccLink' <- shortenCreatedLink ccLink -- TODO PQ pass minVersion from the current range - conn <- withFastStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode initialChatVersion PQSupportOn - pure $ CRInvitation user cReq conn - AddContact incognito -> withUser $ \User {userId} -> - processChatCommand $ APIAddContact userId incognito + conn <- withFastStore' $ \db -> createDirectConnection db user connId ccLink' ConnNew incognitoProfile subMode initialChatVersion PQSupportOn + pure $ CRInvitation user ccLink' conn + AddContact short incognito -> withUser $ \User {userId} -> + processChatCommand $ APIAddContact userId short incognito APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do conn'_ <- withFastStore $ \db -> do conn@PendingContactConnection {pccConnStatus, customUserProfileId} <- getPendingContactConnection db userId connId @@ -1674,9 +1676,9 @@ processChatCommand' vr = \case Nothing -> throwChatError CEConnectionIncognitoChangeProhibited APIChangeConnectionUser connId newUserId -> withUser $ \user@User {userId} -> do conn <- withFastStore $ \db -> getPendingContactConnection db userId connId - let PendingContactConnection {pccConnStatus, connReqInv} = conn - case (pccConnStatus, connReqInv) of - (ConnNew, Just cReqInv) -> do + let PendingContactConnection {pccConnStatus, connLinkInv} = conn + 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) pure $ CRConnectionUserChanged user conn conn' newUser @@ -1697,19 +1699,21 @@ processChatCommand' vr = \case forM_ customUserProfileId $ \profileId -> deletePCCIncognitoProfile db user profileId pure conn' - recreateConn user conn@PendingContactConnection {customUserProfileId} newUser = do + recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode - (agConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId newUser) True SCMInvitation Nothing IKPQOn subMode + let userData = shortLinkUserData $ isJust $ connShortLink =<< connLinkInv + (agConnId, ccLink) <- withAgent $ \a -> createConnection a (aUserId newUser) True SCMInvitation userData Nothing IKPQOn subMode + ccLink' <- shortenCreatedLink ccLink conn' <- withFastStore' $ \db -> do deleteConnectionRecord db user connId forM_ customUserProfileId $ \profileId -> deletePCCIncognitoProfile db user profileId - createDirectConnection db newUser agConnId cReq ConnNew Nothing subMode initialChatVersion PQSupportOn + createDirectConnection db newUser agConnId ccLink' ConnNew Nothing subMode initialChatVersion PQSupportOn deleteAgentConnectionAsync user (aConnId' conn) pure conn' - APIConnectPlan userId cReqUri -> withUserId userId $ \user -> - CRConnectionPlan user <$> connectPlan user cReqUri - APIConnect userId incognito (Just (ACR SCMInvitation cReq@(CRInvitationUri crData e2e))) -> withUserId userId $ \user -> withInvitationLock "connect" (strEncode cReq) . procCmd $ do + APIConnectPlan userId cLink -> withUserId userId $ \user -> + uncurry (CRConnectionPlan user) <$> connectPlan user cLink + APIConnect userId incognito (Just (ACCL SCMInvitation (CCLink cReq@(CRInvitationUri crData e2e) sLnk_))) -> withUserId userId $ \user -> withInvitationLock "connect" (strEncode cReq) . procCmd $ do subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -1732,7 +1736,8 @@ processChatCommand' vr = \case where joinNewConn chatV dm = do connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' - pcc <- withFastStore' $ \db -> createDirectConnection db user connId cReq ConnPrepared (incognitoProfile $> profileToSend) subMode chatV pqSup' + let ccLink = CCLink cReq $ serverShortLink <$> sLnk_ + pcc <- withFastStore' $ \db -> createDirectConnection db user connId ccLink ConnPrepared (incognitoProfile $> profileToSend) subMode chatV pqSup' joinPreparedConn connId pcc dm joinPreparedConn connId pcc@PendingContactConnection {pccConnId} dm = do void $ withAgent $ \a -> joinConnection a (aUserId user) connId True cReq dm pqSup' subMode @@ -1742,43 +1747,40 @@ processChatCommand' vr = \case ( CRInvitationUri crData {crScheme = SSSimplex} e2e, CRInvitationUri crData {crScheme = simplexChat} e2e ) - APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq + APIConnect userId incognito (Just (ACCL SCMContact ccLink)) -> withUserId userId $ \user -> connectViaContact user incognito ccLink APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq - Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do - plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) - case plan of - CPContactAddress (CAPContactViaAddress Contact {contactId}) -> - processChatCommand $ APIConnectContactViaAddress userId incognito contactId - _ -> processChatCommand $ APIConnect userId incognito aCReqUri + Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do + (ccLink, plan) <- connectPlan user cLink `catchChatError` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink ILPOk); _ -> throwError e + connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do ct@Contact {activeConn, profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db vr user contactId when (isJust activeConn) $ throwChatError (CECommandError "contact already has connection") - case contactLink of - Just cReq -> connectContactViaAddress user incognito ct cReq + ccLink <- case contactLink of + Just (CLFull cReq) -> pure $ CCLink cReq Nothing + Just (CLShort sLnk) -> do + cReq <- getShortLinkConnReq user sLnk + pure $ CCLink cReq $ Just sLnk Nothing -> throwChatError (CECommandError "no address in contact profile") - ConnectSimplex incognito -> withUser $ \user@User {userId} -> do - let cReqUri = ACR SCMContact adminContactReq - plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) - case plan of - CPContactAddress (CAPContactViaAddress Contact {contactId}) -> - processChatCommand $ APIConnectContactViaAddress userId incognito contactId - _ -> processChatCommand $ APIConnect userId incognito (Just cReqUri) + connectContactViaAddress user incognito ct ccLink + ConnectSimplex incognito -> withUser $ \user -> do + plan <- contactRequestPlan user adminContactReq `catchChatError` const (pure $ CPContactAddress CAPOk) + connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) plan DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) cdm ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> CRContactsList user <$> withFastStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId - APICreateMyAddress userId -> withUserId userId $ \user -> procCmd $ do + APICreateMyAddress userId short -> withUserId userId $ \user -> procCmd $ do subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing IKPQOn subMode - withFastStore $ \db -> createUserContactLink db user connId cReq subMode - pure $ CRUserContactLinkCreated user cReq - CreateMyAddress -> withUser $ \User {userId} -> - processChatCommand $ APICreateMyAddress userId + let userData = shortLinkUserData short + (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact userData Nothing IKPQOn subMode + ccLink' <- shortenCreatedLink ccLink + withFastStore $ \db -> createUserContactLink db user connId ccLink' subMode + pure $ CRUserContactLinkCreated user ccLink' + CreateMyAddress short -> withUser $ \User {userId} -> + processChatCommand $ APICreateMyAddress userId short APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do conns <- withFastStore $ \db -> getUserAddressConnections db vr user withChatLock "deleteMyAddress" $ do @@ -1800,8 +1802,9 @@ processChatCommand' vr = \case let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing APISetProfileAddress userId True -> withUserId userId $ \user@User {profile = p} -> do - ucl@UserContactLink {connReqContact} <- withFastStore (`getUserAddress` user) - let p' = (fromLocalProfile p :: Profile) {contactLink = Just connReqContact} + ucl@UserContactLink {connLinkContact = CCLink cReq _} <- withFastStore (`getUserAddress` user) + -- TODO [short links] replace with short links + let p' = (fromLocalProfile p :: Profile) {contactLink = Just $ CLFull cReq} updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user $ Just ucl SetProfileAddress onOff -> withUser $ \User {userId} -> processChatCommand $ APISetProfileAddress userId onOff @@ -1979,7 +1982,7 @@ processChatCommand' vr = \case Nothing -> do gVar <- asks random subMode <- chatReadVar subscriptionMode - (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode member <- withFastStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -2276,16 +2279,18 @@ processChatCommand' vr = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db vr user gName) - APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do + APICreateGroupLink groupId mRole short -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) IKPQOff subMode - withFastStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode - pure $ CRGroupLinkCreated user gInfo cReq mRole + userData = shortLinkUserData short + (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact userData (Just crClientData) IKPQOff subMode + ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink + withFastStore $ \db -> createGroupLink db user gInfo connId ccLink' groupLinkId mRole subMode + pure $ CRGroupLinkCreated user gInfo ccLink' mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withGroupLock "groupLinkMemberRole" groupId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId (groupLinkId, groupLink, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo @@ -2311,7 +2316,7 @@ processChatCommand' vr = \case when (isJust $ memberContactId m) $ throwChatError $ CECommandError "member contact already exists" subMode <- chatReadVar subscriptionMode -- TODO PQ should negotitate contact connection with PQSupportOn? - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode + (connId, CCLink cReq _) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode -- TODO not sure it is correct to set connections status here? @@ -2332,9 +2337,9 @@ processChatCommand' vr = \case toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] pure $ CRNewMemberContactSentInv user ct' g m _ -> throwChatError CEGroupMemberNotActive - CreateGroupLink gName mRole -> withUser $ \user -> do + CreateGroupLink gName mRole short -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APICreateGroupLink groupId mRole + processChatCommand $ APICreateGroupLink groupId mRole short GroupLinkMemberRole gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIGroupLinkMemberRole groupId mRole @@ -2671,8 +2676,8 @@ processChatCommand' vr = \case CTGroup -> withFastStore $ \db -> getGroupChatItemIdByText' db user cId msg CTLocal -> withFastStore $ \db -> getLocalChatItemIdByText' db user cId msg _ -> throwChatError $ CECommandError "not supported" - connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> CM ChatResponse - connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withInvitationLock "connectViaContact" (strEncode cReq) $ do + connectViaContact :: User -> IncognitoEnabled -> CreatedLinkContact -> CM ChatResponse + connectViaContact user@User {userId} incognito (CCLink cReq@(CRContactUri ConnReqUriData {crClientData}) sLnk) = withInvitationLock "connectViaContact" (strEncode cReq) $ do let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq case groupLinkId of @@ -2702,11 +2707,12 @@ processChatCommand' vr = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - conn@PendingContactConnection {pccConnId} <- withFastStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup + let sLnk' = serverShortLink <$> sLnk + conn@PendingContactConnection {pccConnId} <- withFastStore' $ \db -> createConnReqConnection db userId connId cReqHash sLnk' xContactId incognitoProfile groupLinkId subMode chatV pqSup joinContact user pccConnId connId cReq incognitoProfile xContactId inGroup pqSup chatV pure $ CRSentInvitation user conn incognitoProfile - connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> CM ChatResponse - connectContactViaAddress user incognito ct cReq = + connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse + connectContactViaAddress user incognito ct (CCLink cReq shortLink) = withInvitationLock "connectContactViaAddress" (strEncode cReq) $ do newXContactId <- XContactId <$> drgRandomBytes 16 let pqSup = PQSupportOn @@ -2715,10 +2721,10 @@ processChatCommand' vr = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - (pccConnId, ct') <- withFastStore $ \db -> createAddressContactConnection db vr user ct connId cReqHash newXContactId incognitoProfile subMode chatV pqSup + (pccConnId, ct') <- withFastStore $ \db -> createAddressContactConnection db vr user ct connId cReqHash shortLink newXContactId incognitoProfile subMode chatV pqSup joinContact user pccConnId connId cReq incognitoProfile newXContactId False pqSup chatV pure $ CRSentInvitationToContact user ct' incognitoProfile - prepareContact :: User -> ConnectionRequestUri 'CMContact -> PQSupport -> CM (ConnId, VersionChat) + prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat) prepareContact user cReq pqSup = do -- 0) toggle disabled - PQSupportOff -- 1) toggle enabled, address supports PQ (connRequestPQSupport returns Just True) - PQSupportOn, enable support with compression @@ -2729,7 +2735,7 @@ processChatCommand' vr = \case let chatV = agentToChatVersion agentV connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup pure (connId, chatV) - joinContact :: User -> Int64 -> ConnId -> ConnectionRequestUri 'CMContact -> Maybe Profile -> XContactId -> Bool -> PQSupport -> VersionChat -> CM () + joinContact :: User -> Int64 -> ConnId -> ConnReqContact -> Maybe Profile -> XContactId -> Bool -> PQSupport -> VersionChat -> CM () joinContact user pccConnId connId cReq incognitoProfile xContactId inGroup pqSup chatV = do let profileToSend = userProfileToSend user incognitoProfile Nothing inGroup dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend $ Just xContactId) @@ -3034,32 +3040,77 @@ processChatCommand' vr = \case pure (gId, chatSettings) _ -> throwChatError $ CECommandError "not supported" processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings - connectPlan :: User -> AConnectionRequestUri -> CM ConnectionPlan - connectPlan user (ACR SCMInvitation (CRInvitationUri crData e2e)) = do - withFastStore' (\db -> getConnectionEntityByConnReq db vr user cReqSchemas) >>= \case - Nothing -> pure $ CPInvitationLink ILPOk - Just (RcvDirectMsgConnection Connection {connStatus = ConnPrepared} Nothing) -> - pure $ CPInvitationLink ILPOk - Just (RcvDirectMsgConnection conn ct_) -> do - let Connection {connStatus, contactConnInitiated} = conn - if - | connStatus == ConnNew && contactConnInitiated -> - pure $ CPInvitationLink ILPOwnLink - | not (connReady conn) -> - pure $ CPInvitationLink (ILPConnecting ct_) - | otherwise -> case ct_ of - Just ct -> pure $ CPInvitationLink (ILPKnown ct) - Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" - Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + connectPlan :: User -> AConnectionLink -> CM (ACreatedConnLink, ConnectionPlan) + connectPlan user (ACL SCMInvitation cLink) = case cLink of + CLFull cReq -> invitationReqAndPlan cReq Nothing + CLShort l -> do + let l' = serverShortLink l + withFastStore' (\db -> getConnectionEntityViaShortLink db vr user l') >>= \case + Just (cReq, ent) -> + (ACCL SCMInvitation (CCLink cReq (Just l')),) <$> (invitationEntityPlan ent `catchChatError` (pure . CPError)) + Nothing -> getShortLinkConnReq user l' >>= (`invitationReqAndPlan` Just l') where - cReqSchemas :: (ConnReqInvitation, ConnReqInvitation) - cReqSchemas = + invitationReqAndPlan cReq sLnk_ = do + plan <- inviationRequestPlan user cReq `catchChatError` (pure . CPError) + pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan) + connectPlan user (ACL SCMContact cLink) = case cLink of + CLFull cReq -> contactReqAndPlan cReq Nothing + CLShort l@(CSLContact _ ct _ _) -> do + let l' = serverShortLink l + case ct of + CCTContact -> + withFastStore' (\db -> getUserContactLinkViaShortLink db user l') >>= \case + Just (UserContactLink (CCLink cReq _) _) -> pure (ACCL SCMContact $ CCLink cReq (Just l'), CPContactAddress CAPOwnLink) + Nothing -> getShortLinkConnReq user l' >>= (`contactReqAndPlan` Just l') + CCTGroup -> + withFastStore' (\db -> getGroupInfoViaUserShortLink db vr user l') >>= \case + Just (cReq, g) -> pure (ACCL SCMContact $ CCLink cReq (Just l'), CPGroupLink (GLPOwnLink g)) + Nothing -> getShortLinkConnReq user l' >>= (`contactReqAndPlan` Just l') + CCTChannel -> throwChatError $ CECommandError "channel links are not supported in this version" + where + contactReqAndPlan cReq sLnk_ = do + plan <- contactRequestPlan user cReq `catchChatError` (pure . CPError) + pure (ACCL SCMContact $ CCLink cReq sLnk_, plan) + connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse + connectWithPlan user@User {userId} incognito ccLink plan + | connectionPlanProceed plan = do + case plan of CPError e -> toView $ CRChatError (Just user) e; _ -> pure () + case plan of + CPContactAddress (CAPContactViaAddress Contact {contactId}) -> + processChatCommand $ APIConnectContactViaAddress userId incognito contactId + _ -> processChatCommand $ APIConnect userId incognito (Just ccLink) + | otherwise = pure $ CRConnectionPlan user ccLink plan + inviationRequestPlan :: User -> ConnReqInvitation -> CM ConnectionPlan + inviationRequestPlan user cReq = do + withFastStore' (\db -> getConnectionEntityByConnReq db vr user $ cReqSchemas cReq) >>= \case + Nothing -> pure $ CPInvitationLink ILPOk + Just ent -> invitationEntityPlan ent + where + cReqSchemas :: ConnReqInvitation -> (ConnReqInvitation, ConnReqInvitation) + cReqSchemas (CRInvitationUri crData e2e) = ( CRInvitationUri crData {crScheme = SSSimplex} e2e, CRInvitationUri crData {crScheme = simplexChat} e2e ) - connectPlan user (ACR SCMContact (CRContactUri crData)) = do + invitationEntityPlan :: ConnectionEntity -> CM ConnectionPlan + invitationEntityPlan = \case + RcvDirectMsgConnection Connection {connStatus = ConnPrepared} Nothing -> + pure $ CPInvitationLink ILPOk + RcvDirectMsgConnection conn ct_ -> do + let Connection {connStatus, contactConnInitiated} = conn + if + | connStatus == ConnNew && contactConnInitiated -> + pure $ CPInvitationLink ILPOwnLink + | not (connReady conn) -> + pure $ CPInvitationLink (ILPConnecting ct_) + | otherwise -> case ct_ of + Just ct -> pure $ CPInvitationLink (ILPKnown ct) + Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" + _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + contactRequestPlan :: User -> ConnReqContact -> CM ConnectionPlan + contactRequestPlan user (CRContactUri crData) = do let ConnReqUriData {crClientData} = crData groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + cReqHashes = bimap hash hash cReqSchemas case groupLinkId of -- contact address Nothing -> @@ -3105,9 +3156,31 @@ processChatCommand' vr = \case ( CRContactUri crData {crScheme = SSSimplex}, CRContactUri crData {crScheme = simplexChat} ) - cReqHashes :: (ConnReqUriHash, ConnReqUriHash) - cReqHashes = bimap hash hash cReqSchemas + hash :: ConnReqContact -> ConnReqUriHash hash = ConnReqUriHash . C.sha256Hash . strEncode + getShortLinkConnReq :: User -> ConnShortLink m -> CM (ConnectionRequestUri m) + getShortLinkConnReq User {userId} l = do + l' <- restoreShortLink' l + (cReq, cData) <- withAgent (\a -> getConnShortLink a userId l') + case cData of + ContactLinkData {direct} | not direct -> throwChatError CEUnsupportedConnReq + _ -> pure () + pure cReq + -- This function is needed, as UI uses simplex:/ schema in message view, so that the links can be handled without browser, + -- and short links are stored with server hostname schema, so they wouldn't match without it. + serverShortLink :: ConnShortLink m -> ConnShortLink m + serverShortLink = \case + CSLInvitation _ srv lnkId linkKey -> CSLInvitation SLSServer srv lnkId linkKey + CSLContact _ ct srv linkKey -> CSLContact SLSServer ct srv linkKey + restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) + shortLinkUserData short = if short then Just "" else Nothing + shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) + shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM (\l -> (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config)) sLnk + createdGroupLink :: CreatedLinkContact -> CreatedLinkContact + createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toGroupLink <$> shortLink) + where + toGroupLink :: ShortLinkContact -> ShortLinkContact + toGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId @@ -3574,7 +3647,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do viaUserContactLink, groupLinkId, customUserProfileId, - connReqInv = Nothing, + connLinkInv = Nothing, localAlias, createdAt, updatedAt = createdAt @@ -4042,11 +4115,11 @@ chatCommandP = "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayNameP), - "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), + "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember) <*> shortOnOffP), "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), - "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember)), + "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember) <*> shortP), "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), "/delete link #" *> (DeleteGroupLink <$> displayNameP), "/show link #" *> (ShowGroupLink <$> displayNameP), @@ -4057,12 +4130,12 @@ chatCommandP = "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), - "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), - "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), + "/_connect " *> (APIAddContact <$> A.decimal <*> shortOnOffP <*> incognitoOnOffP), + "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> connLinkP), "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), "/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal), + ("/connect" <|> "/c") *> (AddContact <$> shortP <*> incognitoP), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), - ("/connect" <|> "/c") *> (AddContact <$> incognitoP), ForwardMessage <$> chatNameP <* " <- @" <*> displayNameP <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, @@ -4096,8 +4169,8 @@ chatCommandP = ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/_connect contact " *> (APIConnectContactViaAddress <$> A.decimal <*> incognitoOnOffP <* A.space <*> A.decimal), "/simplex" *> (ConnectSimplex <$> incognitoP), - "/_address " *> (APICreateMyAddress <$> A.decimal), - ("/address" <|> "/ad") $> CreateMyAddress, + "/_address " *> (APICreateMyAddress <$> A.decimal <*> shortOnOffP), + ("/address" <|> "/ad") *> (CreateMyAddress <$> shortP), "/_delete_address " *> (APIDeleteMyAddress <$> A.decimal), ("/delete_address" <|> "/da") $> DeleteMyAddress, "/_show_address " *> (APIShowMyAddress <$> A.decimal), @@ -4167,7 +4240,12 @@ chatCommandP = ] where choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput) + connLinkP = do + ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing) + >>= mapM (\(ACR m cReq) -> ACCL m . CCLink cReq <$> optional (A.space *> strP)) + shortP = (A.space *> ("short" <|> "s")) $> True <|> pure False incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False + shortOnOffP = (A.space *> "short=" *> onOffP) <|> pure False incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P)) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 0d7de70098..dca3a7f678 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2234,7 +2234,7 @@ simplexTeamContactProfile = { displayName = "SimpleX Chat team", fullName = "", image = Just (ImageData "data:image/jpg;base64,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"), - contactLink = Just adminContactReq, + contactLink = Just $ CLFull adminContactReq, preferences = Nothing } @@ -2244,7 +2244,7 @@ simplexStatusContactProfile = { displayName = "SimpleX-Status", fullName = "", image = Just (ImageData "data:image/jpg;base64,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"), - contactLink = Just (either error id $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), + contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), preferences = Nothing } diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f9fcecc5a1..38d67aa150 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1186,8 +1186,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {connReqContact, autoAccept}, gLinkInfo_) = ucl - isSimplexTeam = sameConnReqContact connReqContact adminContactReq + let (UserContactLink {connLinkContact = CCLink connReq _, autoAccept}, gLinkInfo_) = ucl + isSimplexTeam = sameConnReqContact connReq adminContactReq v = maxVersion chatVRange case autoAccept of Just AutoAccept {acceptIncognito, businessAddress} diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 3ed2a8fa0b..e5de9c408c 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -29,11 +29,10 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (..), ConnShortLink (..), ConnectionLink (..), ConnectionRequestUri (..), ContactConnType (..), SMPQueue (..), simplexConnReqUri, simplexShortLink) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) -import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email @@ -49,7 +48,7 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: AConnectionRequestUri, smpHosts :: NonEmpty Text} + | SimplexLink {linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text} | Mention {memberName :: Text} | Email | Phone @@ -62,7 +61,7 @@ mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) Mention name -> Just name _ -> Nothing -data SimplexLinkType = XLContact | XLInvitation | XLGroup +data SimplexLinkType = XLContact | XLInvitation | XLGroup | XLChannel deriving (Eq, Show) colored :: Color -> Format @@ -248,24 +247,34 @@ markdownP = mconcat <$> A.many' fragmentP ')' -> False c -> isPunctuation c uriMarkdown s = case strDecode $ encodeUtf8 s of - Right cReq -> markdown (simplexUriFormat cReq) s + Right cLink -> markdown (simplexUriFormat cLink) s _ -> markdown Uri s isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked - simplexUriFormat :: AConnectionRequestUri -> Format + simplexUriFormat :: AConnectionLink -> Format simplexUriFormat = \case - ACR m (CRContactUri crData) -> - let cReq = ACR m $ CRContactUri crData {crScheme = SSSimplex} - in SimplexLink (linkType' crData) cReq $ uriHosts crData - ACR m (CRInvitationUri crData e2e) -> - let cReq = ACR m $ CRInvitationUri crData {crScheme = SSSimplex} e2e - in SimplexLink XLInvitation cReq $ uriHosts crData - where - uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues - linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of - Just (CRDataGroup _) -> XLGroup - Nothing -> XLContact + ACL m (CLFull cReq) -> case cReq of + CRContactUri crData -> SimplexLink (linkType' crData) cLink $ uriHosts crData + CRInvitationUri crData _ -> SimplexLink XLInvitation cLink $ uriHosts crData + where + cLink = ACL m $ CLFull $ simplexConnReqUri cReq + uriHosts ConnReqUriData {crSmpQueues} = L.map strEncodeText $ sconcat $ L.map (host . qServer) crSmpQueues + linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of + Just (CRDataGroup _) -> XLGroup + Nothing -> XLContact + ACL m (CLShort sLnk) -> case sLnk of + CSLContact _ ct srv _ -> SimplexLink (linkType' ct) cLink $ uriHosts srv + CSLInvitation _ srv _ _ -> SimplexLink XLInvitation cLink $ uriHosts srv + where + cLink = ACL m $ CLShort $ simplexShortLink sLnk + uriHosts srv = L.map strEncodeText $ host srv + linkType' = \case + CCTGroup -> XLGroup + CCTChannel -> XLChannel + CCTContact -> XLContact + strEncodeText :: StrEncoding a => a -> Text + strEncodeText = safeDecodeUtf8 . strEncode markdownText :: FormattedText -> Text markdownText (FormattedText f_ t) = case f_ of diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 5240460c9c..8c4490a2c4 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -275,6 +275,10 @@ data UserServer' s (p :: ProtocolType) = UserServer } deriving (Show) +presetServerAddress :: UserServer' s p -> ProtocolServer p +presetServerAddress UserServer {server = ProtoServerWithAuth srv _} = srv +{-# INLINE presetServerAddress #-} + data PresetOperator = PresetOperator { operator :: Maybe NewServerOperator, smp :: [NewUserServer 'PSMP], @@ -297,6 +301,9 @@ operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of SPSMP -> useSMP SPXFTP -> useXFTP +presetServer' :: Bool -> ProtocolServer p -> NewUserServer p +presetServer' enabled = presetServer enabled . (`ProtoServerWithAuth` Nothing) + presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p presetServer = newUserServer_ True diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs new file mode 100644 index 0000000000..4aa0903d3c --- /dev/null +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -0,0 +1,117 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Operators.Presets where + +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Simplex.Chat.Operators +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) +import Simplex.Messaging.Protocol (ProtocolType (..), SMPServer) + +operatorSimpleXChat :: NewServerOperator +operatorSimpleXChat = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTSimplex, + tradeName = "SimpleX Chat", + legalName = Just "SimpleX Chat Ltd", + serverDomains = ["simplex.im"], + conditionsAcceptance = CARequired Nothing, + enabled = True, + smpRoles = allRoles, + xftpRoles = allRoles + } + +operatorFlux :: NewServerOperator +operatorFlux = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTFlux, + tradeName = "Flux", + legalName = Just "InFlux Technologies Limited", + serverDomains = ["simplexonflux.com"], + conditionsAcceptance = CARequired Nothing, + enabled = False, + smpRoles = ServerRoles {storage = False, proxy = True}, + xftpRoles = ServerRoles {storage = False, proxy = True} + } + +-- Please note: if any servers are removed from the lists below, they MUST be added here. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +allPresetServers :: NonEmpty SMPServer +allPresetServers = enabledSimplexChatSMPServers <> disabledSimplexChatSMPServers <> fluxSMPServers_ + -- TODO [short links] remove, added for testing + <> ["smp://8Af90NX2TTkKEJAF1RCg69P_Odg2Z-6_J6DOKUqK3rQ=@smp7.simplex.im,dbxqutskmmbkbrs7ofi7pmopeyhgi5cxbjbh4ummgmep4r6bz4cbrcid.onion"] + +simplexChatSMPServers :: [NewUserServer 'PSMP] +simplexChatSMPServers = + map (presetServer' True) (L.toList enabledSimplexChatSMPServers) + <> map (presetServer' False) (L.toList disabledSimplexChatSMPServers) + +-- Please note: if any servers are removed from this list, they MUST be added to allPresetServers. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +enabledSimplexChatSMPServers :: NonEmpty SMPServer +enabledSimplexChatSMPServers = + [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", + "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", + "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", + "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", + "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", + "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", + "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", + "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", + "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" + ] + +-- Please note: if any servers are removed from this list, they MUST be added to allPresetServers. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +disabledSimplexChatSMPServers :: NonEmpty SMPServer +disabledSimplexChatSMPServers = + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" + ] + +fluxSMPServers :: [NewUserServer 'PSMP] +fluxSMPServers = map (presetServer' True) $ L.toList fluxSMPServers_ + +-- Please note: if any servers are removed from this list, they MUST be added to allPresetServers. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +fluxSMPServers_ :: NonEmpty SMPServer +fluxSMPServers_ = + [ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion", + "smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion", + "smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion", + "smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion", + "smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion", + "smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion" + ] + +fluxXFTPServers :: [NewUserServer 'PXFTP] +fluxXFTPServers = + map + (presetServer True) + [ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion", + "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion", + "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion", + "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion", + "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion", + "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion" + ] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index d43f7169ea..0a4f5392c0 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -12,6 +13,7 @@ module Simplex.Chat.Store.Connections ( getChatLockEntity, getConnectionEntity, getConnectionEntityByConnReq, + getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, getConnectionsToSubscribe, unsetConnectionToSubscribe, @@ -33,7 +35,7 @@ import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (ConnId) +import Simplex.Messaging.Agent.Protocol (ConnId, ConnShortLink, ConnectionMode (..)) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -204,6 +206,26 @@ getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ +getConnectionEntityViaShortLink :: DB.Connection -> VersionRangeChat -> User -> ConnShortLink 'CMInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity)) +getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do + (cReq, connId) <- ExceptT getConnReqConnId + (cReq,) <$> getConnectionEntity db vr user connId + where + getConnReqConnId = + firstRow' toConnReqConnId (SEInternalError "connection not found") $ + DB.query + db + [sql| + SELECT conn_req_inv, agent_conn_id + FROM connections + WHERE user_id = ? AND short_link_inv = ? LIMIT 1 + |] + (userId, shortLink) + -- cReq is Maybe - it is removed when connection is established + toConnReqConnId = \case + (Just cReq, connId) -> Right (cReq, connId) + _ -> Left $ SEInternalError "no connection request" + -- search connection for connection plan: -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index d473c81758..4de832a8b1 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -100,7 +100,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) +import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), InvitationId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -122,7 +122,7 @@ getPendingContactConnection db userId connId = do DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND connection_id = ? @@ -148,14 +148,14 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createAddressContactConnection :: DB.Connection -> VersionRangeChat -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO (Int64, Contact) -createAddressContactConnection db vr user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode chatV pqSup = do - PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode chatV pqSup +createAddressContactConnection :: DB.Connection -> VersionRangeChat -> User -> Contact -> ConnId -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO (Int64, Contact) +createAddressContactConnection db vr user@User {userId} Contact {contactId} acId cReqHash sLnk xContactId incognitoProfile subMode chatV pqSup = do + PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash sLnk xContactId incognitoProfile Nothing subMode chatV pqSup liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId) (pccConnId,) <$> getContact db vr user contactId -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash sLnk xContactId incognitoProfile groupLinkId subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -164,16 +164,16 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + via_contact_uri_hash, via_short_link_contact, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, pccConnStatus, ConnContact, BI True, cReqHash, xContactId) + ( (userId, acId, pccConnStatus, ConnContact, BI True, cReqHash, sLnk, xContactId) :. (customUserProfileId, BI (isJust groupLinkId), groupLinkId) :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connLinkInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} getConnReqContactXContactId :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId db vr user@User {userId} cReqHash = do @@ -214,8 +214,8 @@ getContactByConnReqHash db vr user@User {userId} cReqHash = do (userId, cReqHash, CSActive) mapM (addDirectChatTags db) ct_ -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode chatV pqSup = do +createDirectConnection :: DB.Connection -> User -> ConnId -> CreatedLinkInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createDirectConnection db User {userId} acId ccLink@(CCLink cReq shortLinkInv) pccConnStatus incognitoProfile subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let contactConnInitiated = pccConnStatus == ConnNew @@ -223,15 +223,15 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + (user_id, agent_conn_id, conn_req_inv, short_link_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, pccConnStatus, ConnContact, BI contactConnInitiated, customUserProfileId) + ( (userId, acId, cReq, shortLinkInv, pccConnStatus, ConnContact, BI contactConnInitiated, customUserProfileId) :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connLinkInv = Just ccLink, localAlias = "", createdAt, updatedAt = createdAt} createIncognitoProfile :: DB.Connection -> User -> Profile -> IO Int64 createIncognitoProfile db User {userId} p = do @@ -904,7 +904,7 @@ getPendingContactConnections db User {userId} = do <$> DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -989,7 +989,7 @@ updateConnectionStatus_ :: DB.Connection -> Int64 -> ConnStatus -> IO () updateConnectionStatus_ db connId connStatus = do currentTs <- getCurrentTime if connStatus == ConnReady - then DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) + then DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL, short_link_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) else DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 0094e20cb8..0c49338a2e 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -39,6 +39,7 @@ module Simplex.Chat.Store.Groups getGroup, getGroupInfo, getGroupInfoByUserContactLinkConnReq, + getGroupInfoViaUserShortLink, getGroupInfoByGroupLinkHash, updateGroupProfile, updateGroupPreferences, @@ -157,14 +158,14 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, firstRow', ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM #if defined(dbPostgres) @@ -175,21 +176,21 @@ 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 ConnLinkContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, 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)) = 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 _ _ = Nothing -createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () -createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole subMode = +createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () +createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole subMode = checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do currentTs <- getCurrentTime DB.execute db - "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, BI True, currentTs, currentTs) + "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" + (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, shortLink, memberRole, BI True, currentTs, currentTs) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff @@ -250,12 +251,12 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) -getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, ConnReqContact, GroupMemberRole) +getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, CreatedLinkContact, GroupMemberRole) getGroupLink db User {userId} gInfo@GroupInfo {groupId} = ExceptT . firstRow groupLink (SEGroupLinkNotFound gInfo) $ - DB.query db "SELECT user_contact_link_id, conn_req_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) + DB.query db "SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) where - groupLink (linkId, cReq, mRole_) = (linkId, cReq, fromMaybe GRMember mRole_) + groupLink (linkId, cReq, shortLink, mRole_) = (linkId, CCLink cReq shortLink, fromMaybe GRMember mRole_) getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId) getGroupLinkId db User {userId} GroupInfo {groupId} = @@ -1683,8 +1684,9 @@ getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do + -- fmap join is to support group_id = NULL if non-group contact request is sent to this function (e.g., if client data is appended). groupId_ <- - maybeFirstRow fromOnly $ + fmap join . maybeFirstRow fromOnly $ DB.query db [sql| @@ -1695,6 +1697,26 @@ getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReq (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ +getGroupInfoViaUserShortLink :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> IO (Maybe (ConnReqContact, GroupInfo)) +getGroupInfoViaUserShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do + (cReq, groupId) <- ExceptT getConnReqGroup + (cReq,) <$> getGroupInfo db vr user groupId + where + getConnReqGroup = + firstRow' toConnReqGroupId (SEInternalError "group link not found") $ + DB.query + db + [sql| + SELECT conn_req_contact, group_id + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? + |] + (userId, shortLink) + toConnReqGroupId = \case + -- cReq is "not null", group_id is nullable + (cReq, Just groupId) -> Right (cReq, groupId) + _ -> Left $ SEInternalError "no conn req or group ID" + getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index ab7d866349..bdbb3fe67d 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -162,7 +162,7 @@ import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Shared -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, ConnShortLink, ConnectionMode (..), MsgMeta (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -966,7 +966,7 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of [sql| SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -982,7 +982,7 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of PTLast count -> DB.query db (query <> " ORDER BY updated_at DESC LIMIT ?") (params search :. Only count) PTAfter ts count -> DB.query db (query <> " AND updated_at > ? ORDER BY updated_at ASC LIMIT ?") (params search :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND updated_at < ? ORDER BY updated_at DESC LIMIT ?") (params search :. (ts, count)) - toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> AChatPreviewData + toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, Maybe (ConnShortLink 'CMInvitation), LocalAlias, UTCTime, UTCTime) -> AChatPreviewData toPreview connRow = let conn@PendingContactConnection {updatedAt} = toPendingContactConnection connRow aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] emptyChatStats diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 285a952279..dc7202edc8 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -5,11 +5,13 @@ module Simplex.Chat.Store.Postgres.Migrations (migrations) where 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.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = - [ ("20241220_initial", m20241220_initial, Nothing) + [ ("20241220_initial", m20241220_initial, Nothing), + ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links) ] -- | 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 new file mode 100644 index 0000000000..de4f699377 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250402_short_links :: Text +m20250402_short_links = + T.pack + [r| +ALTER TABLE user_contact_links ADD COLUMN short_link_contact BYTEA; +ALTER TABLE connections ADD COLUMN short_link_inv BYTEA; +|] + +down_m20250402_short_links :: Text +down_m20250402_short_links = + T.pack + [r| +ALTER TABLE user_contact_links DROP COLUMN short_link_contact; +ALTER TABLE connections DROP COLUMN short_link_inv; +|] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index bdd54c3f1e..a7dc154d9d 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -50,6 +50,7 @@ module Simplex.Chat.Store.Profiles getUserContactLinkById, getGroupLinkInfo, getUserContactLinkByConnReq, + getUserContactLinkViaShortLink, getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, @@ -100,7 +101,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) -import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, ConnectionLink (..), CreatedConnLink (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -326,11 +327,13 @@ setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profil SET contact_link = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? |] - (connReqContact_, ts, userId, profileId) - pure (user :: User) {profile = p {contactLink = connReqContact_}} + (contactLink, ts, userId, profileId) + pure (user :: User) {profile = p {contactLink}} where - connReqContact_ = case ucl_ of - Just UserContactLink {connReqContact} -> Just connReqContact + -- TODO [short links] this should be replaced with short links once they are supported by all clients. + -- Or, maybe, we want to allow both, when both are optional. + contactLink = case ucl_ of + Just UserContactLink {connLinkContact = CCLink cReq _} -> Just $ CLFull cReq _ -> Nothing -- only used in tests @@ -346,17 +349,17 @@ getUserContactProfiles db User {userId} = |] (Only userId) where - toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) -> Profile + toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, Maybe Preferences) -> Profile toContactProfile (displayName, fullName, image, contactLink, preferences) = Profile {displayName, fullName, image, contactLink, preferences} -createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> SubscriptionMode -> ExceptT StoreError IO () -createUserContactLink db User {userId} agentConnId cReq subMode = +createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> ExceptT StoreError IO () +createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute db - "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" - (userId, cReq, currentTs, currentTs) + "INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, created_at, updated_at) VALUES (?,?,?,?,?)" + (userId, cReq, shortLink, currentTs, currentTs) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff @@ -450,7 +453,7 @@ data UserMsgReceiptSettings = UserMsgReceiptSettings deriving (Show) data UserContactLink = UserContactLink - { connReqContact :: ConnReqContact, + { connLinkContact :: CreatedLinkContact, autoAccept :: Maybe AutoAccept } deriving (Show) @@ -472,22 +475,15 @@ $(J.deriveJSON defaultJSON ''AutoAccept) $(J.deriveJSON defaultJSON ''UserContactLink) -toUserContactLink :: (ConnReqContact, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink -toUserContactLink (connReq, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = - UserContactLink connReq $ +toUserContactLink :: (ConnReqContact, Maybe ShortLinkContact, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink +toUserContactLink (connReq, shortLink, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = + UserContactLink (CCLink connReq shortLink) $ if autoAccept then Just AutoAccept {businessAddress, acceptIncognito, autoReply} else Nothing getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink getUserAddress db User {userId} = ExceptT . firstRow toUserContactLink SEUserContactLinkNotFound $ - DB.query - db - [sql| - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL - |] - (Only userId) + DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL") (Only userId) getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupLinkInfo) getUserContactLinkById db userId userContactLinkId = @@ -495,7 +491,7 @@ getUserContactLinkById db userId userContactLinkId = DB.query db [sql| - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT conn_req_contact, short_link_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 = ? |] @@ -521,14 +517,19 @@ getGroupLinkInfo db userId groupId = getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = maybeFirstRow toUserContactLink $ - DB.query - db - [sql| - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND conn_req_contact IN (?,?) - |] - (userId, cReqSchema1, cReqSchema2) + DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND conn_req_contact IN (?,?)") (userId, cReqSchema1, cReqSchema2) + +getUserContactLinkViaShortLink :: DB.Connection -> User -> ShortLinkContact -> IO (Maybe UserContactLink) +getUserContactLinkViaShortLink db User {userId} shortLink = + maybeFirstRow toUserContactLink $ + DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND short_link_contact = ?") (userId, shortLink) + +userContactLinkQuery :: Query +userContactLinkQuery = + [sql| + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + |] getContactWithoutConnViaAddress :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 5865cd180e..81253c5b87 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.M20250402_short_links 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), + ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250402_short_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250402_short_links.hs new file mode 100644 index 0000000000..62637c0782 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250402_short_links.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250402_short_links :: Query +m20250402_short_links = + [sql| +ALTER TABLE user_contact_links ADD COLUMN short_link_contact BLOB; +ALTER TABLE connections ADD COLUMN short_link_inv BLOB; +ALTER TABLE connections ADD COLUMN via_short_link_contact BLOB; + +|] + +down_m20250402_short_links :: Query +down_m20250402_short_links = + [sql| +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/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 31bc2feb81..e6a567035e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -443,6 +443,22 @@ Query: Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: + SELECT link_id, snd_private_key + FROM inv_short_links + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=?) + +Query: + SELECT link_key, snd_private_key, snd_id + FROM inv_short_links + WHERE host = ? AND port = ? AND link_id = ? + +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) + Query: SELECT s.internal_id, m.msg_type, s.internal_hash, s.rcpt_internal_id, s.rcpt_status FROM snd_messages s @@ -466,6 +482,19 @@ Query: Plan: +Query: + INSERT INTO inv_short_links + (host, port, server_key_hash, link_id, link_key, snd_private_key, snd_id) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT (host, port, link_id) + DO UPDATE SET + server_key_hash = EXCLUDED.server_key_hash, + link_key = EXCLUDED.link_key, + snd_private_key = EXCLUDED.snd_private_key, + snd_id = EXCLUDED.snd_id + +Plan: + Query: INSERT INTO messages (conn_id, internal_id, internal_ts, internal_rcv_id, internal_snd_id, msg_type, msg_flags, msg_body, pq_encryption) @@ -524,7 +553,10 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_ Query: INSERT INTO rcv_queues - (host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, snd_id, snd_secure, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + ( host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, + snd_id, queue_mode, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash, + link_id, link_key, link_priv_sig_key, link_enc_fixed_data + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); Plan: @@ -546,14 +578,14 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_ Query: INSERT INTO snd_queues - (host, port, snd_id, snd_secure, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, + (host, port, snd_id, queue_mode, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, status, snd_queue_id, snd_primary, replace_snd_queue_id, smp_client_version, server_key_hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (host, port, snd_id) DO UPDATE SET host=EXCLUDED.host, port=EXCLUDED.port, snd_id=EXCLUDED.snd_id, - snd_secure=EXCLUDED.snd_secure, + queue_mode=EXCLUDED.queue_mode, conn_id=EXCLUDED.conn_id, snd_public_key=EXCLUDED.snd_public_key, snd_private_key=EXCLUDED.snd_private_key, @@ -631,6 +663,14 @@ Query: Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: + UPDATE inv_short_links + SET snd_id = ? + WHERE host = ? AND port = ? AND link_id = ? + +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) + Query: UPDATE ratchets SET x3dh_priv_key_1 = ?, x3dh_priv_key_2 = ?, pq_priv_kem = ? @@ -691,7 +731,7 @@ SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) Query: SELECT - c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.snd_secure, + c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.queue_mode, q.snd_public_key, q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version FROM snd_queues q @@ -705,9 +745,10 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -719,9 +760,10 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -733,9 +775,10 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -747,9 +790,10 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -761,9 +805,10 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -799,6 +844,10 @@ Query: DELETE FROM deleted_snd_chunk_replicas WHERE deleted_snd_chunk_replica_id Plan: SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) +Query: DELETE FROM inv_short_links WHERE host = ? AND port = ? AND link_id = ? +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) + Query: DELETE FROM messages 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 6f1d243cf3..e3eff0f6f1 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -497,6 +497,14 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT conn_req_contact, group_id + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? + +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + Query: SELECT conn_req_contact, group_id FROM user_contact_links @@ -505,6 +513,14 @@ Query: Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT conn_req_inv, agent_conn_id + FROM connections + WHERE user_id = ? AND short_link_inv = ? LIMIT 1 + +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) + 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, @@ -1347,7 +1363,7 @@ SCAN cc Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -1364,7 +1380,7 @@ SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -1381,7 +1397,7 @@ SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -2935,23 +2951,7 @@ Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND conn_req_contact IN (?,?) - -Plan: -SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) - -Query: - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL - -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 + SELECT conn_req_contact, short_link_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 = ? @@ -2970,7 +2970,7 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -2980,7 +2980,7 @@ Plan: SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) Query: - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND connection_id = ? @@ -3944,9 +3944,9 @@ Plan: Query: INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + (user_id, agent_conn_id, conn_req_inv, short_link_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -3962,9 +3962,9 @@ Plan: Query: INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + via_contact_uri_hash, via_short_link_contact, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -4650,6 +4650,27 @@ 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, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND conn_req_contact IN (?,?) +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + +Query: + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) + +Query: + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i @@ -5409,10 +5430,10 @@ SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO temp_conn_ids (conn_id) VALUES (?) Plan: -Query: INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?) +Query: INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, created_at, updated_at) VALUES (?,?,?,?,?) Plan: -Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) +Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?) @@ -5637,7 +5658,7 @@ Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_i Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT user_contact_link_id, conn_req_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 Plan: SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) @@ -5685,7 +5706,7 @@ Query: UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_i Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ? +Query: UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL, short_link_inv = NULL WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index f4cf2bccb8..33b800f4ef 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -297,6 +297,8 @@ CREATE TABLE connections( pq_snd_enabled INTEGER, pq_rcv_enabled INTEGER, quota_err_counter INTEGER NOT NULL DEFAULT 0, + short_link_inv BLOB, + via_short_link_contact BLOB, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -316,6 +318,7 @@ CREATE TABLE user_contact_links( group_link_id BLOB, group_link_member_role TEXT NULL, business_address INTEGER DEFAULT 0, + short_link_contact BLOB, UNIQUE(user_id, local_display_name) ); CREATE TABLE contact_requests( diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index a556fd0a49..c681180759 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} @@ -35,7 +36,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (ConnId, ConnShortLink, ConnectionMode (..), CreatedConnLink (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -416,7 +417,7 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] (userId, profileId, userId, profileId, userId, profileId) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' @@ -441,10 +442,10 @@ getProfileById db userId profileId = |] (userId, profileId) where - toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) -> LocalProfile + toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, contactLink, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, PQSupport, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact) :. (Maybe XContactId, PQSupport, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) toContactRequest :: ContactRequestRow -> UserContactRequest toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, contactId_, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, pqSupport, preferences, createdAt, updatedAt, minVer, maxVer)) = do @@ -462,7 +463,7 @@ userQuery = JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, displayName, fullName, image, contactLink, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt, uiThemes} where @@ -470,9 +471,10 @@ toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, dis fullPreferences = mergePreferences Nothing userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ -toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> PendingContactConnection -toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, localAlias, createdAt, updatedAt) = - PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, localAlias, createdAt, updatedAt} +toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, Maybe (ConnShortLink 'CMInvitation), LocalAlias, UTCTime, UTCTime) -> PendingContactConnection +toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, shortLinkInv, localAlias, createdAt, updatedAt) = + let connLinkInv = (`CCLink` shortLinkInv) <$> connReqInv + in PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connLinkInv, localAlias, createdAt, updatedAt} getConnReqInv :: DB.Connection -> Int64 -> ExceptT StoreError IO ConnReqInvitation getConnReqInv db connId = @@ -579,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) :. (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) +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) 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) = diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 958cd9d75f..e432343839 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -9,12 +9,13 @@ module Simplex.Chat.Terminal where import Control.Monad import qualified Data.List.NonEmpty as L -import Simplex.Chat (defaultChatConfig, operatorSimpleXChat) +import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators +import Simplex.Chat.Operators.Presets (operatorSimpleXChat) import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2bc9e725e5..9d875f5bf4 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -51,7 +51,7 @@ import Simplex.Chat.Types.UITheme import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) -import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnShortLink, ConnectionLink, ConnectionMode (..), ConnectionRequestUri, CreatedConnLink, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) @@ -220,6 +220,8 @@ contactConnId c = aConnId <$> contactConn c type IncognitoEnabled = Bool +type CreateShortLink = Bool + contactConnIncognito :: Contact -> IncognitoEnabled contactConnIncognito = maybe False connIncognito . contactConn @@ -559,7 +561,7 @@ data Profile = Profile { displayName :: ContactName, fullName :: Text, image :: Maybe ImageData, - contactLink :: Maybe ConnReqContact, + contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id @@ -592,7 +594,7 @@ data LocalProfile = LocalProfile displayName :: ContactName, fullName :: Text, image :: Maybe ImageData, - contactLink :: Maybe ConnReqContact, + contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, localAlias :: LocalAlias } @@ -1407,6 +1409,14 @@ type ConnReqInvitation = ConnectionRequestUri 'CMInvitation type ConnReqContact = ConnectionRequestUri 'CMContact +type CreatedLinkInvitation = CreatedConnLink 'CMInvitation + +type CreatedLinkContact = CreatedConnLink 'CMContact + +type ConnLinkContact = ConnectionLink 'CMContact + +type ShortLinkContact = ConnShortLink 'CMContact + data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, @@ -1484,7 +1494,7 @@ data PendingContactConnection = PendingContactConnection viaUserContactLink :: Maybe Int64, groupLinkId :: Maybe GroupLinkId, customUserProfileId :: Maybe Int64, - connReqInv :: Maybe ConnReqInvitation, + connLinkInv :: Maybe CreatedLinkInvitation, localAlias :: Text, createdAt :: UTCTime, updatedAt :: UTCTime diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5053f24ec6..f145262862 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -90,7 +90,7 @@ serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> Time serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] -responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case +responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] @@ -108,7 +108,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserServersValidation {} -> [] CRUsageConditions current _ accepted_ -> viewUsageConditions current accepted_ CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl - CRNetworkConfig cfg -> viewNetworkConfig cfg + CRNetworkConfig netCfg -> viewNetworkConfig netCfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile CRGroupInfo u g s -> ttyUser u $ viewGroupInfo g s CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats @@ -181,7 +181,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe HSDatabase -> databaseHelpInfo CRWelcome user -> chatWelcome user CRContactsList u cs -> ttyUser u $ viewContactsList cs - CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept + CRUserContactLink u UserContactLink {connLinkContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connLinkContact <> autoAcceptStatus_ autoAccept CRUserContactLinkUpdated u UserContactLink {autoAccept} -> ttyUser u $ autoAcceptStatus_ autoAccept CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView @@ -200,10 +200,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info - CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq + CRInvitation u ccLink _ -> ttyUser u $ viewConnReqInvitation ccLink CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c CRConnectionUserChanged u c c' nu -> ttyUser u $ viewConnectionUserChanged u c nu c' - CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan + CRConnectionPlan u _ connectionPlan -> ttyUser u $ viewConnectionPlan cfg connectionPlan CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView @@ -215,7 +215,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] CRContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] CRBusinessRequestAlreadyAccepted u g -> ttyUser u [ttyFullGroup g <> ": sent you a duplicate connection request, but you are already connected, no action needed"] - CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkCreated u ccLink -> ttyUser u $ connReqContact_ "Your new chat address is created!" ccLink CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] @@ -314,8 +314,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupDescription u g -> ttyUser u $ viewGroupDescription g - CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole - CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole + CRGroupLinkCreated u g ccLink mRole -> ttyUser u $ groupLink_ "Group link is created!" g ccLink mRole + CRGroupLink u g ccLink mRole -> ttyUser u $ groupLink_ "Group link:" g ccLink mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] @@ -911,14 +911,17 @@ viewInvalidConnReq = plain updateStr ] -viewConnReqInvitation :: ConnReqInvitation -> [StyledString] -viewConnReqInvitation cReq = +viewConnReqInvitation :: CreatedLinkInvitation -> [StyledString] +viewConnReqInvitation (CCLink cReq shortLink) = [ "pass this invitation link to your contact (via another channel): ", "", - (plain . strEncode) (simplexChatInvitation cReq), + plain $ maybe cReqStr strEncode shortLink, "", "and ask them to connect: " <> highlight' "/c " ] + <> ["The invitation link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatInvitation cReq simplexChatInvitation :: ConnReqInvitation -> ConnReqInvitation simplexChatInvitation (CRInvitationUri crData e2e) = CRInvitationUri crData {crScheme = simplexChat} e2e @@ -973,21 +976,29 @@ viewForwardPlan count itemIds = maybe [forwardCount] $ \fc -> [confirmation fc, | otherwise = plain $ show len <> " message(s) out of " <> show count <> " can be forwarded" len = length itemIds -connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] -connReqContact_ intro cReq = +connReqContact_ :: StyledString -> CreatedLinkContact -> [StyledString] +connReqContact_ intro (CCLink cReq shortLink) = [ intro, "", - (plain . strEncode) (simplexChatContact cReq), + plain $ maybe cReqStr strEncode shortLink, "", "Anybody can send you contact requests with: " <> highlight' "/c ", "to show it again: " <> highlight' "/sa", "to share with your contacts: " <> highlight' "/profile_address on", "to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)" ] + <> ["The contact link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatContact cReq simplexChatContact :: ConnReqContact -> ConnReqContact simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simplexChat} +simplexChatContact' :: ConnLinkContact -> ConnLinkContact +simplexChatContact' = \case + CLFull (CRContactUri crData) -> CLFull $ CRContactUri crData {crScheme = simplexChat} + l@(CLShort _) -> l + autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ = \case Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> @@ -1000,16 +1011,19 @@ autoAcceptStatus_ = \case | otherwise = "" _ -> ["auto_accept off"] -groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString] -groupLink_ intro g cReq mRole = +groupLink_ :: StyledString -> GroupInfo -> CreatedLinkContact -> GroupMemberRole -> [StyledString] +groupLink_ intro g (CCLink cReq shortLink) mRole = [ intro, "", - (plain . strEncode) (simplexChatContact cReq), + plain $ maybe cReqStr strEncode shortLink, "", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c ", "to show it again: " <> highlight ("/show link #" <> viewGroupName g), "to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)" ] + <> ["The group link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatContact cReq viewGroupLinkDeleted :: GroupInfo -> [StyledString] viewGroupLinkDeleted g = @@ -1405,7 +1419,7 @@ viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledS viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> maybe [] viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> maybe ["you've shared main profile with this contact"] (\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p]) @@ -1437,7 +1451,7 @@ viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProf "member ID: " <> sShow groupMemberId ] <> maybe ["member not connected"] viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (memberSecurityCode m) | isJust stats] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn @@ -1663,21 +1677,24 @@ viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserPr | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] viewConnectionUserChanged :: User -> PendingContactConnection -> User -> PendingContactConnection -> [StyledString] -viewConnectionUserChanged User {localDisplayName = n} PendingContactConnection {pccConnId, connReqInv} User {localDisplayName = n'} PendingContactConnection {connReqInv = connReqInv'} = - case (connReqInv, connReqInv') of - (Just cReqInv, Just cReqInv') - | cReqInv /= cReqInv' -> [userChangedStr <> ", new link:"] <> newLink cReqInv' +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' _ -> [userChangedStr] where userChangedStr = "connection " <> sShow pccConnId <> " changed from user " <> plain n <> " to user " <> plain n' - newLink cReqInv = + newLink (CCLink cReq shortLink) = [ "", - (plain . strEncode) (simplexChatInvitation cReqInv), + plain $ maybe cReqStr strEncode shortLink, "" ] + <> ["The invitation link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatInvitation cReq -viewConnectionPlan :: ConnectionPlan -> [StyledString] -viewConnectionPlan = \case +viewConnectionPlan :: ChatConfig -> ConnectionPlan -> [StyledString] +viewConnectionPlan ChatConfig {logLevel, testView} = \case CPInvitationLink ilp -> case ilp of ILPOk -> [invLink "ok to connect"] ILPOwnLink -> [invLink "own link"] @@ -1716,6 +1733,7 @@ viewConnectionPlan = \case grpOrBiz GroupInfo {businessChat} = case businessChat of Just _ -> "business" Nothing -> "group" + CPError e -> viewChatError False logLevel testView e viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated @@ -2138,8 +2156,8 @@ viewChatError isCmd logLevel testView = \case CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] - CEConnectionPlan connectionPlan -> viewConnectionPlan connectionPlan CEInvalidConnReq -> viewInvalidConnReq + CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr] CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> [ plain $ ("chat message error: " <> e <> " (" <> T.unpack (T.take 120 msg) <> ")") diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index f16bc84cfd..2d0bde5058 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -186,6 +186,7 @@ testCfg = defaultChatConfig { agentConfig = testAgentCfg, showReceipts = False, + shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16 } @@ -290,8 +291,8 @@ startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {maintenance} user ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts False void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc - chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts - atomically . unless maintenance $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry + chatAsync <- async $ runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts + unless maintenance $ atomically $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput} @@ -393,14 +394,14 @@ withTmpFiles = testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> TestParams -> IO () testChatN cfg opts ps test params = - bracket (getTestCCs (zip ps [1 ..]) []) entTests test + bracket (getTestCCs $ zip ps [1 ..]) endTests test where - getTestCCs :: [(Profile, Int)] -> [TestCC] -> IO [TestCC] - getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' tcs - entTests tcs = do - concurrentlyN_ $ map ( IO [TestCC] + getTestCCs [] = pure [] + getTestCCs ((p, db) : envs') = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' + endTests tcs = do + mapConcurrently_ ( TestCC -> Int -> Expectation ( ?" (Only msgIdBob) :: IO [[Int]] bobItemsCount `shouldBe` [[300]] + threadDelay 1000000 + testGetSetSMPServers :: HasCallStack => TestParams -> IO () testGetSetSMPServers = testChat aliceProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 72d502392e..433615e62a 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -101,6 +101,11 @@ chatProfileTests = do it "files & media" testGroupPrefsFilesForRole it "SimpleX links" testGroupPrefsSimplexLinksForRole it "set user, contact and group UI theme" testSetUITheme + describe "short links" $ do + it "should connect via one-time inviation" testShortLinkInvitation + it "should plan and connect via one-time inviation" testPlanShortLinkInvitation + it "should connect via contact address" testShortLinkContactAddress + it "should join group" testShortLinkJoinGroup testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = @@ -2583,3 +2588,162 @@ testSetUITheme = groupInfo a = do a <## "group ID: 1" a <## "current members: 1" + +testShortLinkInvitation :: HasCallStack => TestParams -> IO () +testShortLinkInvitation = + testChat2 aliceProfile bobProfile $ \alice bob -> do + alice ##> "/c short" + inv <- getShortInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + alice #> "@bob hi" + bob <# "alice> hi" + bob #> "@alice hey" + alice <# "bob> hey" + +testPlanShortLinkInvitation :: HasCallStack => TestParams -> IO () +testPlanShortLinkInvitation = + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + alice ##> "/c short" + inv <- getShortInvitation alice + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: own link" + alice ##> ("/_connect plan 1 " <> slSimplexScheme inv) + alice <## "invitation link: own link" + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" + -- nobody else can connect + cath ##> ("/_connect plan 1 " <> inv) + cath <##. "error: connection authorization failed" + cath ##> ("/c " <> inv) + cath <##. "error: connection authorization failed" + -- bob can retry "plan" + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" + -- with simplex: scheme too + bob ##> ("/_connect plan 1 " <> slSimplexScheme inv) + bob <## "invitation link: ok to connect" + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + alice #> "@bob hi" + bob <# "alice> hi" + bob #> "@alice hey" + alice <# "bob> hey" + bob ##> ("/_connect plan 1 " <> inv) + bob <##. "error: connection authorization failed" + alice ##> ("/_connect plan 1 " <> inv) + alice <##. "error: connection authorization failed" -- short_link_inv and conn_req_inv are removed after connection + +slSimplexScheme :: String -> String +slSimplexScheme sl = T.unpack $ T.replace "https://localhost/" "simplex:/" (T.pack sl) <> "?h=localhost" + +testShortLinkContactAddress :: HasCallStack => TestParams -> IO () +testShortLinkContactAddress = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + alice ##> "/ad short" + (shortLink, fullLink) <- getShortContactLink alice True + alice ##> ("/_connect plan 1 " <> shortLink) + alice <## "contact address: own address" + alice ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + alice <## "contact address: own address" + alice ##> ("/_connect plan 1 " <> fullLink) + alice <## "contact address: own address" + (alice, bob) `connectVia` shortLink + bob ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + (alice, cath) `connectVia` slSimplexScheme shortLink + cath ##> ("/_connect plan 1 " <> shortLink) + cath <## "contact address: known contact alice" + cath <## "use @alice to send messages" + (alice, dan) `connectVia` fullLink + where + (alice, cc) `connectVia` cLink = do + name <- userName cc + sName <- showName cc + cc ##> ("/_connect plan 1 " <> cLink) + cc <## "contact address: ok to connect" + cc ##> ("/c " <> cLink) + alice <#? cc + alice ##> ("/ac " <> name) + alice <## (sName <> ": accepting contact request, you can send messages to contact") + concurrently_ + (cc <## "alice (Alice): contact is connected") + (alice <## (sName <> ": contact is connected")) + cc ##> ("/_connect plan 1 " <> cLink) + cc <## "contact address: known contact alice" + cc <## "use @alice to send messages" + +testShortLinkJoinGroup :: HasCallStack => TestParams -> IO () +testShortLinkJoinGroup = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + threadDelay 100000 + alice ##> "/ad short" -- create the address to test that it can co-exist with group link + _ <- getShortContactLink alice True + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team short" + (shortLink, fullLink) <- getShortGroupLink alice "team" GRMember True + alice ##> ("/_connect plan 1 " <> shortLink) + alice <## "group link: own link for group #team" + alice ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + alice <## "group link: own link for group #team" + alice ##> ("/_connect plan 1 " <> fullLink) + alice <## "group link: own link for group #team" + joinGroup alice bob shortLink + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + bob ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + joinGroup alice cath $ slSimplexScheme shortLink + concurrentlyN_ + [ do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected", + cath <## "#team: member bob (Bob) is connected" + ] + cath ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + cath <## "group link: known group #team" + cath <## "use #team to send messages" + cath ##> ("/_connect plan 1 " <> shortLink) + cath <## "group link: known group #team" + cath <## "use #team to send messages" + joinGroup alice dan fullLink + concurrentlyN_ + [ do + bob <## "#team: alice added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected", + do + cath <## "#team: alice added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected", + do + dan <## "#team: member bob (Bob) is connected" + dan <## "#team: member cath (Catherine) is connected" + ] + dan ##> ("/_connect plan 1 " <> fullLink) + dan <## "group link: known group #team" + dan <## "use #team to send messages" + where + joinGroup alice cc link = do + name <- userName cc + sName <- showName cc + cc ##> ("/_connect plan 1 " <> link) + cc <## "group link: ok to connect" + cc ##> ("/c " <> link) + cc <## "connection request sent!" + alice <## (sName <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> name <> " joined the group"), + do + cc <## "#team: joining the group..." + cc <## "#team: you joined the group" + ] diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9a3b560b29..3ae3f61ca7 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -11,7 +11,7 @@ module ChatTests.Utils where import ChatClient import ChatTests.DBUtils import Control.Concurrent (threadDelay) -import Control.Concurrent.Async (concurrently_) +import Control.Concurrent.Async (concurrently_, mapConcurrently_) import Control.Concurrent.STM import Control.Monad (unless, when) import Control.Monad.Except (runExceptT) @@ -424,7 +424,7 @@ getInAnyOrder f cc ls = do cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line (*<#) :: HasCallStack => [TestCC] -> String -> Expectation -ccs *<# line = concurrentlyN_ $ map (<# line) ccs +ccs *<# line = mapConcurrently_ (<# line) ccs (?<#) :: HasCallStack => TestCC -> String -> Expectation cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line @@ -502,14 +502,27 @@ dropPartialReceipt_ msg = case splitAt 2 msg of _ -> Nothing getInvitation :: HasCallStack => TestCC -> IO String -getInvitation cc = do +getInvitation = getInvitation_ False + +getShortInvitation :: HasCallStack => TestCC -> IO String +getShortInvitation = getInvitation_ True + +getInvitation_ :: HasCallStack => Bool -> TestCC -> IO String +getInvitation_ short cc = do cc <## "pass this invitation link to your contact (via another channel):" cc <## "" inv <- getTermLine cc cc <## "" cc <## "and ask them to connect: /c " + when short $ cc <##. "The invitation link for old clients: https://simplex.chat/invitation#" pure inv +getShortContactLink :: HasCallStack => TestCC -> Bool -> IO (String, String) +getShortContactLink cc created = do + shortLink <- getContactLink cc created + fullLink <- dropLinePrefix "The contact link for old clients: " =<< getTermLine cc + pure (shortLink, fullLink) + getContactLink :: HasCallStack => TestCC -> Bool -> IO String getContactLink cc created = do cc <## if created then "Your new chat address is created!" else "Your chat address:" @@ -522,6 +535,17 @@ getContactLink cc created = do cc <## "to delete it: /da (accepted contacts will remain connected)" pure link +dropLinePrefix :: String -> String -> IO String +dropLinePrefix line s + | line `isPrefixOf` s = pure $ drop (length line) s + | otherwise = error $ "expected to start from: " <> line <> ", got: " <> s + +getShortGroupLink :: HasCallStack => TestCC -> String -> GroupMemberRole -> Bool -> IO (String, String) +getShortGroupLink cc gName mRole created = do + shortLink <- getGroupLink cc gName mRole created + fullLink <- dropLinePrefix "The group link for old clients: " =<< getTermLine cc + pure (shortLink, fullLink) + getGroupLink :: HasCallStack => TestCC -> String -> GroupMemberRole -> Bool -> IO String getGroupLink cc gName mRole created = do cc <## if created then "Group link is created!" else "Group link:" diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 0a00d7b83c..dbfde6a03d 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -19,6 +19,7 @@ import qualified Data.List.NonEmpty as L import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Operators +import Simplex.Chat.Operators.Presets import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 5c672b2858..50d2c1eef0 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -35,7 +35,7 @@ queue = { smpServer = srv, senderId = EntityId "\223\142z\251", dhPublicKey = "MCowBQYDK2VuAyEAjiswwI3O/NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o=", - sndSecure = False + queueMode = Nothing } connReqData :: ConnReqUriData @@ -201,7 +201,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"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\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -210,7 +210,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"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\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -237,10 +237,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"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\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -261,16 +261,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"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\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-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\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"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\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"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\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} 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-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-14\",\"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\"}}}}}" @@ -291,10 +291,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"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\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-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") 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-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-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 -- 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\"}}}" diff --git a/website/src/.well-known/apple-app-site-association/index.json b/website/src/.well-known/apple-app-site-association/index.json index 3cd3fdd043..3b513fe61e 100644 --- a/website/src/.well-known/apple-app-site-association/index.json +++ b/website/src/.well-known/apple-app-site-association/index.json @@ -17,6 +17,30 @@ }, { "/": "/invitation" + }, + { + "/": "/a/*" + }, + { + "/": "/a" + }, + { + "/": "/c/*" + }, + { + "/": "/c" + }, + { + "/": "/g/*" + }, + { + "/": "/g" + }, + { + "/": "/i/*" + }, + { + "/": "/i" } ] } From df99ed495ca55b18117aafefb62cb7a0e3cdf4af Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:02:50 +0000 Subject: [PATCH 175/289] ci/docker: use Java Corretto (#5832) --- Dockerfile.build | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Dockerfile.build b/Dockerfile.build index cd0fd22aad..76bb1127f2 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -11,7 +11,7 @@ ARG JAVA=17 ENV TZ=Etc/UTC \ DEBIAN_FRONTEND=noninteractive -# Install curl, git and and simplexmq dependencies +# Install curl, git and and simplex-chat dependencies RUN apt-get update && \ apt-get install -y curl \ libpq-dev \ @@ -27,7 +27,6 @@ RUN apt-get update && \ libnuma-dev \ libssl-dev \ desktop-file-utils \ - openjdk-${JAVA}-jdk-headless \ patchelf \ ca-certificates \ zip \ @@ -37,9 +36,18 @@ RUN apt-get update && \ appstream \ gpg \ unzip &&\ - export JAVA_HOME=$(update-java-alternatives -l | head -n 1 | awk -F ' ' '{print $NF}') &&\ ln -s /bin/fusermount /bin/fusermount3 || : +# Install Java Coretto +# Required, because official Java in Ubuntu +# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final +# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin +# to fix this :( +RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\ + echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\ + apt update &&\ + apt install -y java-${JAVA}-amazon-corretto-jdk + # Specify bootstrap Haskell versions ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} From f52d06af3ad160490787bebe8f0e15f8f0d927d6 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:24:24 +0000 Subject: [PATCH 176/289] flatpak: update metainfo (#5836) * flatpak: update metainfo * include previous changes --- .../flatpak/chat.simplex.simplex.metainfo.xml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 6ad4fda03e..823b8562fc 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,28 @@
+ + https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +

New in v6.3.1-2:

+
    +
  • fix related to backward/forward compatibility of the app in some rare cases.
  • +
  • scrolling/navigation improvements.
  • +
  • faster onboarding (conditions and operators are combined to one screen).
  • +
+

New in v6.3.0:

+
    +
  • Mention members and get notified when mentioned.
  • +
  • Send private reports to moderators.
  • +
  • Delete, block and change role for multiple members at once
  • +
  • Faster sending messages and faster deletion.
  • +
  • Organize chats into lists to keep track of what's important.
  • +
  • Jump to found and forwarded messages.
  • +
  • Private media file names.
  • +
  • Message expiration in chats.
  • +
+
+
https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html From 0f3e546e369845372346504fd7e82afe3002e371 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 15 Apr 2025 13:50:06 +0000 Subject: [PATCH 177/289] kotlin: refactor chat contexts 2 (null secondary context, pass context instead of content tag, straighten chat state code) (#5830) --- .../simplex/common/platform/UI.android.kt | 3 - .../kotlin/chat/simplex/common/App.kt | 4 +- .../chat/simplex/common/model/ChatModel.kt | 92 ++-- .../chat/simplex/common/model/SimpleXAPI.kt | 92 ++-- .../simplex/common/platform/NtfManager.kt | 2 +- .../simplex/common/views/chat/ChatInfoView.kt | 15 +- .../common/views/chat/ChatItemInfoView.kt | 2 +- .../common/views/chat/ChatItemsLoader.kt | 17 +- .../common/views/chat/ChatItemsMerger.kt | 43 +- .../simplex/common/views/chat/ChatView.kt | 520 +++++++++--------- .../views/chat/SelectableChatItemToolbars.kt | 4 +- .../views/chat/group/AddGroupMembersView.kt | 4 +- .../views/chat/group/GroupChatInfoView.kt | 9 +- .../views/chat/group/GroupMemberInfoView.kt | 44 +- .../views/chat/group/GroupReportsView.kt | 23 +- .../views/chat/item/CIChatFeatureView.kt | 9 +- .../common/views/chat/item/ChatItemView.kt | 50 +- .../views/chat/item/MarkedDeletedItemView.kt | 9 +- .../views/chatlist/ChatListNavLinkView.kt | 40 +- .../views/contacts/ContactListNavView.kt | 4 +- .../common/views/database/DatabaseView.kt | 8 +- .../simplex/common/views/helpers/ModalView.kt | 11 +- .../kotlin/chat/simplex/common/DesktopApp.kt | 4 +- .../views/chatlist/ChatListView.desktop.kt | 4 +- 24 files changed, 462 insertions(+), 551 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index f56563a1cb..f6066d1624 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -80,9 +80,6 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { chatModel.chatId.value = null chatModel.chatsContext.chatItems.clearAndNotify() } - withContext(Dispatchers.Main) { - chatModel.chatsContext.chatItems.clearAndNotify() - } } } else { // ChatList, nothing to do. Maybe to show other view except ChatList diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 600804a763..d88a450fd1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, contentTag = null, onComposed = onComposed) + ChatView(chatsCtx = chatModel.chatsContext, currentChatId, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId, contentTag = null) {} + else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} } } 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 7ad26c3726..7e5eed3c42 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 @@ -67,7 +67,7 @@ object ChatModel { val chatId = mutableStateOf(null) val openAroundItemId: MutableState = mutableStateOf(null) val chatsContext = ChatsContext(null) - val secondaryChatsContext = ChatsContext(MsgContentTag.Report) + val secondaryChatsContext = mutableStateOf(null) // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions val chats: State> = chatsContext.chats // rhId, chatId @@ -294,16 +294,15 @@ object ChatModel { } } - class ChatsContext(private val contentTag: MsgContentTag?) { + class ChatsContext(val contentTag: MsgContentTag?) { val chats = mutableStateOf(SnapshotStateList()) - /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on. + /** 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]. - * If you use api call to get the items, use just [add] instead of [addAndNotify]. + * 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 - var chatItemsChangesListener: ChatItemsChangesListener? = null val chatState = ActiveChatState() fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null @@ -395,6 +394,26 @@ object ChatModel { addChat(chat) } } + + fun addToChatItems(index: Int, elem: ChatItem) { + chatItems.value = SnapshotStateList().apply { addAll(chatItems.value); add(index, elem); chatState.itemAdded(elem.id to elem.isRcvNew) } + } + + fun addToChatItems(elem: ChatItem) { + chatItems.value = SnapshotStateList().apply { addAll(chatItems.value); add(elem); chatState.itemAdded(elem.id to elem.isRcvNew) } + } + + fun removeLastChatItems() { + val removed: Triple + chatItems.value = SnapshotStateList().apply { + addAll(chatItems.value) + val remIndex = lastIndex + val rem = removeLast() + removed = Triple(rem.id, remIndex, rem.isRcvNew) + } + chatState.itemsRemoved(listOf(removed), chatItems.value) + } + suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { // mark chat non deleted if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { @@ -448,9 +467,9 @@ object ChatModel { // 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) { - chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem, contentTag) + addToChatItems(kotlin.math.max(0, chatItems.value.lastIndex), cItem) } else { - chatItems.addAndNotify(cItem, contentTag) + addToChatItems(cItem) } } } @@ -495,7 +514,7 @@ object ChatModel { } else { cItem } - chatItems.addAndNotify(ci, contentTag) + addToChatItems(ci) true } } else { @@ -602,9 +621,10 @@ object ChatModel { } } - val popChatCollector = PopChatCollector(contentTag) + val popChatCollector = PopChatCollector(this) - class PopChatCollector(contentTag: MsgContentTag?) { + // TODO [contexts] no reason for this to be nested? + class PopChatCollector(chatsCtx: ChatsContext) { private val subject = MutableSharedFlow() private var remoteHostId: Long? = null private val chatsToPop = mutableMapOf() @@ -615,7 +635,6 @@ object ChatModel { .throttleLatest(2000) .collect { withContext(Dispatchers.Main) { - val chatsCtx = if (contentTag == null) chatsContext else secondaryChatsContext chatsCtx.chats.replaceAll(popCollectedChats()) } } @@ -704,7 +723,7 @@ object ChatModel { } i-- } - chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) + chatState.itemsRead(if (itemIds != null) markedReadIds else null, items) } return markedRead to mentionsMarkedRead } @@ -917,7 +936,7 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) withContext(Dispatchers.Main) { - chatsContext.chatItems.addAndNotify(cItem, contentTag = null) + chatsContext.addToChatItems(cItem) } return cItem } @@ -926,7 +945,7 @@ object ChatModel { if (chatsContext.chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { withApi { withContext(Dispatchers.Main) { - chatsContext.chatItems.removeLastAndNotify(contentTag = null) + chatsContext.removeLastChatItems() } } } @@ -1000,8 +1019,6 @@ object ChatModel { withApi { withContext(Dispatchers.Main) { showingInvitation.value = null - // TODO [contexts] - why does clearAndNotify operates with listeners for both contexts? - // TODO - should it be called for both contexts here instead? chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = withId } @@ -1015,7 +1032,6 @@ object ChatModel { if (id == showingInvitation.value?.connId) { withContext(Dispatchers.Main) { showingInvitation.value = null - // TODO [contexts] see replaceConnReqView chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = null } @@ -1067,15 +1083,6 @@ object ChatModel { fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } -interface ChatItemsChangesListener { - // pass null itemIds if the whole chat now read - fun read(itemIds: Set?, newItems: List) - fun added(item: Pair, index: Int) - // itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not) - fun removed(itemIds: List>, newItems: List) - fun cleared() -} - data class ShowingInvitation( val connId: String, val connLink: CreatedConnLink, @@ -2697,11 +2704,6 @@ fun MutableState>.add(index: Int, elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(index, elem) } } -fun MutableState>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext - value = SnapshotStateList().apply { addAll(value); add(index, elem); chatsCtx.chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) } -} - fun MutableState>.add(elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(elem) } } @@ -2709,12 +2711,6 @@ fun MutableState>.add(elem: Chat) { // For some reason, Kotlin version crashes if the list is empty fun MutableList.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate) -// Adds item to chatItems and notifies a listener about newly added item -fun MutableState>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext - value = SnapshotStateList().apply { addAll(value); add(elem); chatsCtx.chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) } -} - fun MutableState>.addAll(index: Int, elems: List) { value = SnapshotStateList().apply { addAll(value); addAll(index, elems) } } @@ -2727,6 +2723,7 @@ fun MutableState>.removeAll(block: (Chat) -> Boolean) { value = SnapshotStateList().apply { addAll(value); removeAll(block) } } +// TODO [contexts] operates with both contexts? // Removes item(s) from chatItems and notifies a listener about removed item(s) fun MutableState>.removeAllAndNotify(block: (ChatItem) -> Boolean) { val toRemove = ArrayList>() @@ -2741,8 +2738,8 @@ fun MutableState>.removeAllAndNotify(block: (ChatIte } } if (toRemove.isNotEmpty()) { - chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value) - chatModel.secondaryChatsContext.chatItemsChangesListener?.removed(toRemove, value) + chatModel.chatsContext.chatState.itemsRemoved(toRemove, value) + chatModel.secondaryChatsContext.value?.chatState?.itemsRemoved(toRemove, value) } } @@ -2754,18 +2751,6 @@ fun MutableState>.removeAt(index: Int): Chat { return res } -fun MutableState>.removeLastAndNotify(contentTag: MsgContentTag?) { - val removed: Triple - value = SnapshotStateList().apply { - addAll(value) - val remIndex = lastIndex - val rem = removeLast() - removed = Triple(rem.id, remIndex, rem.isRcvNew) - } - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext - chatsCtx.chatItemsChangesListener?.removed(listOf(removed), value) -} - fun MutableState>.replaceAll(elems: List) { value = SnapshotStateList().apply { addAll(elems) } } @@ -2774,11 +2759,12 @@ fun MutableState>.clear() { value = SnapshotStateList() } +// TODO [contexts] operates with both contexts? // Removes all chatItems and notifies a listener about it fun MutableState>.clearAndNotify() { value = SnapshotStateList() - chatModel.chatsContext.chatItemsChangesListener?.cleared() - chatModel.secondaryChatsContext.chatItemsChangesListener?.cleared() + chatModel.chatsContext.chatState.clear() + chatModel.secondaryChatsContext.value?.chatState?.clear() } fun State>.asReversed(): MutableList = value.asReversed() 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 b5f44fdebc..a557cf93cf 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 @@ -1553,7 +1553,7 @@ object ChatController { chatModel.chatsContext.clearChat(chat.remoteHostId, updatedChatInfo) } withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.clearChat(chat.remoteHostId, updatedChatInfo) + chatModel.secondaryChatsContext.value?.clearChat(chat.remoteHostId, updatedChatInfo) } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() @@ -2493,9 +2493,7 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.toMember) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.toMember) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.toMember) } } } @@ -2550,10 +2548,8 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.addChatItem(rhId, cInfo, cItem) - } + if (cItem.isReport) { + chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) } } } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { @@ -2583,10 +2579,8 @@ object ChatController { chatModel.chatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) - } + if (cItem.isReport) { + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } } } @@ -2599,10 +2593,8 @@ object ChatController { chatModel.chatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (r.reaction.chatReaction.chatItem.isReport) { - chatModel.secondaryChatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) - } + if (r.reaction.chatReaction.chatItem.isReport) { + chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } } } @@ -2646,13 +2638,11 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (cItem.isReport) { - if (toChatItem == null) { - chatModel.secondaryChatsContext.removeChatItem(rhId, cInfo, cItem) - } else { - chatModel.secondaryChatsContext.upsertChatItem(rhId, cInfo, toChatItem.chatItem) - } + if (cItem.isReport) { + if (toChatItem == null) { + chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem) + } else { + chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } } @@ -2722,10 +2712,8 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (r.withMessages) { - chatModel.secondaryChatsContext.removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) - } + if (r.withMessages) { + chatModel.secondaryChatsContext.value?.removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) } } } @@ -2738,11 +2726,9 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) - if (r.withMessages) { - chatModel.secondaryChatsContext.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + if (r.withMessages) { + chatModel.secondaryChatsContext.value?.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) } } } @@ -2752,9 +2738,7 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.MemberRole -> @@ -2763,9 +2747,7 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.MembersRoleUser -> @@ -2776,10 +2758,8 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - r.members.forEach { member -> - chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, member) - } + r.members.forEach { member -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, member) } } } @@ -2789,9 +2769,7 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.GroupDeleted -> // TODO update user member @@ -3167,10 +3145,8 @@ object ChatController { val cItem = aChatItem.chatItem withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.upsertChatItem(rh, cInfo, cItem) - } + if (cItem.isReport) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) } } } @@ -3212,8 +3188,8 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - val chatsCtx = chatModel.secondaryChatsContext + val chatsCtx = chatModel.secondaryChatsContext.value + if (chatsCtx != null) { r.chatItemIDs.forEach { itemId -> val cItem = chatsCtx.chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach if (chatModel.chatId.value != null) { @@ -3240,10 +3216,8 @@ object ChatController { } else { val createdChat = withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (cItem.content.msgContent is MsgContent.MCReport) { - chatModel.secondaryChatsContext.upsertChatItem(rh, cInfo, cItem) - } + if (cItem.content.msgContent is MsgContent.MCReport) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) } } if (createdChat) { @@ -3296,11 +3270,9 @@ object ChatController { chatModel.chatsContext.popChatCollector.clear() } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.chatItems.clearAndNotify() - chatModel.secondaryChatsContext.chats.clear() - chatModel.secondaryChatsContext.popChatCollector.clear() - } + chatModel.secondaryChatsContext.value?.chatItems?.clearAndNotify() + chatModel.secondaryChatsContext.value?.chats?.clear() + chatModel.secondaryChatsContext.value?.popChatCollector?.clear() } } val statuses = apiGetNetworkStatuses(rhId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 5efd3747a3..5b9e63963c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -73,7 +73,7 @@ abstract class NtfManager { } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true - if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo) + if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(secondaryChatsCtx = null, rhId = null, cInfo) } } 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 d5a99d9acb..2a77d0a6dc 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 @@ -55,6 +55,7 @@ import java.io.File @Composable fun ChatInfoView( + chatsCtx: ChatModel.ChatsContext, chatModel: ChatModel, contact: Contact, connectionStats: ConnectionStats?, @@ -97,7 +98,7 @@ fun ChatInfoView( val previousChatTTL = chatItemTTL.value chatItemTTL.value = it - setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, connStats = connStats, contactNetworkStatus.value, @@ -1332,6 +1333,7 @@ fun queueInfoText(info: Pair): String { } fun setChatTTLAlert( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, selectedChatTTL: MutableState, @@ -1351,7 +1353,7 @@ fun setChatTTLAlert( } else MR.strings.enable_automatic_deletion_question), text = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion_message else MR.strings.change_automatic_chat_deletion_message), confirmText = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion else MR.strings.delete_messages), - onConfirm = { setChatTTL(rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) }, + onConfirm = { setChatTTL(chatsCtx, rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) }, onDismiss = { selectedChatTTL.value = previousChatTTL }, onDismissRequest = { selectedChatTTL.value = previousChatTTL }, destructive = true, @@ -1359,6 +1361,7 @@ fun setChatTTLAlert( } private fun setChatTTL( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, chatTTL: MutableState, @@ -1369,16 +1372,16 @@ private fun setChatTTL( withBGApi { try { chatModel.controller.setChatTTL(rhId, chatInfo.chatType, chatInfo.apiId, chatTTL.value) - afterSetChatTTL(rhId, chatInfo, progressIndicator) + afterSetChatTTL(chatsCtx, rhId, chatInfo, progressIndicator) } catch (e: Exception) { chatTTL.value = previousChatTTL - afterSetChatTTL(rhId, chatInfo, progressIndicator) + afterSetChatTTL(chatsCtx, rhId, chatInfo, progressIndicator) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_message_deletion), e.stackTraceToString()) } } } -private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState) { +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 @@ -1393,9 +1396,9 @@ private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressInd } if (chat.remoteHostId != chatModel.remoteHostId() || chat.id != chatModel.chatId.value) return processLoadedChat( + chatsCtx, chat, navInfo, - contentTag = null, pagination = pagination, openAroundItemId = null ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index c13eb8f139..9c36f4896b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -209,7 +209,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools SectionItemView( click = { withBGApi { - openChat(chatRh, forwardedFromItem.chatInfo) + openChat(secondaryChatsCtx = null, chatRh, forwardedFromItem.chatInfo) ModalManager.end.closeModals() } }, 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 51a6d24e21..eabe9cb60a 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 @@ -11,43 +11,42 @@ import kotlin.math.min const val TRIM_KEEP_COUNT = 200 suspend fun apiLoadSingleMessage( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatType: ChatType, apiId: Long, - itemId: Long, - contentTag: MsgContentTag?, + itemId: Long ): ChatItem? = coroutineScope { - val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null + val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null chat.chatItems.firstOrNull() } suspend fun apiLoadMessages( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatType: ChatType, apiId: Long, - contentTag: MsgContentTag?, pagination: ChatPagination, search: String = "", openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, 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) || !isActive) return@coroutineScope - processLoadedChat(chat, navInfo, contentTag, pagination, openAroundItemId, visibleItemIndexesNonReversed) + processLoadedChat(chatsCtx, chat, navInfo, pagination, openAroundItemId, visibleItemIndexesNonReversed) } suspend fun processLoadedChat( + chatsCtx: ChatModel.ChatsContext, chat: Chat, navInfo: NavigationInfo, - contentTag: MsgContentTag?, pagination: ChatPagination, openAroundItemId: Long?, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext val chatState = chatsCtx.chatState val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState val oldItems = chatsCtx.chatItems.value @@ -55,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 (contentTag == null) { + if (chatsCtx.contentTag == 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/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index d318cf05fd..d98c041478 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -237,24 +237,8 @@ data class ActiveChatState ( unreadAfter.value = 0 unreadAfterNewestLoaded.value = 0 } -} -fun visibleItemIndexesNonReversed(mergedItems: State, reversedItemsSize: Int, listState: LazyListState): IntRange { - val zero = 0 .. 0 - if (listState.layoutInfo.totalItemsCount == 0) return zero - val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems - val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed() - if (newest == null || oldest == null) return zero - val range = reversedItemsSize - oldest .. reversedItemsSize - newest - if (range.first < 0 || range.last < 0) return zero - - // visible items mapped to their underlying data structure which is chatModel.chatItems - return range -} - -fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItemsChangesListener { - override fun read(itemIds: Set?, newItems: List) { - val (_, unreadAfterItemId, _, unreadTotal, unreadAfter) = chatState + fun itemsRead(itemIds: Set?, newItems: List) { if (itemIds == null) { // special case when the whole chat became read unreadTotal.value = 0 @@ -287,14 +271,15 @@ fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItem unreadTotal.value = newUnreadTotal unreadAfter.value = newUnreadAfter } - override fun added(item: Pair, index: Int) { + + fun itemAdded(item: Pair) { if (item.second) { - chatState.unreadAfter.value++ - chatState.unreadTotal.value++ + unreadAfter.value++ + unreadTotal.value++ } } - override fun removed(itemIds: List>, newItems: List) { - val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter) = chatState + + fun itemsRemoved(itemIds: List>, newItems: List) { val newSplits = ArrayList() for (split in splits.value) { val index = itemIds.indexOfFirst { it.first == split } @@ -343,7 +328,19 @@ fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItem totalAfter.value -= itemIds.size } } - override fun cleared() { chatState.clear() } +} + +fun visibleItemIndexesNonReversed(mergedItems: State, reversedItemsSize: Int, listState: LazyListState): IntRange { + val zero = 0 .. 0 + if (listState.layoutInfo.totalItemsCount == 0) return zero + val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems + val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed() + if (newest == null || oldest == null) return zero + val range = reversedItemsSize - oldest .. reversedItemsSize - newest + if (range.first < 0 || range.last < 0) return zero + + // visible items mapped to their underlying data structure which is chatModel.chatItems + return range } /** Helps in debugging */ 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 2a18419746..ef82b9a35b 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 @@ -57,8 +57,8 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts fun ChatView( + chatsCtx: ChatModel.ChatsContext, staleChatId: State, - contentTag: MsgContentTag?, scrollToItemId: MutableState = remember { mutableStateOf(null) }, onComposed: suspend (chatId: String) -> Unit ) { @@ -99,7 +99,7 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> - if (contentTag == null) { + if (chatsCtx.contentTag == null) { markUnreadChatAsRead(chatId) } showSearch.value = false @@ -112,7 +112,6 @@ fun ChatView( val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext val unreadCount = remember { derivedStateOf { chatsCtx.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 @@ -121,7 +120,6 @@ fun ChatView( val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), - LocalContentTag provides contentTag ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -135,15 +133,16 @@ 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 && contentTag == null + val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.contentTag == null val c = chatModel.getChat(chatInfo.id) if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { - apiFindMessages(c, value, contentTag) + apiFindMessages(chatsCtx, c, value) searchText.value = value } } ChatLayout( + chatsCtx = chatsCtx, remoteHostId = remoteHostId, chatInfo = activeChatInfo, unreadCount, @@ -175,7 +174,7 @@ fun ChatView( } } else { SelectedItemsButtonsToolbar( - contentTag = contentTag, + chatsCtx = chatsCtx, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -287,7 +286,7 @@ fun ChatView( code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second preloadedCode = code } - ChatInfoView(chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) { + ChatInfoView(chatsCtx, chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) { showSearch.value = true } } else if (chatInfo is ChatInfo.Group) { @@ -297,7 +296,7 @@ fun ChatView( link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatRh, chatInfo.id, link?.first, link?.second, selectedItems, appBar, scrollToItemId, { + GroupChatInfoView(chatsCtx, chatRh, chatInfo.id, link?.first, link?.second, selectedItems, appBar, scrollToItemId, { link = it preloadedLink = it }, close, { showSearch.value = true }) @@ -344,7 +343,7 @@ fun ChatView( setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - if (contentTag == null) { + if (chatsCtx.contentTag == null) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(true) { close -> @@ -358,12 +357,12 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, null, visibleItemIndexes) + apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, searchText.value, null, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = reversedChatItemsStatic(contentTag).lastOrNull { it.id == itemId } + val toDeleteItem = reversedChatItemsStatic(chatsCtx).lastOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -400,13 +399,11 @@ fun ChatView( } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (deletedChatItem.isReport) { - if (toChatItem != null) { - chatModel.secondaryChatsContext.upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - chatModel.secondaryChatsContext.removeChatItem(chatRh, chatInfo, deletedChatItem) - } + if (deletedChatItem.isReport) { + if (toChatItem != null) { + chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, deletedChatItem) } } } @@ -524,10 +521,8 @@ fun ChatView( chatModel.chatsContext.updateChatItem(cInfo, updatedCI) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.updateChatItem(cInfo, updatedCI) - } + if (cItem.isReport) { + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, updatedCI) } } } @@ -592,9 +587,7 @@ fun ChatView( ) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.markChatItemsRead(chatRh, chatInfo.id, itemsIds) - } + chatModel.secondaryChatsContext.value?.markChatItemsRead(chatRh, chatInfo.id, itemsIds) } } }, @@ -610,9 +603,7 @@ fun ChatView( ) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.markChatItemsRead(chatRh, chatInfo.id) - } + chatModel.secondaryChatsContext.value?.markChatItemsRead(chatRh, chatInfo.id) } } }, @@ -676,6 +667,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) @Composable fun ChatLayout( + chatsCtx: ChatModel.ChatsContext, remoteHostId: State, chatInfo: State, unreadCount: State, @@ -753,8 +745,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - val contentTag = LocalContentTag.current - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, contentTag == null)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.contentTag == null)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -768,7 +759,7 @@ fun ChatLayout( override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, + chatsCtx, remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, @@ -791,7 +782,7 @@ fun ChatLayout( } } } - if (contentTag == MsgContentTag.Report) { + if (chatsCtx.contentTag == MsgContentTag.Report) { Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -802,7 +793,7 @@ fun ChatLayout( AnimatedVisibility(selectedChatItems.value != null) { if (chatInfo != null) { SelectedItemsButtonsToolbar( - contentTag = contentTag, + chatsCtx = chatsCtx, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { _ -> @@ -842,7 +833,7 @@ fun ChatLayout( } val reportsCount = reportsCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (contentTag == null && reportsCount > 0) { + if (chatsCtx.contentTag == null && reportsCount > 0) { ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) } else { StatusBarBackground() @@ -850,14 +841,14 @@ fun ChatLayout( } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - if (contentTag == MsgContentTag.Report) { + 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(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged) + GroupReportsAppBar(chatsCtx, { ModalManager.end.closeModal() }, onSearchValueChanged) } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) } @@ -868,13 +859,13 @@ fun ChatLayout( Box { if (selectedChatItems.value == null) { if (chatInfo != null) { - ChatInfoToolbar(chatInfo, contentTag, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) } } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { + if (chatsCtx.contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) } } @@ -886,8 +877,8 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, - contentTag: MsgContentTag?, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -909,7 +900,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid && contentTag == null) { + if (appPlatform.isAndroid && chatsCtx.contentTag == null) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -1148,6 +1139,7 @@ private var reportsListState: LazyListState? = null @Composable fun BoxScope.ChatItemsList( + chatsCtx: ChatModel.ChatsContext, remoteHostId: Long?, chatInfo: ChatInfo, unreadCount: State, @@ -1205,107 +1197,107 @@ fun BoxScope.ChatItemsList( val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val contentTag = LocalContentTag.current // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext - val mergedItems = remember { - derivedStateOf { - MergedItems.create(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) + if (chatsCtx != null) { + 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 = contentTag == null, contentTag == null && reportsCount > 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 + 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() + + /** 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 + 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) } - 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(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(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, contentTag, 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) + 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 } - } finally { - loadingMoreItems.value = false } } - true - } + SmallScrollOnNewMessage(listState, reversedChatItems) + val finishedInitialComposition = remember { mutableStateOf(false) } + NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) - 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 (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(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, contentTag) } - if (contentTag == null) { - LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { - if (appPlatform.isAndroid) { - ModalManager.end.closeModals() + DisposableEffectOnGone( + whenGone = { + VideoPlayerHolder.releaseAll() } - scrollToItem(it) - scrollToItemId.value = null } - } - } - SmallScrollOnNewMessage(listState, reversedChatItems) - val finishedInitialComposition = remember { mutableStateOf(false) } - NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) - - DisposableEffectOnGone( - always = { - chatsCtx.chatItemsChangesListener = recalculateChatStatePositions(chatsCtx.chatState) - }, - whenGone = { - VideoPlayerHolder.releaseAll() - chatsCtx.chatItemsChangesListener = recalculateChatStatePositions(chatsCtx.chatState) - } - ) + ) @Composable fun ChatViewListItem( @@ -1350,7 +1342,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(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, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -1487,7 +1479,7 @@ fun BoxScope.ChatItemsList( } } else { ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } Row( @@ -1502,7 +1494,7 @@ fun BoxScope.ChatItemsList( } } else { ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } Box( @@ -1517,7 +1509,7 @@ fun BoxScope.ChatItemsList( } } else { // direct message ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } @@ -1547,110 +1539,110 @@ fun BoxScope.ChatItemsList( 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(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 } + 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) } - MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead) } } - } - FloatingButtons( - reversedChatItems, - chatInfoUpdated, - topPaddingToContent, - topPaddingToContentPx, - contentTag, - 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) + 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 + 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(chatId: State, contentTag: MsgContentTag?, listState: State, loadItems: State Boolean>) { +private suspend fun loadLastItems(chatsCtx: ChatModel.ChatsContext, chatId: State, listState: State, loadItems: State Boolean>) { val lastVisible = listState.value.layoutInfo.visibleItemsInfo.lastOrNull() val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size if (!itemsCanCoverScreen) return - if (lastItemsLoaded(contentTag)) return + if (lastItemsLoaded(chatsCtx)) return delay(500) loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } -private fun lastItemsLoaded(contentTag: MsgContentTag?): Boolean { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext +private fun lastItemsLoaded(chatsCtx: ChatModel.ChatsContext): Boolean { val chatState = chatsCtx.chatState return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatsCtx.chatItems.value.lastOrNull()?.id } @@ -1715,11 +1707,11 @@ private fun NotifyChatListOnFinishingComposition( @Composable fun BoxScope.FloatingButtons( + chatsCtx: ChatModel.ChatsContext, reversedChatItems: State>, chatInfo: State, topPaddingToContent: Dp, topPaddingToContentPx: State, - contentTag: MsgContentTag?, loadingMoreItems: MutableState, loadingTopItems: MutableState, loadingBottomItems: MutableState, @@ -1743,7 +1735,6 @@ fun BoxScope.FloatingButtons( fun scrollToTopUnread() { scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext if (chatsCtx.chatState.splits.value.isNotEmpty()) { val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) val oldSize = reversedChatItems.value.size @@ -1806,7 +1797,7 @@ fun BoxScope.FloatingButtons( animatedScrollingInProgress, composeViewHeight, onClick = { - if (loadingBottomItems.value || !lastItemsLoaded(contentTag)) { + if (loadingBottomItems.value || !lastItemsLoaded(chatsCtx)) { requestedTopScroll.value = false requestedBottomScroll.value = true } else { @@ -1881,11 +1872,11 @@ fun BoxScope.FloatingButtons( @Composable fun PreloadItems( + chatsCtx: ChatModel.ChatsContext, chatId: String, ignoreLoadingRequests: MutableSet, loadingMoreItems: State, resetListState: State, - contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1911,20 +1902,20 @@ fun PreloadItems( snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() .collect { firstVisibleIndex -> - if (!preloadItemsBefore(firstVisibleIndex, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems)) { - preloadItemsAfter(firstVisibleIndex, chatId, contentTag, mergedItems, remaining, loadItems) + if (!preloadItemsBefore(chatsCtx, firstVisibleIndex, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems)) { + preloadItemsAfter(chatsCtx, firstVisibleIndex, chatId, mergedItems, remaining, loadItems) } - loadLastItems(chatId, contentTag, listState, loadItems) + loadLastItems(chatsCtx, chatId, listState, loadItems) } } } } private suspend fun preloadItemsBefore( + chatsCtx: ChatModel.ChatsContext, firstVisibleIndex: Int, chatId: State, ignoreLoadingRequests: State>, - contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1933,18 +1924,18 @@ private suspend fun preloadItemsBefore( val splits = mergedItems.value.splits val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = reversedChatItemsStatic(contentTag) + val items = reversedChatItemsStatic(chatsCtx) if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { lastIndexToLoadFrom = items.lastIndex } if (lastIndexToLoadFrom != null) { val loadFromItemId = items.getOrNull(lastIndexToLoadFrom)?.id ?: return false if (!ignoreLoadingRequests.value.contains(loadFromItemId)) { - val items = reversedChatItemsStatic(contentTag) + val items = reversedChatItemsStatic(chatsCtx) val sizeWas = items.size val oldestItemIdWas = items.lastOrNull()?.id val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - val itemsUpdated = reversedChatItemsStatic(contentTag) + val itemsUpdated = reversedChatItemsStatic(chatsCtx) if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { ignoreLoadingRequests.value.add(loadFromItemId) return false @@ -1956,14 +1947,14 @@ private suspend fun preloadItemsBefore( } private suspend fun preloadItemsAfter( + chatsCtx: ChatModel.ChatsContext, firstVisibleIndex: Int, chatId: State, - contentTag: MsgContentTag?, mergedItems: State, remaining: Int, loadItems: State Boolean>, ) { - val items = reversedChatItemsStatic(contentTag) + val items = reversedChatItemsStatic(chatsCtx) val splits = mergedItems.value.splits val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) @@ -2129,11 +2120,10 @@ private fun FloatingDate( } @Composable -private fun SaveReportsStateOnDispose(listState: State) { - val contentTag = LocalContentTag.current +private fun SaveReportsStateOnDispose(chatsCtx: ChatModel.ChatsContext, listState: State) { DisposableEffect(Unit) { onDispose { - reportsListState = if (contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) listState.value else null + reportsListState = if (chatsCtx.contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) listState.value else null } } } @@ -2244,10 +2234,8 @@ fun reportsCount(staleChatId: String?): Int { } } -private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext - return chatsCtx.chatItems.value.asReversed() -} +private fun reversedChatItemsStatic(chatsCtx: ChatModel.ChatsContext): List = + chatsCtx.chatItems.value.asReversed() private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State, mergedItems: State, listState: State): ListItem? { val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value @@ -2323,22 +2311,20 @@ private fun scrollToItem( } private fun findQuotedItemFromItem( + chatsCtx: ChatModel.ChatsContext, rhId: State, chatInfo: State, scope: CoroutineScope, - scrollToItem: (Long) -> Unit, - contentTag: MsgContentTag? + scrollToItem: (Long) -> Unit ): (Long) -> Unit = { itemId: Long -> scope.launch(Dispatchers.Default) { - val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId, contentTag) + val item = apiLoadSingleMessage(chatsCtx, rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId) if (item != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateChatItem(chatInfo.value, item) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.updateChatItem(chatInfo.value, item) - } + chatModel.secondaryChatsContext.value?.updateChatItem(chatInfo.value, item) } if (item.quotedItem?.itemId != null) { scrollToItem(item.quotedItem.itemId) @@ -2533,15 +2519,13 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List Unit) { @Composable fun SelectedItemsButtonsToolbar( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, - contentTag: MsgContentTag?, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible archiveItems: () -> Unit, @@ -122,7 +121,6 @@ fun SelectedItemsButtonsToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext val chatItems = remember { derivedStateOf { chatsCtx.chatItems.value } } LaunchedEffect(chatInfo, chatItems.value, selectedChatItems.value) { recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) 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 a6d009f76a..10694d13bf 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 @@ -65,9 +65,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, member) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, member) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, member) } } else { break 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 1551c49b47..22956738e7 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 @@ -51,6 +51,7 @@ val MEMBER_ROW_VERTICAL_PADDING = 8.dp @Composable fun ModalData.GroupChatInfoView( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatId: String, groupLink: CreatedConnLink?, @@ -92,7 +93,7 @@ fun ModalData.GroupChatInfoView( val previousChatTTL = chatItemTTL.value chatItemTTL.value = it - setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, activeSortedMembers = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } @@ -971,10 +972,8 @@ fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSu } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - updatedMembers.forEach { updatedMember -> - chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, updatedMember) - } + updatedMembers.forEach { updatedMember -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, 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 638722463d..285c96165c 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 @@ -66,9 +66,7 @@ fun GroupMemberInfoView( chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) - } + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -88,7 +86,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withBGApi { - apiLoadMessages(rhId, ChatType.Direct, it, null, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) + apiLoadMessages(chatModel.chatsContext, rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) if (chatModel.getContactChat(it) != null) { closeAll() } @@ -154,9 +152,7 @@ fun GroupMemberInfoView( chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) - } + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -173,9 +169,7 @@ fun GroupMemberInfoView( chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) - } + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -195,9 +189,7 @@ fun GroupMemberInfoView( chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) - } + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -223,9 +215,7 @@ fun GroupMemberInfoView( chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, copy) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, copy) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, copy) } r } @@ -262,10 +252,8 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - removedMembers.forEach { removedMember -> - chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, removedMember) - } + removedMembers.forEach { removedMember -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, removedMember) } } } @@ -714,10 +702,8 @@ fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInf } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - members.forEach { member -> - chatModel.secondaryChatsContext.upsertGroupMember(rhId, groupInfo, member) - } + members.forEach { member -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, member) } } onSuccess() @@ -815,9 +801,7 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem chatModel.chatsContext.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) - } + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } } } @@ -878,10 +862,8 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, memberIds: List, bloc } } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - updatedMembers.forEach { updatedMember -> - chatModel.secondaryChatsContext.upsertGroupMember(rhId, gInfo, updatedMember) - } + updatedMembers.forEach { updatedMember -> + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, gInfo, updatedMember) } } onSuccess() 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 b41d190ffe..1eeeb99c93 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 @@ -14,16 +14,14 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.* -val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } - @Composable -private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { - ChatView(staleChatId, contentTag = MsgContentTag.Report, scrollToItemId, onComposed = {}) +private fun GroupReportsView(reportsChatsCtx: ChatModel.ChatsContext, staleChatId: State, scrollToItemId: MutableState) { + ChatView(reportsChatsCtx, staleChatId, scrollToItemId, onComposed = {}) } @Composable fun GroupReportsAppBar( - contentTag: MsgContentTag?, + chatsCtx: ChatModel.ChatsContext, close: () -> Unit, onSearchValueChanged: (String) -> Unit ) { @@ -51,11 +49,11 @@ fun GroupReportsAppBar( } } ) - ItemsReload(contentTag) + ItemsReload(chatsCtx) } @Composable -private fun ItemsReload(contentTag: MsgContentTag?) { +private fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { LaunchedEffect(Unit) { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() @@ -65,18 +63,19 @@ private fun ItemsReload(contentTag: MsgContentTag?) { .filterNotNull() .filter { it.chatInfo is ChatInfo.Group } .collect { chat -> - reloadItems(chat, contentTag) + reloadItems(chatsCtx, chat) } } } suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { - openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report) + val reportsChatsCtx = ChatModel.ChatsContext(contentTag = MsgContentTag.Report) + openChat(secondaryChatsCtx = reportsChatsCtx, chatModel.remoteHostId(), chatInfo) ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> 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(staleChatId, scrollToItemId) + GroupReportsView(reportsChatsCtx, staleChatId, scrollToItemId) } else { LaunchedEffect(Unit) { close() @@ -86,6 +85,6 @@ suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: Mu } } -private suspend fun reloadItems(chat: Chat, contentTag: MsgContentTag?) { - apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +private suspend fun reloadItems(chatsCtx: ChatModel.ChatsContext, chat: Chat) { + apiLoadMessages(chatsCtx, chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 6a57912296..c743d78d1c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -12,12 +12,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull -import chat.simplex.common.platform.chatModel import chat.simplex.common.platform.onRightClick -import chat.simplex.common.views.chat.group.LocalContentTag @Composable fun CIChatFeatureView( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, chatItem: ChatItem, feature: Feature, @@ -26,7 +25,7 @@ fun CIChatFeatureView( revealed: State, showMenu: MutableState, ) { - val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList() + val merged = if (!revealed.value) mergedFeatures(chatsCtx, chatItem, chatInfo) else emptyList() Box( Modifier .combinedClickable( @@ -73,11 +72,9 @@ private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): Feat ) @Composable -private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List? { - val m = ChatModel +private fun mergedFeatures(chatsCtx: ChatModel.ChatsContext, chatItem: ChatItem, chatInfo: ChatInfo): List? { val fs: ArrayList = arrayListOf() val icons: MutableSet = mutableSetOf() - val chatsCtx = if (LocalContentTag.current == null) m.chatsContext else m.secondaryChatsContext val reversedChatItems = chatsCtx.chatItems.value.asReversed() var i = getChatItemIndexOrNull(chatItem, reversedChatItems) if (i != null) { 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 d08fa574ef..5bbbc33741 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 @@ -29,7 +29,6 @@ import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -63,6 +62,7 @@ data class ChatItemReactionMenuItem ( @Composable fun ChatItemView( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem, @@ -277,7 +277,7 @@ fun ChatItemView( if (searchIsNotBlank.value) { GoToItemInnerButton(alignStart, MR.images.ic_search, 17.dp, parentActivated) { withBGApi { - openChat(rhId, cInfo.chatType, cInfo.apiId, null, cItem.id) + openChat(secondaryChatsCtx = null, rhId, cInfo.chatType, cInfo.apiId, cItem.id) closeReportsIfNeeded() } } @@ -285,7 +285,7 @@ fun ChatItemView( GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, 22.dp, parentActivated) { val (chatType, apiId, msgId) = chatTypeApiIdMsgId withBGApi { - openChat(rhId, chatType, apiId, null, msgId) + openChat(secondaryChatsCtx = null, rhId, chatType, apiId, msgId) closeReportsIfNeeded() } } @@ -364,7 +364,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -382,7 +382,7 @@ fun ChatItemView( if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() SelectItemAction(showMenu, selectChatItem) } @@ -472,7 +472,7 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } if (cItem.chatDir !is CIDirection.GroupSnd) { val groupInfo = cItem.memberToModerate(cInfo)?.first @@ -498,7 +498,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -508,7 +508,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -522,7 +522,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -531,7 +531,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -548,7 +548,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -560,7 +560,7 @@ fun ChatItemView( fun ContentItem() { val mc = cItem.content.msgContent if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -582,7 +582,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -631,14 +631,13 @@ fun ChatItemView( } @Composable fun EventItemView() { - val chatsCtx = if (LocalContentTag.current == null) chatModel.chatsContext else chatModel.secondaryChatsContext val reversedChatItems = chatsCtx.chatItems.value.asReversed() CIEventView(eventItemViewText(reversedChatItems)) } @Composable fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { if (revealed.value) { HideItemAction(revealed, showMenu, reveal) @@ -648,7 +647,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -729,11 +728,11 @@ fun ChatItemView( MsgContentItemDropdownMenu() } is CIContent.RcvChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatPreference -> { @@ -742,23 +741,23 @@ fun ChatItemView( DeleteItemMenu() } is CIContent.SndChatPreference -> { - CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndModerated -> DeletedItem() @@ -830,6 +829,7 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( + chatsCtx: ChatModel.ChatsContext, cItem: ChatItem, revealed: State, showMenu: MutableState, @@ -838,8 +838,6 @@ fun DeleteItemAction( deleteMessages: (List) -> Unit, buttonText: String = stringResource(MR.strings.delete_verb), ) { - val contentTag = LocalContentTag.current - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext ItemAction( buttonText, painterResource(MR.images.ic_delete), @@ -1424,6 +1422,7 @@ fun PreviewChatItemView( chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello") ) { ChatItemView( + chatsCtx = ChatModel.ChatsContext(contentTag = null), rhId = null, ChatInfo.Direct.sampleData, chatItem, @@ -1473,6 +1472,7 @@ fun PreviewChatItemView( fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( + chatsCtx = ChatModel.ChatsContext(contentTag = null), rhId = null, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index af9df2cb9a..84bc14fee3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -12,17 +12,15 @@ import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.chatModel import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { +fun MarkedDeletedItemView(chatsCtx: ChatModel.ChatsContext, ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( @@ -35,7 +33,7 @@ fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: In verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - MergedMarkedDeletedText(ci, chatInfo, revealed) + MergedMarkedDeletedText(chatsCtx, ci, chatInfo, revealed) } CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -43,8 +41,7 @@ fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: In } @Composable -private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { - val chatsCtx = if (LocalContentTag.current == null) chatModel.chatsContext else chatModel.secondaryChatsContext +private fun MergedMarkedDeletedText(chatsCtx: ChatModel.ChatsContext, chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { val reversedChatItems = chatsCtx.chatItems.value.asReversed() var i = getChatItemIndexOrNull(chatItem, reversedChatItems) val ciCategory = chatItem.mergeCategory 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 e8350e606a..958b794bd7 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 @@ -189,7 +189,7 @@ fun ErrorChatListItem() { suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { contact.activeConn == null && contact.profile.contactLink != null && contact.active -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) - else -> openChat(rhId, ChatInfo.Direct(contact)) + else -> openDirectChat(rhId, contact.contactId) } } @@ -197,30 +197,33 @@ suspend fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId) - else -> openChat(rhId, ChatInfo.Group(groupInfo)) + else -> openGroupChat(rhId, groupInfo.groupId) } } -suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat(rhId, ChatInfo.Local(noteFolder)) +suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat(secondaryChatsCtx = null, rhId, ChatInfo.Local(noteFolder)) -suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId) +suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(secondaryChatsCtx = null, rhId, ChatType.Direct, contactId) -suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? = null) = openChat(rhId, ChatType.Group, groupId, contentTag) +suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(secondaryChatsCtx = null, rhId, ChatType.Group, groupId) -suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag) +suspend fun openChat(secondaryChatsCtx: ChatModel.ChatsContext?, rhId: Long?, chatInfo: ChatInfo) = openChat(secondaryChatsCtx, rhId, chatInfo.chatType, chatInfo.apiId) suspend fun openChat( + secondaryChatsCtx: ChatModel.ChatsContext?, rhId: Long?, chatType: ChatType, apiId: Long, - contentTag: MsgContentTag? = null, openAroundItemId: Long? = null -) = +) { + if (secondaryChatsCtx != null) { + chatModel.secondaryChatsContext.value = secondaryChatsCtx + } apiLoadMessages( + chatsCtx = secondaryChatsCtx ?: chatModel.chatsContext, rhId, chatType, apiId, - contentTag, if (openAroundItemId != null) { ChatPagination.Around(openAroundItemId, ChatPagination.INITIAL_COUNT) } else { @@ -229,23 +232,22 @@ suspend fun openChat( "", openAroundItemId ) +} -suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { +suspend fun openLoadedChat(chat: Chat) { withContext(Dispatchers.Main) { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext - chatsCtx.chatItemStatuses.clear() - chatsCtx.chatItems.replaceAll(chat.chatItems) + chatModel.chatsContext.chatItemStatuses.clear() + chatModel.chatsContext.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id - chatsCtx.chatState.clear() + chatModel.chatsContext.chatState.clear() } } -suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) { +suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, search: String) { withContext(Dispatchers.Main) { - val chatsCtx = if (contentTag == null) chatModel.chatsContext else chatModel.secondaryChatsContext chatsCtx.chatItems.clearAndNotify() } - apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) + apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { @@ -608,9 +610,7 @@ fun markChatRead(c: Chat) { chatModel.chatsContext.markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) - } + chatModel.secondaryChatsContext.value?.markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } chatModel.controller.apiChatRead( chat.remoteHostId, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 6ea7e9fc02..4e65a3649e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -55,13 +55,13 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State, showDel when (contactType) { ContactType.RECENT -> { withApi { - openChat(rhId, chat.chatInfo) + openChat(secondaryChatsCtx = null, rhId, chat.chatInfo) ModalManager.start.closeModals() } } ContactType.CHAT_DELETED -> { withApi { - openChat(rhId, chat.chatInfo) + openChat(secondaryChatsCtx = null, rhId, chat.chatInfo) ModalManager.start.closeModals() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index a2fcae9d7d..4a911fa6f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -543,11 +543,9 @@ fun deleteChatDatabaseFilesAndState() { chatModel.chatsContext.popChatCollector.clear() } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.chatItems.clearAndNotify() - chatModel.secondaryChatsContext.chats.clear() - chatModel.secondaryChatsContext.popChatCollector.clear() - } + chatModel.secondaryChatsContext.value?.chatItems?.clearAndNotify() + chatModel.secondaryChatsContext.value?.chats?.clear() + chatModel.secondaryChatsContext.value?.popChatCollector?.clear() } } chatModel.users.clear() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 4848e791e1..af207d1381 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -161,13 +161,20 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun closeModal() { if (modalViews.isNotEmpty()) { - if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex) - else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } + val lastModal = modalViews.lastOrNull() + if (lastModal != null) { + if (lastModal.id == ModalViewId.SECONDARY_CHAT) chatModel.secondaryChatsContext.value = null + if (!lastModal.animated) + modalViews.removeAt(modalViews.lastIndex) + else + runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } + } } _modalCount.value = modalViews.size - toRemove.size } fun closeModals() { + chatModel.secondaryChatsContext.value = null modalViews.clear() toRemove.clear() _modalCount.value = 0 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 1d0a873c7d..dfffb826f5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -61,9 +61,7 @@ fun showApp() { chatModel.chatsContext.chatItems.clearAndNotify() } withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { - chatModel.secondaryChatsContext.chatItems.clearAndNotify() - } + chatModel.secondaryChatsContext.value = null } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index e295144191..9fd65ec995 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -51,7 +51,7 @@ private fun ActiveCallInteractiveAreaOneHand(call: Call, showMenu: MutableState< val chat = chatModel.getChat(call.contact.id) if (chat != null) { withBGApi { - openChat(chat.remoteHostId, chat.chatInfo) + openChat(secondaryChatsCtx = null, chat.remoteHostId, chat.chatInfo) } } }, @@ -116,7 +116,7 @@ private fun ActiveCallInteractiveAreaNonOneHand(call: Call, showMenu: MutableSta val chat = chatModel.getChat(call.contact.id) if (chat != null) { withBGApi { - openChat(chat.remoteHostId, chat.chatInfo) + openChat(secondaryChatsCtx = null, chat.remoteHostId, chat.chatInfo) } } }, From 82f9fecccf6e970d7765f25dfb4916e9c91bb4b2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 16 Apr 2025 19:21:42 +0100 Subject: [PATCH 178/289] =?UTF-8?q?android,=20desktop:=20enable=20reaction?= =?UTF-8?q?s=20=F0=9F=98=82=20and=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 8 +++++--- .../chat/simplex/common/views/chat/item/ChatItemView.kt | 4 ++-- 2 files changed, 7 insertions(+), 5 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 7e5eed3c42..47d9563ee3 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 @@ -3336,14 +3336,16 @@ sealed class MsgReaction { } companion object { - val values: List get() = MREmojiChar.values().map(::Emoji) - val old: List get() = listOf( + val values: List get() = MREmojiChar.entries.map(::Emoji) + val supported: List get() = listOf( MREmojiChar.ThumbsUp, MREmojiChar.ThumbsDown, MREmojiChar.Smile, + MREmojiChar.Laugh, MREmojiChar.Sad, MREmojiChar.Heart, - MREmojiChar.Launch + MREmojiChar.Launch, + MREmojiChar.Check ).map(::Emoji) } } 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..2e789df7bc 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 @@ -337,7 +337,7 @@ fun ChatItemView( @Composable fun MsgReactionsMenu() { - val rs = MsgReaction.old.mapNotNull { r -> + val rs = MsgReaction.supported.mapNotNull { r -> if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { r } else { @@ -348,7 +348,7 @@ fun ChatItemView( Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { rs.forEach() { r -> Box( - Modifier.size(36.dp).clickable { + Modifier.size(36.dp).clip(CircleShape).clickable { setReaction(cInfo, cItem, true, r) showMenu.value = false }, 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 179/289] 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 180/289] 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 181/289] 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 b637d370f3aa9a010f32d1ac723412a8df500d3e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 23 Apr 2025 08:33:17 +0100 Subject: [PATCH 182/289] core: 6.3.3.0 (simplexmq 6.4.0.1) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 1bf1289900..28403c9fff 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: 305f79d2a66a8d122bf457e023988200bb7fe00c + tag: ec5a60430d6e2b1b4f33fa1790effbb6060bf7b8 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index e2df71ae0f..e71a13b22a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."305f79d2a66a8d122bf457e023988200bb7fe00c" = "1lawc5pf4hgc6wym2xz8gi92izi1vk98ppv3ldrpajz1mq62ifpc"; + "https://github.com/simplex-chat/simplexmq.git"."ec5a60430d6e2b1b4f33fa1790effbb6060bf7b8" = "0q238j7w976f9nx7r3gd61yhj557zwcxvrbci5lq7fib0v4ja7aw"; "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 3eb75d5cf6..a01345a910 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.2.0 +version: 6.3.3.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 a7f44eb465..8c87851af7 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, 0, 8] +minRemoteCtrlVersion = AppVersion [6, 3, 3, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 8] +minRemoteHostVersion = AppVersion [6, 3, 3, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version 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 183/289] 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 a6fd5ce9020006d11fdfbbb718515d6ec2ccb55b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 23 Apr 2025 10:30:37 +0100 Subject: [PATCH 184/289] website: translations (#5845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Hebrew) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (German) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (258 of 258 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% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (German) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ --------- Co-authored-by: מילקי צבעוני Co-authored-by: mlanp Co-authored-by: Ghost of Sparta Co-authored-by: summoner001 --- website/langs/de.json | 2 +- website/langs/he.json | 8 +++++--- website/langs/hu.json | 42 +++++++++++++++++++++--------------------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/website/langs/de.json b/website/langs/de.json index 3b1e9d34e8..00c34478e7 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -54,7 +54,7 @@ "simplex-private-6-title": "Out-of-Band-
Schlüsselaustausch", "simplex-private-9-title": "Unidirektionale
Nachrichten-Warteschlangen", "simplex-private-10-title": "Temporäre, anonyme paarweise Kennungen", - "simplex-private-card-1-point-1": "Double-Ratchet-Protokoll —
Off-the-Record-Nachrichten mit Perfect Forward Secrecy und Einbruchsresistenz.", + "simplex-private-card-1-point-1": "Double-Ratchet-Protokoll —
Off-the-Record-Nachrichten mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung.", "simplex-private-card-1-point-2": "NaCL-Kryptobox in jeder Warteschlange, um eine Korrelation des Datenverkehrs zwischen Nachrichtenwarteschlangen zu verhindern, falls TLS kompromittiert wurde.", "simplex-private-card-3-point-1": "Für Client-Server-Verbindungen wird nur TLS 1.2/1.3 mit starken Algorithmen verwendet.", "simplex-private-card-2-point-1": "Zusätzliche Server-Verschlüsselungs-Schicht für die Zustellung an den Empfänger, um eine Korrelation zwischen empfangenen und gesendeten Server-Daten zu vermeiden, falls TLS kompromittiert wurde.", diff --git a/website/langs/he.json b/website/langs/he.json index 4fd966f05d..878f0e51b2 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -75,12 +75,12 @@ "privacy-matters-2-overlay-1-linkText": "פרטיות מעניקה לכם עוצמה", "privacy-matters-3-overlay-1-linkText": "פרטיות מגנה על החופש שלכם", "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות.", - "hero-overlay-3-title": "הערכת אבטחה", + "hero-overlay-3-title": "הערכות אבטחה", "simplex-unique-1-overlay-1-title": "פרטיות מלאה של הזהות, הפרופיל, אנשי הקשר והמטא נתונים שלך", "simplex-unique-3-overlay-1-title": "בעלות, שליטה ואבטחה של הנתונים שלך", "simplex-unique-2-overlay-1-title": "ההגנה הטובה ביותר מפני ספאם וניצול לרעה", "simplex-unique-3-title": "אתה שולט בנתונים שלך", - "hero-overlay-3-textlink": "הערכת אבטחה", + "hero-overlay-3-textlink": "הערכות אבטחה", "simplex-unique-4-overlay-1-title": "מבוזר לחלוטין — המשתמשים הם הבעלים של רשת SimpleX", "simplex-unique-2-title": "אתה מוגן
מפני ספאם וניצול לרעה", "simplex-unique-4-title": "רשת SimpleX בבעלותך", @@ -254,5 +254,7 @@ "please-use-link-in-mobile-app": "אנא השתמש בקישור באפליקציה במכשיר נייד", "docs-dropdown-10": "שקיפות", "docs-dropdown-11": "שאלות ותשובות", - "docs-dropdown-12": "אבטחה" + "docs-dropdown-12": "אבטחה", + "hero-overlay-card-3-p-3": "Trail of bits סקר את הקוד הקריפטוגרפי של פרוטוקולי רשת SimpleX ביולי 2024. קרא עוד.", + "docs-dropdown-14": "SimpleX לעסקים" } diff --git a/website/langs/hu.json b/website/langs/hu.json index 46ad9668cb..60eeeafe13 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -1,7 +1,7 @@ { "home": "Kezdőoldal", "developers": "Fejlesztők", - "reference": "Referencia", + "reference": "Hivatkozás", "blog": "Blog", "features": "Funkciók", "why-simplex": "Miért válassza a SimpleXet", @@ -18,15 +18,15 @@ "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.", "smp-protocol": "SMP-protokoll", - "chat-protocol": "Csevegésprotokoll", - "donate": "Támogatás", + "chat-protocol": "Csevegési protokoll", + "donate": "Adományozás", "copyright-label": "© 2020-2025 SimpleX | Nyílt forráskódú projekt", "simplex-chat-protocol": "A SimpleX Chat-protokoll", "terminal-cli": "Terminál CLI", "terms-and-privacy-policy": "Adatvédelmi irányelvek", "hero-header": "Újradefiniált adatvédelem", "hero-subheader": "Az első üzenetváltó-alkalmazás
felhasználói azonosítók nélkül", - "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", + "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX azonban nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", "hero-overlay-1-textlink": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "hero-overlay-2-textlink": "Hogyan működik a SimpleX?", "hero-overlay-3-textlink": "Biztonsági felmérések", @@ -43,7 +43,7 @@ "feature-6-title": "E2E-titkosított
hang- és videohí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-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", @@ -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 TORon 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-on vagy más átvitel-átfedő-hálózaton keresztül is elérheti.", "simplex-private-card-6-point-1": "Számos kommunikációs platform 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.", @@ -79,20 +79,20 @@ "privacy-matters-3-overlay-1-linkText": "Az adatvédelem szabaddá tesz", "simplex-unique-1-title": "Teljes magánéletet élvezhet", "simplex-unique-1-overlay-1-title": "Személyazonosságának, profiljának, kapcsolatainak és metaadatainak teljes körű védelme", - "simplex-unique-2-title": "Véd
a kéretlen üzenetektől és a visszaélésektől", - "simplex-unique-2-overlay-1-title": "A legjobb védelem a kéretlen üzenetek és a visszaélések ellen", + "simplex-unique-2-title": "Véd
a kéretlen tartalmaktól és a visszaélésektől", + "simplex-unique-2-overlay-1-title": "A legjobb védelem a kéretlen tartalmak és a visszaélések ellen", "simplex-unique-3-title": "Ön kezeli az adatait", "simplex-unique-3-overlay-1-title": "Az adatok biztonsága és kezelése az Ön kezében van", "simplex-unique-4-title": "Öné a SimpleX-hálózat", "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 platform á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.", "hero-overlay-card-2-p-2": "Ezt az információt aztán összefüggésbe hozhatják a meglévő nyilvános közösségi hálózatokkal, és meghatározhatnak néhány valódi személyazonosságot.", - "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-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.", @@ -116,7 +116,7 @@ "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. A SimpleX használata olyan, mintha minden egyes kapcsolatnak más-más “eldobható” e-mail-címe vagy telefonja lenne és nem kell ezeket gondosan kezelni.", "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-platform 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-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, 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 nem kötelező felhasználói cím esetében is, bár spam kapcsolatfelvételi kérések küldésére használható, megváltoztathatja vagy teljesen törölheti azt anélkül, hogy elveszítené a meglévő kapcsolatait.", + "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.", "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", @@ -129,7 +129,7 @@ "simplex-unique-card-2-p-1": "Mivel a SimpleX-platformon nincs azonosítója vagy állandó címe, 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-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.", "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", - "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", + "simplex-unique-card-4-p-1": "A SimpleX-hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", "simplex-unique-card-4-p-2": "Használhatja a SimpleXet a saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", "join": "Csatlakozzon a közösségeinkhez", "we-invite-you-to-join-the-conversation": "Meghívjuk Önt, hogy csatlakozzon a beszélgetésekhez", @@ -168,17 +168,17 @@ "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-label": "Győződjön meg arról, hogy az üzenetváltó-alkalmazás amit használ nem fér hozzá az adataidhoz!", "simplex-private-section-header": "Mitől lesz a SimpleX privát", - "simplex-network-section-header": "SimpleX hálózat", - "simplex-network-section-desc": "A Simplex Chat a P2P és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", + "simplex-network-section-header": "SimpleX-hálózat", + "simplex-network-section-desc": "A Simplex Chat a P2P- és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", "simplex-network-2-desc": "A SimpleX továbbítókiszolgálói NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM kapcsolódnak egymáshoz, és NINCS kiszolgálókönyvtár.", - "simplex-network-3-header": "SimpleX hálózat", + "simplex-network-3-header": "SimpleX-hálózat", "simplex-network-3-desc": "a kiszolgálók egyirányú üzenet várakoztatásokat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", "comparison-section-header": "Összehasonlítás más protokollokkal", "protocol-1-text": "Signal, nagy platformok", "protocol-2-text": "XMPP, Matrix", - "protocol-3-text": "P2P protokollok", + "protocol-3-text": "P2P-protokollok", "comparison-point-1-text": "Globális személyazonosságot igényel", "comparison-point-2-text": "MITM lehetősége", "comparison-point-4-text": "Egyetlen vagy központosított hálózat", @@ -195,7 +195,7 @@ "comparison-section-list-point-4a": "A SimpleX továbbítókiszolgálói nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", "comparison-section-list-point-4": "Ha az üzemeltetett kiszolgálók veszélybe kerülnek. Hitelesítse a biztonsági kódot a Signal vagy más biztonságos üzenetküldő alkalmazás segítségével a támadások veszélyeinek csökkentésére", "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", - "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált - egyetlen hálózatként működnek", + "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált – egyetlen hálózatként működnek", "comparison-section-list-point-7": "A P2P-hálózatoknak vagy van egy központi hitelesítője, vagy az egész hálózat kompromittálódhat", "see-here": "tekintse meg itt", "guide-dropdown-1": "Gyors indítás", @@ -224,18 +224,18 @@ "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, pl. WebSocketsen keresztül is működik.", - "simplex-private-card-4-point-2": "A SimpleX TORon 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-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-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.", "simplex-network-overlay-card-1-p-1": "A P2P üzenetküldő protokollok és alkalmazások számos problémával küzdenek, amelyek miatt kevésbé megbízhatóak, mint a SimpleX, bonyolultabb az elemzésük és többféle támadással szemben sebezhetőek.", - "chat-bot-example": "Chat bot példa", + "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", - "simplex-network-1-header": "A P2P hálózatokkal ellentétben", - "simplex-network-1-overlay-linktext": "a P2P hálózat problémái", + "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", "yes": "Igen", "guide-dropdown-9": "Kapcsolatok létrehozása", From 52e2af6e3250d267c1c8aa00505bc13606305f00 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 23 Apr 2025 10:53:55 +0100 Subject: [PATCH 185/289] ui: translations (#5843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Italian) Currently translated at 100.0% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Hebrew) Currently translated at 81.9% (1923 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2346 of 2346 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% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hebrew) Currently translated at 82.6% (1938 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Hebrew) Currently translated at 85.8% (2013 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (German) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (German) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Hebrew) Currently translated at 86.5% (2031 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Hebrew) Currently translated at 86.7% (2034 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Hebrew) Currently translated at 86.7% (2035 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2346 of 2346 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% (2346 of 2346 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% (2051 of 2051 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% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Czech) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Russian) Currently translated at 97.4% (1998 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (2346 of 2346 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% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Hebrew) Currently translated at 86.7% (2035 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2346 of 2346 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (French) Currently translated at 96.0% (1971 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (German) Currently translated at 100.0% (2352 of 2352 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% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Vietnamese) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (German) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2352 of 2352 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% (2352 of 2352 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% (2352 of 2352 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% (2051 of 2051 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% (2051 of 2051 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% (2352 of 2352 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% (2051 of 2051 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * process localizations * update ru translations * correct ru case * export --------- Co-authored-by: Random Co-authored-by: מילקי צבעוני Co-authored-by: summoner001 Co-authored-by: mlanp Co-authored-by: gacarel Co-authored-by: Bezruchenko Simon Co-authored-by: zenobit Co-authored-by: thedmdim Co-authored-by: Rafi Co-authored-by: khalidbelk Co-authored-by: 大王叫我来巡山 Co-authored-by: jonnysemon Co-authored-by: No name Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: fran secs Co-authored-by: M1K4 Co-authored-by: Ghost of Sparta --- .../bg.xcloc/Localized Contents/bg.xliff | 20 ++ .../cs.xcloc/Localized Contents/cs.xliff | 20 ++ .../de.xcloc/Localized Contents/de.xliff | 31 ++- .../en.xcloc/Localized Contents/en.xliff | 25 ++ .../es.xcloc/Localized Contents/es.xliff | 25 +- .../fi.xcloc/Localized Contents/fi.xliff | 20 ++ .../fr.xcloc/Localized Contents/fr.xliff | 79 ++++++ .../hu.xcloc/Localized Contents/hu.xliff | 79 +++--- .../it.xcloc/Localized Contents/it.xliff | 23 +- .../ja.xcloc/Localized Contents/ja.xliff | 20 ++ .../nl.xcloc/Localized Contents/nl.xliff | 27 ++ .../pl.xcloc/Localized Contents/pl.xliff | 20 ++ .../ru.xcloc/Localized Contents/ru.xliff | 37 +++ .../th.xcloc/Localized Contents/th.xliff | 20 ++ .../tr.xcloc/Localized Contents/tr.xliff | 20 ++ .../uk.xcloc/Localized Contents/uk.xliff | 20 ++ .../Localized Contents/zh-Hans.xliff | 20 ++ apps/ios/de.lproj/Localizable.strings | 13 +- apps/ios/es.lproj/Localizable.strings | 7 +- apps/ios/fr.lproj/Localizable.strings | 165 +++++++++++++ apps/ios/hu.lproj/Localizable.strings | 61 ++--- apps/ios/it.lproj/Localizable.strings | 5 +- apps/ios/nl.lproj/Localizable.strings | 15 ++ apps/ios/ru.lproj/Localizable.strings | 48 ++++ .../commonMain/resources/MR/ar/strings.xml | 6 + .../commonMain/resources/MR/ca/strings.xml | 6 + .../commonMain/resources/MR/cs/strings.xml | 11 +- .../commonMain/resources/MR/de/strings.xml | 16 +- .../commonMain/resources/MR/es/strings.xml | 6 + .../commonMain/resources/MR/hu/strings.xml | 60 +++-- .../commonMain/resources/MR/in/strings.xml | 14 +- .../commonMain/resources/MR/it/strings.xml | 8 +- .../commonMain/resources/MR/iw/strings.xml | 231 +++++++++++++++--- .../commonMain/resources/MR/nl/strings.xml | 13 +- .../commonMain/resources/MR/uk/strings.xml | 25 +- .../commonMain/resources/MR/vi/strings.xml | 8 +- .../resources/MR/zh-rCN/strings.xml | 6 + 37 files changed, 1080 insertions(+), 150 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 6fb6729a9b..213394aa14 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -6800,6 +6800,10 @@ chat item action Сподели с контактите No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Покажи QR код @@ -6893,6 +6897,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX адрес за контакт @@ -7447,6 +7455,10 @@ It can happen because of some bug or when the connection is compromised.Това е вашят еднократен линк за връзка! 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. No comment provided by engineer. @@ -7729,6 +7741,10 @@ To connect, please ask your contact to create another connection link and check Непрочетено swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. На новите членове се изпращат до последните 100 съобщения. @@ -7876,6 +7892,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Използвайте приложението по време на разговора. 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 5fd894b226..826ad0171b 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -6558,6 +6558,10 @@ chat item action Sdílet s kontakty No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6650,6 +6654,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX kontaktní adresa @@ -7187,6 +7195,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován This is your own one-time link! 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. No comment provided by engineer. @@ -7460,6 +7472,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Nepřečtený swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -7602,6 +7618,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 1603a67df1..facf6d5a9e 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -2920,6 +2920,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Für einen besseren Metadatenschutz Flux in den Netzwerk- und Servereinstellungen aktivieren. No comment provided by engineer. @@ -4941,12 +4942,12 @@ Das ist Ihr Link für die Gruppe %@! Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery. - Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt. + Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt. 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. - Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt. + Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt. No comment provided by engineer. @@ -5311,7 +5312,7 @@ Das ist Ihr Link für die Gruppe %@! No received or sent files - Keine herunter- oder hochgeladene Dateien + Keine herunter- oder hochgeladenen Dateien No comment provided by engineer. @@ -5900,7 +5901,7 @@ Fehler: %@ Privacy policy and conditions of use. - Datenschutzbestimmungen und Nutzungsbedingungen. + Datenschutz- und Nutzungsbedingungen. No comment provided by engineer. @@ -7198,6 +7199,10 @@ chat item action Mit Kontakten teilen No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code QR-Code anzeigen @@ -7298,6 +7303,10 @@ chat item action SimpleX-Adresse oder Einmal-Link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX-Kontaktadressen-Link @@ -7830,7 +7839,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Es werden alle herunter- und hochgeladene Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! + Es werden alle herunter- und hochgeladenen Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. @@ -7888,6 +7897,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Das ist Ihr eigener Einmal-Link! 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. Dieser Link wurde schon mit einem anderen Mobiltelefon genutzt. Bitte erstellen sie einen neuen Link in der Desktop-App. @@ -8187,6 +8200,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Ungelesen swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Bis zu 100 der letzten Nachrichten werden an neue Mitglieder gesendet. @@ -8347,6 +8364,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verwende Server No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Die App kann während eines Anrufs genutzt werden. 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 8d040ff99a..9eb60c2cbc 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -7199,6 +7199,11 @@ chat item action Share with contacts No comment provided by engineer. + + Short link + Short link + No comment provided by engineer. + Show QR code Show QR code @@ -7299,6 +7304,11 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + SimpleX channel link + simplex link type + SimpleX contact address SimpleX contact address @@ -7889,6 +7899,11 @@ It can happen because of some bug or when the connection is compromised.This is your own one-time link! 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. + 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. This link was used with another mobile device, please create a new link on the desktop. @@ -8188,6 +8203,11 @@ To connect, please ask your contact to create another connection link and check Unread swipe action + + Unsupported connection link + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Up to 100 last messages are sent to new members. @@ -8348,6 +8368,11 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Use the app while in the call. 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 4f33d923af..5250aa0de7 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -466,7 +466,7 @@ time interval 1 year - Un año + 1 año delete after time @@ -873,7 +873,7 @@ swipe action Allow to report messsages to moderators. - Se permite informar de mensajes a los moderadores. + Permitir informar de mensajes a los moderadores. No comment provided by engineer. @@ -2920,6 +2920,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Habilitar Flux en la configuración de Red y servidores para mejorar la privacidad de los metadatos. No comment provided by engineer. @@ -7198,6 +7199,10 @@ chat item action Compartir con contactos No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Mostrar código QR @@ -7298,6 +7303,10 @@ chat item action ¿Dirección SimpleX o enlace de un uso? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Dirección de contacto SimpleX @@ -7888,6 +7897,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. ¡Este es tu propio enlace de un solo uso! 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. Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador. @@ -8187,6 +8200,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión No leído swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Hasta 100 últimos mensajes son enviados a los miembros nuevos. @@ -8347,6 +8364,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Usar servidores No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Usar la aplicación durante la llamada. 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 c5393e97a1..8dc4250e20 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -6537,6 +6537,10 @@ chat item action Jaa kontaktien kanssa No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6629,6 +6633,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX-yhteystiedot @@ -7165,6 +7173,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.
This is your own one-time link! 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. No comment provided by engineer. @@ -7437,6 +7449,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Lukematon swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -7579,6 +7595,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 823491dc72..304b9e2084 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -192,6 +192,7 @@ %d seconds(s) + %d seconde(s) delete after time @@ -465,6 +466,7 @@ time interval 1 year + 1 an delete after time @@ -601,6 +603,7 @@ swipe action Active + Actif token status text @@ -620,6 +623,7 @@ swipe action Add list + Ajouter une liste No comment provided by engineer. @@ -649,6 +653,7 @@ swipe action Add to list + Ajouter à la liste No comment provided by engineer. @@ -728,6 +733,7 @@ swipe action All + Tout No comment provided by engineer. @@ -742,6 +748,7 @@ swipe action All chats will be removed from the list %@, and the list deleted. + Tous les chats seront supprimés de la liste %@, et la liste sera supprimée. alert message @@ -786,6 +793,7 @@ swipe action All reports will be archived for you. + Tous les rapports seront archivés pour vous. No comment provided by engineer. @@ -865,6 +873,7 @@ swipe action Allow to report messsages to moderators. + Permettre de signaler des messages aux modérateurs. No comment provided by engineer. @@ -949,6 +958,7 @@ swipe action Another reason + Autre raison report reason @@ -1027,14 +1037,17 @@ swipe action Archive + Archiver No comment provided by engineer. Archive %lld reports? + Archiver les rapports %lld ? No comment provided by engineer. Archive all reports? + Archiver tous les rapports ? No comment provided by engineer. @@ -1049,14 +1062,17 @@ swipe action Archive report + Archiver le rapport No comment provided by engineer. Archive report? + Archiver le rapport ? No comment provided by engineer. Archive reports + Archiver les rapports swipe action @@ -1071,6 +1087,7 @@ swipe action Ask + Demander No comment provided by engineer. @@ -1175,6 +1192,7 @@ swipe action Better groups performance + Meilleure performance des groupes No comment provided by engineer. @@ -1199,6 +1217,7 @@ swipe action Better privacy and security + Meilleure protection de la privacité et de la sécurité No comment provided by engineer. @@ -1303,6 +1322,7 @@ swipe action Businesses + Entreprises No comment provided by engineer. @@ -1314,6 +1334,9 @@ swipe action By using SimpleX Chat you agree to: - send only legal content in public groups. - respect other users – no spam. + En utilisant SimpleX Chat, vous acceptez de : +- n'envoyer que du contenu légal dans les groupes publics. +- respecter les autres utilisateurs - pas de spam. No comment provided by engineer. @@ -1404,6 +1427,7 @@ alert button Change automatic message deletion? + Modifier la suppression automatique des messages ? alert title @@ -1629,10 +1653,12 @@ set passcode view Clear group? + Vider le groupe ? No comment provided by engineer. Clear or delete group? + Vider ou supprimer le groupe ? No comment provided by engineer. @@ -1657,6 +1683,7 @@ set passcode view Community guidelines violation + Infraction aux règles communautaires report reason @@ -1716,6 +1743,7 @@ set passcode view Configure server operators + Configurer les opérateurs de serveur No comment provided by engineer. @@ -1770,6 +1798,7 @@ set passcode view Confirmed + Confirmé token status text @@ -1893,6 +1922,7 @@ Il s'agit de votre propre lien unique ! Connection blocked + Connexion bloquée No comment provided by engineer. @@ -1908,10 +1938,13 @@ Il s'agit de votre propre lien unique ! Connection is blocked by server operator: %@ + La connexion est bloquée par l'opérateur du serveur : +%@ No comment provided by engineer. Connection not ready. + La connexion n'est pas prête. No comment provided by engineer. @@ -1926,6 +1959,7 @@ Il s'agit de votre propre lien unique ! Connection requires encryption renegotiation. + La connexion nécessite une renégociation du cryptage. No comment provided by engineer. @@ -2010,6 +2044,7 @@ Il s'agit de votre propre lien unique ! Content violates conditions of use + Le contenu enfreint les conditions d'utilisation blocking reason @@ -2089,6 +2124,7 @@ Il s'agit de votre propre lien unique ! Create list + Créer une liste No comment provided by engineer. @@ -2352,6 +2388,7 @@ swipe action Delete chat messages from your device. + Supprimer les messages de chat de votre appareil. No comment provided by engineer. @@ -2446,6 +2483,7 @@ swipe action Delete list? + Supprimer la liste ? alert title @@ -2500,6 +2538,7 @@ swipe action Delete report + Supprimer le rapport No comment provided by engineer. @@ -2664,10 +2703,12 @@ swipe action Disable automatic message deletion? + Désactiver la suppression automatique des messages ? alert title Disable delete messages + Désactiver la suppression des messages alert button @@ -2762,6 +2803,7 @@ swipe action Documents: + Documents: No comment provided by engineer. @@ -2776,6 +2818,7 @@ swipe action Don't miss important messages. + Ne manquez pas les messages importants. No comment provided by engineer. @@ -2785,6 +2828,7 @@ swipe action Done + Terminé No comment provided by engineer. @@ -2875,6 +2919,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Activez Flux dans les paramètres du réseau et des serveurs pour une meilleure confidentialité des métadonnées. No comment provided by engineer. @@ -3019,6 +3064,7 @@ chat item action Encryption renegotiation in progress. + Renégociation du chiffrement en cours. No comment provided by engineer. @@ -3133,6 +3179,7 @@ chat item action Error checking token status + Erreur lors de la vérification de l'état du jeton (token) No comment provided by engineer. @@ -3157,6 +3204,7 @@ chat item action Error creating list + Erreur lors de la création de la liste alert title @@ -3176,6 +3224,7 @@ chat item action Error creating report + Erreur lors de la création du rapport No comment provided by engineer. @@ -3290,6 +3339,7 @@ chat item action Error registering for notifications + Erreur lors de l'inscription aux notifications alert title @@ -3299,6 +3349,7 @@ chat item action Error reordering lists + Erreur lors de la réorganisation des listes alert title @@ -3313,6 +3364,7 @@ chat item action Error saving chat list + Erreur lors de l'enregistrement de la liste des chats alert title @@ -3397,6 +3449,7 @@ chat item action Error testing server connection + Erreur lors du test de connexion au serveur No comment provided by engineer. @@ -3483,6 +3536,7 @@ snd error text Expired + Expiré token status text @@ -3527,6 +3581,7 @@ snd error text Faster deletion of groups. + Suppression plus rapide des groupes. No comment provided by engineer. @@ -3536,6 +3591,7 @@ snd error text Faster sending messages. + Envoi plus rapide des messages. No comment provided by engineer. @@ -3545,6 +3601,7 @@ snd error text Favorites + Favoris No comment provided by engineer. @@ -3562,6 +3619,8 @@ snd error text File is blocked by server operator: %@. + Le fichier est bloqué par l'opérateur du serveur : +%@. file error text @@ -7077,6 +7136,10 @@ chat item action Partager avec vos contacts No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Afficher le code QR @@ -7177,6 +7240,10 @@ chat item action Adresse SimpleX ou lien unique ? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Adresse de contact SimpleX @@ -7762,6 +7829,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Voici votre propre lien unique ! 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. Ce lien a été utilisé avec un autre appareil mobile, veuillez créer un nouveau lien sur le bureau. @@ -8059,6 +8130,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Non lu swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Les 100 derniers messages sont envoyés aux nouveaux membres. @@ -8217,6 +8292,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser les serveurs No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Utiliser l'application pendant l'appel. 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 76157f29ac..f24ba2b781 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -613,7 +613,7 @@ swipe action Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára. + Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára. No comment provided by engineer. @@ -758,7 +758,7 @@ swipe action All data is kept private on your device. - Az összes adat privát módon van tárolva az Ön eszközén. + Az összes adat privát módon van tárolva az eszközén. No comment provided by engineer. @@ -803,7 +803,7 @@ swipe action All your contacts will remain connected. Profile update will be sent to your contacts. - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön partnerei számára. + A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. No comment provided by engineer. @@ -1836,14 +1836,14 @@ set passcode view Connect to yourself? This is your own SimpleX address! Kapcsolódik saját magához? -Ez az Ön SimpleX-címe! +Ez a saját SimpleX-címe! No comment provided by engineer. Connect to yourself? This is your own one-time link! Kapcsolódik saját magához? -Ez az Ön egyszer használható meghívója! +Ez a saját egyszer használható meghívója! No comment provided by engineer. @@ -2389,7 +2389,7 @@ swipe action Delete chat messages from your device. - Csevegési üzenetek törlése az Ön eszközéről. + Csevegési üzenetek törlése a saját eszközéről. No comment provided by engineer. @@ -2454,7 +2454,7 @@ swipe action Delete for me - Csak számomra + Csak nálam No comment provided by engineer. @@ -2774,7 +2774,7 @@ swipe action Do NOT send messages directly, even if your or destination server does not support private routing. - NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + 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. No comment provided by engineer. @@ -2885,7 +2885,7 @@ chat item action Duplicate display name! - Duplikált megjelenített név! + Duplikált megjelenítendő név! No comment provided by engineer. @@ -2920,6 +2920,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. No comment provided by engineer. @@ -3765,7 +3766,7 @@ snd error text For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. - Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. + Például, ha a partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. No comment provided by engineer. @@ -4516,7 +4517,7 @@ További fejlesztések hamarosan! Join your group? This is your link for group %@! Csatlakozik a csoportjához? -Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! +Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No comment provided by engineer. @@ -4641,7 +4642,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! List name... - Listanév… + Lista neve… No comment provided by engineer. @@ -5161,7 +5162,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! New display name - Új megjelenítési név + Új megjelenítendő név No comment provided by engineer. @@ -5895,7 +5896,7 @@ Hiba: %@ Privacy for your customers. - Az Ön ügyfeleinek adatvédelme. + Saját ügyfeleinek adatvédelme. No comment provided by engineer. @@ -5975,7 +5976,7 @@ Hiba: %@ Profile update will be sent to your contacts. - A profilfrissítés el lesz küldve az Ön partnerei számára. + A profilfrissítés el lesz küldve a partnerei számára. alert message @@ -6684,7 +6685,7 @@ chat item action Scan QR code from desktop - QR-kód beolvasása számítógépről + QR-kód beolvasása a számítógépről No comment provided by engineer. @@ -6829,12 +6830,12 @@ chat item action Send messages directly when IP address is protected and your or destination server does not support private routing. - Közvetlen üzenetküldés, ha az IP-cím védett és az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. No comment provided by engineer. Send messages directly when your or destination server does not support private routing. - Közvetlen üzenetküldés, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. No comment provided by engineer. @@ -7198,6 +7199,10 @@ chat item action Megosztás a partnerekkel No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code QR-kód megjelenítése @@ -7298,6 +7303,10 @@ chat item action SimpleX-cím vagy egyszer használható meghívó? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX kapcsolattartási cím @@ -7548,12 +7557,12 @@ report reason TCP connection - TCP kapcsolat + TCP-kapcsolat No comment provided by engineer. TCP connection timeout - TCP kapcsolat időtúllépése + TCP-kapcsolat időtúllépése No comment provided by engineer. @@ -7705,7 +7714,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The connection reached the limit of undelivered messages, your contact may be offline. - A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön partnere lehet, hogy offline állapotban van. + A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van. No comment provided by engineer. @@ -7880,12 +7889,16 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This is your own SimpleX address! - Ez az Ön SimpleX-címe! + Ez a saját SimpleX-címe! No comment provided by engineer. This is your own one-time link! - Ez az Ön egyszer használható meghívója! + Ez a saját egyszer használható meghívója! + 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. @@ -7952,7 +7965,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. + Adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. No comment provided by engineer. @@ -8187,6 +8200,10 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Olvasatlan swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. @@ -8347,6 +8364,10 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Kiszolgálók használata No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Használja az alkalmazást hívás közben. @@ -8664,7 +8685,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso You already have a chat profile with the same display name. Please choose another name. - Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet. + Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. No comment provided by engineer. @@ -8968,7 +8989,7 @@ Megismétli a meghívási kérést? You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile - Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban + Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban No comment provided by engineer. @@ -9073,12 +9094,12 @@ Megismétli a meghívási kérést? 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 az Ön partnerei számára. + A profilja módosult. Ha elmenti, a profilfrissítés el lesz küldve a partnerei számára. alert message Your profile, contacts and delivered messages are stored on your device. - A profilja, a partnerei és az elküldött üzenetei az Ön eszközén vannak tárolva. + A profilja, a partnerei és az elküldött üzenetei a saját eszközén vannak tárolva. No comment provided by engineer. @@ -9620,7 +9641,7 @@ pref value invited via your group link - meghíva az Ön csoporthivatkozásán keresztül + meghíva a saját csoporthivatkozásán keresztül rcv 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 9d6aa9f4be..353d420e27 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -1218,7 +1218,7 @@ swipe action Better privacy and security - Privacy e sicurezza migliorate + Privacy e sicurezza migliori No comment provided by engineer. @@ -2920,6 +2920,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Attiva Flux nelle impostazioni "Rete e server" per una migliore privacy dei metadati. No comment provided by engineer. @@ -7198,6 +7199,10 @@ chat item action Condividi con i contatti No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Mostra codice QR @@ -7298,6 +7303,10 @@ chat item action Indirizzo SimpleX o link una tantum? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Indirizzo di contatto SimpleX @@ -7888,6 +7897,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questo è il tuo link una tantum! 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. Questo link è stato usato con un altro dispositivo mobile, creane uno nuovo sul desktop. @@ -8187,6 +8200,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Non letto swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Vengono inviati ai nuovi membri fino a 100 ultimi messaggi. @@ -8347,6 +8364,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa i server No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Usa l'app mentre sei in chiamata. 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 8e7c0ae206..34856e2e2b 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -6607,6 +6607,10 @@ chat item action 連絡先と共有する No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6699,6 +6703,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX連絡先アドレス @@ -7235,6 +7243,10 @@ It can happen because of some bug or when the connection is compromised.This is your own one-time link! 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. No comment provided by engineer. @@ -7507,6 +7519,10 @@ To connect, please ask your contact to create another connection link and check 未読 swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -7649,6 +7665,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 2ab0c75ff1..4d9df505db 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1335,6 +1335,9 @@ swipe action By using SimpleX Chat you agree to: - send only legal content in public groups. - respect other users – no spam. + Door SimpleX Chat te gebruiken, gaat u ermee akkoord: +- alleen legale content te versturen in openbare groepen. +- andere gebruikers te respecteren – geen spam. No comment provided by engineer. @@ -1741,6 +1744,7 @@ set passcode view Configure server operators + Serveroperators configureren No comment provided by engineer. @@ -2916,6 +2920,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Schakel Flux in bij Netwerk- en serverinstellingen voor betere privacy van metagegevens. No comment provided by engineer. @@ -5896,6 +5901,7 @@ Fout: %@ Privacy policy and conditions of use. + Privacybeleid en gebruiksvoorwaarden. No comment provided by engineer. @@ -5905,6 +5911,7 @@ Fout: %@ Private chats, groups and your contacts are not accessible to server operators. + Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders. No comment provided by engineer. @@ -7192,6 +7199,10 @@ chat item action Delen met contacten No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Toon QR-code @@ -7292,6 +7303,10 @@ chat item action SimpleX adres of eenmalige link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX contact adres @@ -7882,6 +7897,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Dit is uw eigen eenmalige link! 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. Deze link is gebruikt met een ander mobiel apparaat. Maak een nieuwe link op de desktop. @@ -8181,6 +8200,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Ongelezen swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Er worden maximaal 100 laatste berichten naar nieuwe leden verzonden. @@ -8341,6 +8364,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Gebruik de app tijdens het gesprek. 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 dce57ccc9e..0a548dc227 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -7034,6 +7034,10 @@ chat item action Udostępnij kontaktom No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Pokaż kod QR @@ -7131,6 +7135,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Adres kontaktowy SimpleX @@ -7704,6 +7712,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom To jest twój jednorazowy link! 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. Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze. @@ -7996,6 +8008,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Nieprzeczytane swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Do nowych członków wysyłanych jest do 100 ostatnich wiadomości. @@ -8150,6 +8166,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Używaj aplikacji podczas połączenia. 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 09e0400ec9..61495069c7 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -127,10 +127,12 @@ %@, %@ and %lld members + %@, %@ и %lld членов группы No comment provided by engineer. %@, %@ and %lld other members connected + установлено соединение с %@, %@ и %lld другими членами группы No comment provided by engineer. @@ -230,6 +232,7 @@ %lld members + %lld членов No comment provided by engineer. @@ -521,6 +524,8 @@ time interval A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. + Будет использовано отдельное TCP соединение **для каждого контакта и члена группы**. +**Примечание**: Чем больше подключений, тем быстрее разряжается батарея и расходуется трафик, а некоторые соединения могут отваливаться. No comment provided by engineer. @@ -708,6 +713,7 @@ swipe action Admins can block a member for all. + Админы могут заблокировать члена группы. No comment provided by engineer. @@ -757,6 +763,7 @@ swipe action All group members will remain connected. + Все члены группы останутся соединены. No comment provided by engineer. @@ -846,6 +853,7 @@ swipe action Allow sending direct messages to members. + Разрешить личные сообщения членам группы. No comment provided by engineer. @@ -1240,18 +1248,22 @@ swipe action Block group members + Заблокировать членов группы No comment provided by engineer. Block member + Заблокировать члена группы No comment provided by engineer. Block member for all? + Заблокировать для всех? No comment provided by engineer. Block member? + Заблокировать члена группы? No comment provided by engineer. @@ -1355,6 +1367,7 @@ swipe action Can't call member + Не удаётся позвонить члену группы No comment provided by engineer. @@ -1369,6 +1382,7 @@ swipe action Can't message member + Не удаётся отправить сообщение члену группы No comment provided by engineer. @@ -1434,6 +1448,7 @@ alert button Change member role? + Поменять роль члена группы? No comment provided by engineer. @@ -2334,6 +2349,7 @@ swipe action Delete %lld messages of members? + Удалить %lld сообщений членов группы? No comment provided by engineer. @@ -2668,6 +2684,7 @@ swipe action Direct messages between members are prohibited in this chat. + Личные сообщения запрещены в этой группе. No comment provided by engineer. @@ -7141,6 +7158,10 @@ chat item action Поделиться с контактами No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Показать QR код @@ -7241,6 +7262,10 @@ chat item action Адрес SimpleX или одноразовая ссылка? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX ссылка-контакт @@ -7826,6 +7851,10 @@ It can happen because of some bug or when the connection is compromised.Это ваша собственная одноразовая ссылка! 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. Эта ссылка была использована на другом мобильном, пожалуйста, создайте новую ссылку на компьютере. @@ -8122,6 +8151,10 @@ To connect, please ask your contact to create another connection link and check Не прочитано swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -8281,6 +8314,10 @@ To connect, please ask your contact to create another connection link and check Использовать серверы No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Используйте приложение во время звонка. 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 a3162e0bec..7431c13969 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -6512,6 +6512,10 @@ chat item action แชร์กับผู้ติดต่อ No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code No comment provided by engineer. @@ -6603,6 +6607,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address ที่อยู่ติดต่อ SimpleX @@ -7137,6 +7145,10 @@ It can happen because of some bug or when the connection is compromised.This is your own one-time link! 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. No comment provided by engineer. @@ -7409,6 +7421,10 @@ To connect, please ask your contact to create another connection link and check เปลี่ยนเป็นยังไม่ได้อ่าน swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -7549,6 +7565,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index bce3cdde08..935c4885b5 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -7046,6 +7046,10 @@ chat item action Kişilerle paylaş No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code QR kodunu göster @@ -7143,6 +7147,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX kişi adresi @@ -7719,6 +7727,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Bu senin kendi tek kullanımlık bağlantın! 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. Bu bağlantı başka bir mobil cihazda kullanıldı, lütfen masaüstünde yeni bir bağlantı oluşturun. @@ -8011,6 +8023,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Okunmamış swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Yeni üyelere 100e kadar en son mesajlar gönderildi. @@ -8165,6 +8181,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Görüşme sırasında uygulamayı kullanın. 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 c95be26929..9ea65c4b11 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -7077,6 +7077,10 @@ chat item action Поділіться з контактами No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code Показати QR-код @@ -7177,6 +7181,10 @@ chat item action SimpleX адреса або одноразове посилання? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address Контактна адреса SimpleX @@ -7762,6 +7770,10 @@ It can happen because of some bug or when the connection is compromised.Це ваше власне одноразове посилання! 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. Це посилання було використано з іншого мобільного пристрою, будь ласка, створіть нове посилання на робочому столі. @@ -8059,6 +8071,10 @@ To connect, please ask your contact to create another connection link and check Непрочитане swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. Новим користувачам надсилається до 100 останніх повідомлень. @@ -8217,6 +8233,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте сервери No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. Використовуйте додаток під час розмови. 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 1e4c1a72f6..30a6567414 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 @@ -6946,6 +6946,10 @@ chat item action 与联系人分享 No comment provided by engineer. + + Short link + No comment provided by engineer. + Show QR code 显示二维码 @@ -7043,6 +7047,10 @@ chat item action SimpleX address or 1-time link? No comment provided by engineer. + + SimpleX channel link + simplex link type + SimpleX contact address SimpleX 联系地址 @@ -7613,6 +7621,10 @@ It can happen because of some bug or when the connection is compromised.这是你自己的一次性链接! 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. 此链接已在其他移动设备上使用,请在桌面上创建新链接。 @@ -7903,6 +7915,10 @@ To connect, please ask your contact to create another connection link and check 未读 swipe action + + Unsupported connection link + No comment provided by engineer. + Up to 100 last messages are sent to new members. 给新成员发送了最多 100 条历史消息。 @@ -8056,6 +8072,10 @@ To connect, please ask your contact to create another connection link and check Use servers No comment provided by engineer. + + Use short links (BETA) + No comment provided by engineer. + Use the app while in the call. 通话时使用本应用. diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index d92ad24117..ef03de17f0 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1945,6 +1945,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Kamera-Zugriff aktivieren"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Für einen besseren Metadatenschutz Flux in den Netzwerk- und Servereinstellungen aktivieren."; + /* No comment provided by engineer. */ "Enable for all" = "Für Alle aktivieren"; @@ -3271,10 +3274,10 @@ snd error text */ "Messages were deleted after you selected them." = "Die Nachrichten wurden gelöscht, nachdem Sie sie ausgewählt hatten."; /* 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." = "Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt."; +"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Nachrichten, Dateien und Anrufe sind durch **Ende-zu-Ende-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt."; /* 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." = "Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt."; +"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Nachrichten, Dateien und Anrufe sind durch **Quantum-resistente E2E-Verschlüsselung** mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt."; /* No comment provided by engineer. */ "Migrate device" = "Gerät migrieren"; @@ -3523,7 +3526,7 @@ snd error text */ "No push server" = "Lokal"; /* No comment provided by engineer. */ -"No received or sent files" = "Keine herunter- oder hochgeladene Dateien"; +"No received or sent files" = "Keine herunter- oder hochgeladenen Dateien"; /* servers error */ "No servers for private message routing." = "Keine Server für privates Nachrichten-Routing."; @@ -3909,7 +3912,7 @@ time to disappear */ "Privacy for your customers." = "Schutz der Privatsphäre Ihrer Kunden."; /* No comment provided by engineer. */ -"Privacy policy and conditions of use." = "Datenschutzbestimmungen und Nutzungsbedingungen."; +"Privacy policy and conditions of use." = "Datenschutz- und Nutzungsbedingungen."; /* No comment provided by engineer. */ "Privacy redefined" = "Datenschutz neu definiert"; @@ -5140,7 +5143,7 @@ report reason */ "They can be overridden in contact and group settings." = "Sie können in den Kontakteinstellungen überschrieben werden."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Es werden alle herunter- und hochgeladene Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden!"; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Es werden alle herunter- und hochgeladenen Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden!"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index b6580836e7..f9fe85dba7 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -287,7 +287,7 @@ time interval */ "1 week" = "una semana"; /* delete after time */ -"1 year" = "Un año"; +"1 year" = "1 año"; /* No comment provided by engineer. */ "1-time link" = "Enlace de un uso"; @@ -551,7 +551,7 @@ swipe action */ "Allow to irreversibly delete sent messages. (24 hours)" = "Se permite la eliminación irreversible de mensajes. (24 horas)"; /* No comment provided by engineer. */ -"Allow to report messsages to moderators." = "Se permite informar de mensajes a los moderadores."; +"Allow to report messsages to moderators." = "Permitir informar de mensajes a los moderadores."; /* No comment provided by engineer. */ "Allow to send files and media." = "Se permite enviar archivos y multimedia."; @@ -1945,6 +1945,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Permitir acceso a la cámara"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Habilitar Flux en la configuración de Red y servidores para mejorar la privacidad de los metadatos."; + /* No comment provided by engineer. */ "Enable for all" = "Activar para todos"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 9990bd4a3e..a0145a7f47 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -178,6 +178,9 @@ /* time interval */ "%d sec" = "%d sec"; +/* delete after time */ +"%d seconds(s)" = "%d seconde(s)"; + /* integrity error chat item */ "%d skipped message(s)" = "%d message·s sauté·s"; @@ -283,6 +286,9 @@ time interval */ time interval */ "1 week" = "1 semaine"; +/* delete after time */ +"1 year" = "1 an"; + /* No comment provided by engineer. */ "1-time link" = "Lien unique"; @@ -367,6 +373,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledgement errors" = "Erreur d'accusé de réception"; +/* token status text */ +"Active" = "Actif"; + /* No comment provided by engineer. */ "Active connections" = "Connections actives"; @@ -376,6 +385,9 @@ swipe action */ /* No comment provided by engineer. */ "Add friends" = "Ajouter des amis"; +/* No comment provided by engineer. */ +"Add list" = "Ajouter une liste"; + /* No comment provided by engineer. */ "Add profile" = "Ajouter un profil"; @@ -391,6 +403,9 @@ swipe action */ /* No comment provided by engineer. */ "Add to another device" = "Ajouter à un autre appareil"; +/* No comment provided by engineer. */ +"Add to list" = "Ajouter à la liste"; + /* No comment provided by engineer. */ "Add welcome message" = "Ajouter un message d'accueil"; @@ -448,12 +463,18 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "négociation du chiffrement…"; +/* No comment provided by engineer. */ +"All" = "Tout"; + /* No comment provided by engineer. */ "All app data is deleted." = "Toutes les données de l'application sont supprimées."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière !"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Tous les chats seront supprimés de la liste %@, et la liste sera supprimée."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Toutes les données sont effacées lorsqu'il est saisi."; @@ -481,6 +502,9 @@ swipe action */ /* profile dropdown */ "All profiles" = "Tous les profiles"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Tous les rapports seront archivés pour vous."; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Tous vos contacts resteront connectés."; @@ -526,6 +550,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Autoriser la suppression irréversible de messages envoyés. (24 heures)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Permettre de signaler des messages aux modérateurs."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Permet l'envoi de fichiers et de médias."; @@ -580,6 +607,9 @@ swipe action */ /* No comment provided by engineer. */ "and %lld other events" = "et %lld autres événements"; +/* report reason */ +"Another reason" = "Autre raison"; + /* No comment provided by engineer. */ "Answer call" = "Répondre à l'appel"; @@ -622,18 +652,39 @@ swipe action */ /* No comment provided by engineer. */ "Apply to" = "Appliquer à"; +/* No comment provided by engineer. */ +"Archive" = "Archiver"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Archiver les rapports %lld ?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archiver tous les rapports ?"; + /* No comment provided by engineer. */ "Archive and upload" = "Archiver et téléverser"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Archiver les contacts pour discuter plus tard."; +/* No comment provided by engineer. */ +"Archive report" = "Archiver le rapport"; + +/* No comment provided by engineer. */ +"Archive report?" = "Archiver le rapport ?"; + +/* swipe action */ +"Archive reports" = "Archiver les rapports"; + /* No comment provided by engineer. */ "Archived contacts" = "Contacts archivés"; /* No comment provided by engineer. */ "Archiving database" = "Archivage de la base de données"; +/* No comment provided by engineer. */ +"Ask" = "Demander"; + /* No comment provided by engineer. */ "Attach" = "Attacher"; @@ -709,6 +760,9 @@ swipe action */ /* No comment provided by engineer. */ "Better groups" = "Des groupes plus performants"; +/* No comment provided by engineer. */ +"Better groups performance" = "Meilleure performance des groupes"; + /* No comment provided by engineer. */ "Better message dates." = "Meilleures dates de messages."; @@ -721,6 +775,9 @@ swipe action */ /* No comment provided by engineer. */ "Better notifications" = "Notifications améliorées"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Meilleure protection de la privacité et de la sécurité"; + /* No comment provided by engineer. */ "Better security ✅" = "Sécurité accrue ✅"; @@ -794,9 +851,15 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Business chats" = "Discussions professionnelles"; +/* No comment provided by engineer. */ +"Businesses" = "Entreprises"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "En utilisant SimpleX Chat, vous acceptez de :\n- n'envoyer que du contenu légal dans les groupes publics.\n- respecter les autres utilisateurs - pas de spam."; + /* No comment provided by engineer. */ "call" = "appeler"; @@ -864,6 +927,9 @@ alert button */ /* No comment provided by engineer. */ "Change" = "Changer"; +/* alert title */ +"Change automatic message deletion?" = "Modifier la suppression automatique des messages ?"; + /* authentication reason */ "Change chat profiles" = "Changer de profil de discussion"; @@ -1012,6 +1078,12 @@ set passcode view */ /* No comment provided by engineer. */ "Clear conversation?" = "Effacer la conversation ?"; +/* No comment provided by engineer. */ +"Clear group?" = "Vider le groupe ?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Vider ou supprimer le groupe ?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Effacer les notes privées ?"; @@ -1027,6 +1099,9 @@ set passcode view */ /* No comment provided by engineer. */ "colored" = "coloré"; +/* report reason */ +"Community guidelines violation" = "Infraction aux règles communautaires"; + /* server test step */ "Compare file" = "Comparer le fichier"; @@ -1063,6 +1138,9 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "Configurer les serveurs ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Configurer les opérateurs de serveur"; + /* No comment provided by engineer. */ "Confirm" = "Confirmer"; @@ -1093,6 +1171,9 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm upload" = "Confirmer la transmission"; +/* token status text */ +"Confirmed" = "Confirmé"; + /* server test step */ "Connect" = "Se connecter"; @@ -1192,6 +1273,9 @@ set passcode view */ /* No comment provided by engineer. */ "Connection and servers status." = "État de la connexion et des serveurs."; +/* No comment provided by engineer. */ +"Connection blocked" = "Connexion bloquée"; + /* No comment provided by engineer. */ "Connection error" = "Erreur de connexion"; @@ -1201,12 +1285,21 @@ set passcode view */ /* chat list item title (it should not be shown */ "connection established" = "connexion établie"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "La connexion est bloquée par l'opérateur du serveur :\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "La connexion n'est pas prête."; + /* No comment provided by engineer. */ "Connection notifications" = "Notifications de connexion"; /* No comment provided by engineer. */ "Connection request sent!" = "Demande de connexion envoyée !"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "La connexion nécessite une renégociation du cryptage."; + /* No comment provided by engineer. */ "Connection security" = "Sécurité des connexions"; @@ -1267,6 +1360,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter."; +/* blocking reason */ +"Content violates conditions of use" = "Le contenu enfreint les conditions d'utilisation"; + /* No comment provided by engineer. */ "Continue" = "Continuer"; @@ -1309,6 +1405,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create link" = "Créer un lien"; +/* No comment provided by engineer. */ +"Create list" = "Créer une liste"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Créer un nouveau profil sur [l'application de bureau](https://simplex.chat/downloads/). 💻"; @@ -1488,6 +1587,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat" = "Supprimer la discussion"; +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Supprimer les messages de chat de votre appareil."; + /* No comment provided by engineer. */ "Delete chat profile" = "Supprimer le profil de chat"; @@ -1542,6 +1644,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete link?" = "Supprimer le lien ?"; +/* alert title */ +"Delete list?" = "Supprimer la liste ?"; + /* No comment provided by engineer. */ "Delete member message?" = "Supprimer le message de ce membre ?"; @@ -1572,6 +1677,9 @@ swipe action */ /* server test step */ "Delete queue" = "Supprimer la file d'attente"; +/* No comment provided by engineer. */ +"Delete report" = "Supprimer le rapport"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Supprimez jusqu'à 20 messages à la fois."; @@ -1680,6 +1788,12 @@ swipe action */ /* No comment provided by engineer. */ "Disable (keep overrides)" = "Désactiver (conserver les remplacements)"; +/* alert title */ +"Disable automatic message deletion?" = "Désactiver la suppression automatique des messages ?"; + +/* alert button */ +"Disable delete messages" = "Désactiver la suppression des messages"; + /* No comment provided by engineer. */ "Disable for all" = "Désactiver pour tous"; @@ -1740,15 +1854,24 @@ swipe action */ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "N'utilisez PAS SimpleX pour les appels d'urgence."; +/* No comment provided by engineer. */ +"Documents:" = "Documents:"; + /* No comment provided by engineer. */ "Don't create address" = "Ne pas créer d'adresse"; /* No comment provided by engineer. */ "Don't enable" = "Ne pas activer"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Ne manquez pas les messages importants."; + /* No comment provided by engineer. */ "Don't show again" = "Ne plus afficher"; +/* No comment provided by engineer. */ +"Done" = "Terminé"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Rétrograder et ouvrir le chat"; @@ -1816,6 +1939,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Autoriser l'accès à la caméra"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Activez Flux dans les paramètres du réseau et des serveurs pour une meilleure confidentialité des métadonnées."; + /* No comment provided by engineer. */ "Enable for all" = "Activer pour tous"; @@ -1927,6 +2053,9 @@ chat item action */ /* chat item text */ "encryption re-negotiation required for %@" = "renégociation de chiffrement requise pour %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Renégociation du chiffrement en cours."; + /* No comment provided by engineer. */ "ended" = "terminé"; @@ -2002,6 +2131,9 @@ chat item action */ /* No comment provided by engineer. */ "Error changing to incognito!" = "Erreur lors du passage en mode incognito !"; +/* No comment provided by engineer. */ +"Error checking token status" = "Erreur lors de la vérification de l'état du jeton (token)"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard."; @@ -2014,6 +2146,9 @@ chat item action */ /* No comment provided by engineer. */ "Error creating group link" = "Erreur lors de la création du lien du groupe"; +/* alert title */ +"Error creating list" = "Erreur lors de la création de la liste"; + /* No comment provided by engineer. */ "Error creating member contact" = "Erreur lors de la création du contact du membre"; @@ -2023,6 +2158,9 @@ chat item action */ /* No comment provided by engineer. */ "Error creating profile!" = "Erreur lors de la création du profil !"; +/* No comment provided by engineer. */ +"Error creating report" = "Erreur lors de la création du rapport"; + /* No comment provided by engineer. */ "Error decrypting file" = "Erreur lors du déchiffrement du fichier"; @@ -2089,12 +2227,21 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "Erreur de reconnexion des serveurs"; +/* alert title */ +"Error registering for notifications" = "Erreur lors de l'inscription aux notifications"; + /* No comment provided by engineer. */ "Error removing member" = "Erreur lors de la suppression d'un membre"; +/* alert title */ +"Error reordering lists" = "Erreur lors de la réorganisation des listes"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Erreur de réinitialisation des statistiques"; +/* alert title */ +"Error saving chat list" = "Erreur lors de l'enregistrement de la liste des chats"; + /* No comment provided by engineer. */ "Error saving group profile" = "Erreur lors de la sauvegarde du profil de groupe"; @@ -2146,6 +2293,9 @@ chat item action */ /* No comment provided by engineer. */ "Error synchronizing connection" = "Erreur de synchronisation de connexion"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Erreur lors du test de connexion au serveur"; + /* No comment provided by engineer. */ "Error updating group link" = "Erreur lors de la mise à jour du lien de groupe"; @@ -2199,6 +2349,9 @@ snd error text */ /* No comment provided by engineer. */ "expired" = "expiré"; +/* token status text */ +"Expired" = "Expiré"; + /* No comment provided by engineer. */ "Export database" = "Exporter la base de données"; @@ -2223,18 +2376,30 @@ snd error text */ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne !"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Suppression plus rapide des groupes."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Connexion plus rapide et messages plus fiables."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Envoi plus rapide des messages."; + /* swipe action */ "Favorite" = "Favoris"; +/* No comment provided by engineer. */ +"Favorites" = "Favoris"; + /* file error alert title */ "File error" = "Erreur de fichier"; /* alert message */ "File errors:\n%@" = "Erreurs de fichier :\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Le fichier est bloqué par l'opérateur du serveur :\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Fichier introuvable - le fichier a probablement été supprimé ou annulé."; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index f21ba8f6b3..2d2fbb2ad1 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -380,7 +380,7 @@ swipe action */ "Active connections" = "Aktív kapcsolatok száma"; /* No comment provided by engineer. */ -"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára."; +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára."; /* No comment provided by engineer. */ "Add friends" = "Barátok hozzáadása"; @@ -479,7 +479,7 @@ swipe action */ "All data is erased when it is entered." = "A jelkód megadása után az összes adat törölve lesz."; /* No comment provided by engineer. */ -"All data is kept private on your device." = "Az összes adat privát módon van tárolva az Ön eszközén."; +"All data is kept private on your device." = "Az összes adat privát módon van tárolva az eszközén."; /* No comment provided by engineer. */ "All group members will remain connected." = "Az összes csoporttag kapcsolatban marad."; @@ -509,7 +509,7 @@ swipe action */ "All your contacts will remain connected." = "Az összes partnerével kapcsolatban marad."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön partnerei számára."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; /* No comment provided by engineer. */ "All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra."; @@ -1202,10 +1202,10 @@ set passcode view */ "Connect to yourself?" = "Kapcsolódik saját magához?"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódik saját magához?\nEz az Ön egyszer használható meghívója!"; +"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódik saját magához?\nEz a saját egyszer használható meghívója!"; /* No comment provided by engineer. */ -"Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódik saját magához?\nEz az Ön SimpleX-címe!"; +"Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódik saját magához?\nEz a saját SimpleX-címe!"; /* No comment provided by engineer. */ "Connect via contact address" = "Kapcsolódás a kapcsolattartási címen keresztül"; @@ -1594,7 +1594,7 @@ swipe action */ "Delete chat" = "Csevegés törlése"; /* No comment provided by engineer. */ -"Delete chat messages from your device." = "Csevegési üzenetek törlése az Ön eszközéről."; +"Delete chat messages from your device." = "Csevegési üzenetek törlése a saját eszközéről."; /* No comment provided by engineer. */ "Delete chat profile" = "Csevegési profil törlése"; @@ -1633,7 +1633,7 @@ swipe action */ "Delete for everyone" = "Törlés az összes tagnál"; /* No comment provided by engineer. */ -"Delete for me" = "Csak számomra"; +"Delete for me" = "Csak nálam"; /* No comment provided by engineer. */ "Delete group" = "Csoport törlése"; @@ -1849,7 +1849,7 @@ swipe action */ "Do not send history to new members." = "Az előzmények ne legyenek elküldve az új tagok számára."; /* No comment provided by engineer. */ -"Do NOT send messages directly, even if your or destination server does not support private routing." = "NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Do NOT send messages directly, even if your or destination server does not support private routing." = "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."; /* No comment provided by engineer. */ "Do not use credentials with proxy." = "Ne használja a hitelesítőadatokat proxyval."; @@ -1910,7 +1910,7 @@ chat item action */ "Downloading link details" = "Letöltési hivatkozás részletei"; /* No comment provided by engineer. */ -"Duplicate display name!" = "Duplikált megjelenített név!"; +"Duplicate display name!" = "Duplikált megjelenítendő név!"; /* integrity error chat item */ "duplicate message" = "duplikált üzenet"; @@ -1945,6 +1945,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Kamera hozzáférés engedélyezése"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében."; + /* No comment provided by engineer. */ "Enable for all" = "Engedélyezés az összes tag számára"; @@ -2488,7 +2491,7 @@ snd error text */ "For console" = "Konzolhoz"; /* No comment provided by engineer. */ -"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni."; +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Például, ha a partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni."; /* No comment provided by engineer. */ "For me" = "Csak magamnak"; @@ -2929,7 +2932,7 @@ snd error text */ "invited to connect" = "Függőben lévő meghívó"; /* rcv group event chat item */ -"invited via your group link" = "meghíva az Ön csoporthivatkozásán keresztül"; +"invited via your group link" = "meghíva a saját csoporthivatkozásán keresztül"; /* No comment provided by engineer. */ "iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását."; @@ -2995,7 +2998,7 @@ snd error text */ "Join with current profile" = "Csatlakozás a jelenlegi profillal"; /* No comment provided by engineer. */ -"Join your group?\nThis is your link for group %@!" = "Csatlakozik a csoportjához?\nEz az Ön hivatkozása a(z) %@ nevű csoporthoz!"; +"Join your group?\nThis is your link for group %@!" = "Csatlakozik a csoportjához?\nEz a saját hivatkozása a(z) %@ nevű csoporthoz!"; /* No comment provided by engineer. */ "Joining group" = "Csatlakozás a csoporthoz"; @@ -3070,7 +3073,7 @@ snd error text */ "List name and emoji should be different for all lists." = "Az összes lista nevének és emodzsijának különbözőnek kell lennie."; /* No comment provided by engineer. */ -"List name..." = "Listanév…"; +"List name..." = "Lista neve…"; /* No comment provided by engineer. */ "LIVE" = "ÉLŐ"; @@ -3415,7 +3418,7 @@ snd error text */ "New desktop app!" = "Új számítógép-alkalmazás!"; /* No comment provided by engineer. */ -"New display name" = "Új megjelenítési név"; +"New display name" = "Új megjelenítendő név"; /* notification */ "New events" = "Új események"; @@ -3906,7 +3909,7 @@ time to disappear */ "Privacy & security" = "Adatvédelem és biztonság"; /* No comment provided by engineer. */ -"Privacy for your customers." = "Az Ön ügyfeleinek adatvédelme."; +"Privacy for your customers." = "Saját ügyfeleinek adatvédelme."; /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "Adatvédelmi szabályzat és felhasználási feltételek."; @@ -3954,7 +3957,7 @@ time to disappear */ "Profile theme" = "Profiltéma"; /* alert message */ -"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve az Ön partnerei számára."; +"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve a partnerei számára."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "A hívások kezdeményezése le van tiltva."; @@ -4415,7 +4418,7 @@ chat item action */ "Scan QR code" = "QR-kód beolvasása"; /* No comment provided by engineer. */ -"Scan QR code from desktop" = "QR-kód beolvasása számítógépről"; +"Scan QR code from desktop" = "QR-kód beolvasása a számítógépről"; /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Biztonsági kód beolvasása a partnere alkalmazásából."; @@ -4517,10 +4520,10 @@ chat item action */ "Send message to enable calls." = "Üzenet küldése a hívások engedélyezéséhez."; /* No comment provided by engineer. */ -"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha az IP-cím védett és az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; /* No comment provided by engineer. */ -"Send messages directly when your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Send messages directly when your or destination server does not support private routing." = "Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; /* No comment provided by engineer. */ "Send notifications" = "Értesítések küldése"; @@ -5002,10 +5005,10 @@ report reason */ "Tap to scan" = "Koppintson ide a QR-kód beolvasásához"; /* No comment provided by engineer. */ -"TCP connection" = "TCP kapcsolat"; +"TCP connection" = "TCP-kapcsolat"; /* No comment provided by engineer. */ -"TCP connection timeout" = "TCP kapcsolat időtúllépése"; +"TCP connection timeout" = "TCP-kapcsolat időtúllépése"; /* No comment provided by engineer. */ "TCP port for messaging" = "TCP-port az üzenetváltáshoz"; @@ -5062,7 +5065,7 @@ report reason */ "The code you scanned is not a SimpleX link QR code." = "A beolvasott QR-kód nem egy SimpleX-QR-kód-hivatkozás."; /* No comment provided by engineer. */ -"The connection reached the limit of undelivered messages, your contact may be offline." = "A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön partnere lehet, hogy offline állapotban van."; +"The connection reached the limit of undelivered messages, your contact may be offline." = "A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van."; /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "Az Ön által elfogadott kérelem vissza lesz vonva!"; @@ -5173,10 +5176,10 @@ report reason */ "This group no longer exists." = "Ez a csoport már nem létezik."; /* No comment provided by engineer. */ -"This is your own one-time link!" = "Ez az Ön egyszer használható meghívója!"; +"This is your own one-time link!" = "Ez a saját egyszer használható meghívója!"; /* No comment provided by engineer. */ -"This is your own SimpleX address!" = "Ez az Ön SimpleX-címe!"; +"This is your own SimpleX address!" = "Ez a saját SimpleX-címe!"; /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén."; @@ -5215,7 +5218,7 @@ report reason */ "To protect your IP address, private routing uses your SMP servers to deliver messages." = "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."; /* No comment provided by engineer. */ -"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához."; +"To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához."; /* No comment provided by engineer. */ "To receive" = "A fogadáshoz"; @@ -5707,7 +5710,7 @@ report reason */ "You allow" = "Ön engedélyezi"; /* No comment provided by engineer. */ -"You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet."; +"You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet."; /* No comment provided by engineer. */ "You are already connected to %@." = "Ön már kapcsolódott a következőhöz: %@."; @@ -5926,7 +5929,7 @@ report reason */ "you: " = "Ön: "; /* No comment provided by engineer. */ -"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban"; +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban"; /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "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"; @@ -5986,10 +5989,10 @@ report reason */ "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 az Ön partnerei számára."; +"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 az Ön eszközén vannak tárolva."; +"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 53798fe0eb..3988b33531 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -782,7 +782,7 @@ swipe action */ "Better notifications" = "Notifiche migliorate"; /* No comment provided by engineer. */ -"Better privacy and security" = "Privacy e sicurezza migliorate"; +"Better privacy and security" = "Privacy e sicurezza migliori"; /* No comment provided by engineer. */ "Better security ✅" = "Sicurezza migliorata ✅"; @@ -1945,6 +1945,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Attiva l'accesso alla fotocamera"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Attiva Flux nelle impostazioni \"Rete e server\" per una migliore privacy dei metadati."; + /* No comment provided by engineer. */ "Enable for all" = "Attiva per tutti"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index f9e289369e..cd026361e0 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -863,6 +863,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Door SimpleX Chat te gebruiken, gaat u ermee akkoord:\n- alleen legale content te versturen in openbare groepen.\n- andere gebruikers te respecteren – geen spam."; + /* No comment provided by engineer. */ "call" = "bellen"; @@ -1141,6 +1144,9 @@ set passcode view */ /* No comment provided by engineer. */ "Configure ICE servers" = "ICE servers configureren"; +/* No comment provided by engineer. */ +"Configure server operators" = "Serveroperators configureren"; + /* No comment provided by engineer. */ "Confirm" = "Bevestigen"; @@ -1939,6 +1945,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Schakel cameratoegang in"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Schakel Flux in bij Netwerk- en serverinstellingen voor betere privacy van metagegevens."; + /* No comment provided by engineer. */ "Enable for all" = "Inschakelen voor iedereen"; @@ -3902,9 +3911,15 @@ time to disappear */ /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy voor uw klanten."; +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Privacybeleid en gebruiksvoorwaarden."; + /* No comment provided by engineer. */ "Privacy redefined" = "Privacy opnieuw gedefinieerd"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders."; + /* No comment provided by engineer. */ "Private filenames" = "Privé bestandsnamen"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index f86b3aa4fd..45a2d6db4f 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -139,6 +139,12 @@ /* format for date separator in chat */ "%@, %@" = "%1$@, %2$@"; +/* No comment provided by engineer. */ +"%@, %@ and %lld members" = "%@, %@ и %lld членов группы"; + +/* No comment provided by engineer. */ +"%@, %@ and %lld other members connected" = "установлено соединение с %@, %@ и %lld другими членами группы"; + /* copied message info */ "%@:" = "%@:"; @@ -196,6 +202,9 @@ /* No comment provided by engineer. */ "%lld group events" = "%lld событий"; +/* No comment provided by engineer. */ +"%lld members" = "%lld членов"; + /* No comment provided by engineer. */ "%lld messages blocked" = "%lld сообщений заблокировано"; @@ -307,6 +316,9 @@ time interval */ /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "Отдельное TCP-соединение будет использоваться **для каждого профиля чата, который Вы имеете в приложении**."; +/* No comment provided by engineer. */ +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Будет использовано отдельное TCP соединение **для каждого контакта и члена группы**.\n**Примечание**: Чем больше подключений, тем быстрее разряжается батарея и расходуется трафик, а некоторые соединения могут отваливаться."; + /* No comment provided by engineer. */ "Abort" = "Прекратить"; @@ -433,6 +445,9 @@ swipe action */ /* feature role */ "admins" = "админы"; +/* No comment provided by engineer. */ +"Admins can block a member for all." = "Админы могут заблокировать члена группы."; + /* No comment provided by engineer. */ "Admins can create the links to join groups." = "Админы могут создать ссылки для вступления в группу."; @@ -466,6 +481,9 @@ swipe action */ /* No comment provided by engineer. */ "All data is kept private on your device." = "Все данные хранятся только на вашем устройстве."; +/* No comment provided by engineer. */ +"All group members will remain connected." = "Все члены группы останутся соединены."; + /* 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 шифрованием**, с постквантовой безопасностью в прямых разговорах."; @@ -517,6 +535,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow message reactions." = "Разрешить реакции на сообщения."; +/* No comment provided by engineer. */ +"Allow sending direct messages to members." = "Разрешить личные сообщения членам группы."; + /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Разрешить посылать исчезающие сообщения."; @@ -775,6 +796,18 @@ swipe action */ /* No comment provided by engineer. */ "Block for all" = "Заблокировать для всех"; +/* No comment provided by engineer. */ +"Block group members" = "Заблокировать членов группы"; + +/* No comment provided by engineer. */ +"Block member" = "Заблокировать члена группы"; + +/* No comment provided by engineer. */ +"Block member for all?" = "Заблокировать для всех?"; + +/* No comment provided by engineer. */ +"Block member?" = "Заблокировать члена группы?"; + /* marked deleted chat item preview text */ "blocked" = "заблокировано"; @@ -857,12 +890,18 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call contact" = "Не удается позвонить контакту"; +/* No comment provided by engineer. */ +"Can't call member" = "Не удаётся позвонить члену группы"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Нельзя пригласить контакт!"; /* No comment provided by engineer. */ "Can't invite contacts!" = "Нельзя пригласить контакты!"; +/* No comment provided by engineer. */ +"Can't message member" = "Не удаётся отправить сообщение члену группы"; + /* alert action alert button */ "Cancel" = "Отменить"; @@ -903,6 +942,9 @@ alert button */ /* authentication reason */ "Change lock mode" = "Изменить режим блокировки"; +/* No comment provided by engineer. */ +"Change member role?" = "Поменять роль члена группы?"; + /* authentication reason */ "Change passcode" = "Изменить код доступа"; @@ -1524,6 +1566,9 @@ pref value */ swipe action */ "Delete" = "Удалить"; +/* No comment provided by engineer. */ +"Delete %lld messages of members?" = "Удалить %lld сообщений членов группы?"; + /* No comment provided by engineer. */ "Delete %lld messages?" = "Удалить %lld сообщений?"; @@ -1737,6 +1782,9 @@ swipe action */ /* chat feature */ "Direct messages" = "Прямые сообщения"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "Личные сообщения запрещены в этой группе."; + /* No comment provided by engineer. */ "Disable (keep overrides)" = "Выключить (кроме исключений)"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 160e1322b7..a804d48dfb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2366,4 +2366,10 @@ لا يمكن الوصول إلى الدردشات الخاصة والمجموعات وجهات اتصالك لمشغلي الخادم. باستخدام SimpleX Chat، توافق على:\n- إرسال المحتوى القانوني فقط في المجموعات العامة.\n- احترام المستخدمين الآخرين – لا سبام. اقبل + استخدم روابط قصيرة (تجريبي) + يتطلب هذا الرابط إصدار تطبيق أحدث. يُرجى ترقية التطبيق أو اطلب من جهة اتصالك إرسال رابط متوافق. + رابط كامل + رابط قصير + رابط قناة SimpleX + رابط اتصال غير مدعوم diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index b0c6e32c70..77c2a6ef65 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -2343,4 +2343,10 @@ Acceptar En utilitzar SimpleX Chat accepteu:\n- enviar només contingut legal en grups públics.\n- Respectar els altres usuaris, sense correu brossa. Configurar els operadors de servidor + Enllaç al canal SimpleX + Aquest enllaç requereix una versió de l\'aplicació més recent. Actualitzeu l\'aplicació o demaneu al vostre contacte que enviï un enllaç compatible. + Enllaç de connexió no compatible + Emprar enllaços curts (BETA) + Enllaç complet + Enllaç curt diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 3b8db5044b..e262a59214 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1373,9 +1373,7 @@ Zjednodušený režim inkognito Vytvořit nový profil v desktopové aplikaci. 💻 Změnit inkognito při připojování. - - připojit k adresáři skupin (BETA)! -\n- doručenky (až 20 členů). -\n- rychlejší a stabilnější. + - připojení k adresáři skupin (BETA)!\n- doručenky (až 20 členů).\n- rychlejší a stabilnější. odeslat přímou zprávu smazaný kontakt Chyba @@ -2113,7 +2111,7 @@ %s.]]> %s.]]> Přidány servery pro média & soubory - Povolit flux + Povolte Flux v nastavení sítě a serverů pro lepší ochranu metadat. Servery přes proxy Soukromí pro vaše zákazníky. moderátor @@ -2367,4 +2365,9 @@ Členové budou odstraněny ze skupiny - toto nelze zvrátit! Odebrat členy? Členové budou odstraněny z chatu - toto nelze zvrátit! + Použitím SimpleX chatu souhlasíte že:\n- ve veřejných skupinách budete zasílat pouze legální obsah.\n- budete respektovat ostatní uživatele – žádný spam. + Přijmout + Nastavit operátora serveru + Zásady ochrany soukromí a podmínky používání. + Soukromé konverzace, skupiny a kontakty nejsou přístupné provozovatelům serverů. 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 145d4142a7..089ef06827 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -597,8 +597,8 @@ Starten Sie die App neu, um ein neues Chat-Profil zu erstellen. Sie dürfen die neueste Version Ihrer Chat-Datenbank NUR auf einem Gerät verwenden, andernfalls erhalten Sie möglicherweise keine Nachrichten mehr von einigen Ihrer Kontakte. Dateien und Medien löschen? - Es werden alle herunter- und hochgeladene Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! - Keine herunter- oder hochgeladene Dateien + Es werden alle herunter- und hochgeladenen Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! + Keine herunter- oder hochgeladenen Dateien %d Datei(en) mit einem Gesamtspeicherverbrauch von %s nie Älter als ein Tag @@ -1714,8 +1714,8 @@ Bitte bestätigen Sie für die Migration, dass Sie sich an Ihr Datenbank-Passwort erinnern. Hochladen bestätigen Herunterladen fehlgeschlagen - Ende-zu-Ende-Verschlüsselung mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt.]]> - Quantum-resistente E2E-Verschlüsselung mit Perfect Forward Secrecy, Ablehnung und Einbruchs-Wiederherstellung geschützt.]]> + Ende-zu-Ende-Verschlüsselung mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt.]]> + Quantum-resistente E2E-Verschlüsselung mit Perfect Forward Secrecy, Abstreitbarkeit und Wiederherstellung nach einer Kompromittierung geschützt.]]> Dieser Chat ist durch Ende-zu-Ende-Verschlüsselung geschützt. Dieser Chat ist durch Quantum-resistente Ende-zu-Ende-Verschlüsselung geschützt. Migrationsansicht öffnen @@ -2448,8 +2448,14 @@ Mitglieder für Alle blockieren? Alle neuen Nachrichten dieser Mitglieder werden nicht angezeigt! Durch die Nutzung von SimpleX Chat erklären Sie sich damit einverstanden:\n- nur legale Inhalte in öffentlichen Gruppen zu versenden.\n- andere Nutzer zu respektieren - kein Spam. - Datenschutzbestimmungen und Nutzungsbedingungen. + Datenschutz- und Nutzungsbedingungen. Akzeptieren Server-Betreiber konfigurieren Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. + Verbindungs-Link wird nicht unterstützt + Verkürzte Links verwenden (BETA) + Verkürzter Link + Vollständiger Link + SimpleX-Kanal-Link + Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index be71a7a927..6ba4c15afb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -2377,4 +2377,10 @@ Los chats privados, los grupos y tus contactos no son accesibles para los operadores de servidores. Al usar SimpleX Chat, aceptas:\n- enviar únicamente contenido legal en los grupos públicos.\n- respetar a los demás usuarios - spam prohibido. Configurar operadores de servidores + Enlace de canal SimpleX + Enlace completo + Enlace corto + Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible. + Enlace de conexión no compatible + Usar enlaces cortos (BETA) 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 5ab5dd8a63..6d62d3f5b8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -29,7 +29,7 @@ Elfogadja a meghívási kérést? Elfogadás Elfogadás - Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön partnerei számára. + Cím hozzáadása a profilhoz, hogy a partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve partnerei számára. További kiemelőszín híváshiba Csoporttagok letiltása @@ -48,7 +48,7 @@ Megjegyzés: az üzenet- és fájltovábbító kiszolgálók SOCKS-proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön partnerei számára. + A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új, véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. @@ -287,7 +287,7 @@ Hitelesítés törlése készítő Megerősítés - Csak számomra + Csak nálam Töröl %d üzenetet? Egyéni témák kapcsolódás (elfogadva) @@ -475,7 +475,7 @@ TCP életben tartása Kamera váltás Üdvözlöm!\nCsatlakozzon hozzám a SimpleX Chaten keresztül: %s - A megjelenített név nem tartalmazhat szóközöket. + A megjelenítendő név nem tartalmazhat szóközöket. Csoport Adja meg az üdvözlőüzenetet… (nem kötelező) Hiba történt a csevegési adatbázis exportálásakor @@ -740,7 +740,7 @@ Új meghívási kérés Csatlakozás a csoporthoz Társított számítógép beállítások - meghíva az Ön csoporthivatkozásán keresztül + meghíva a saját csoporthivatkozásán keresztül elhagyta a csoportot Társított számítógépek Nincs alkalmazás jelkód @@ -835,7 +835,7 @@ Hamarosan további fejlesztések érkeznek! kikapcsolva SimpleX Chat telepítése a terminálhoz - Új megjelenített név: + Új megjelenítendő név: Új jelmondat… nem fogadott hívás Átköltöztetés: %s @@ -926,7 +926,7 @@ (beolvasás, vagy beillesztés a vágólapról) Várakozás a videóra Válasz - Ez az Ön egyszer használható meghívója! + Ez a saját egyszer használható meghívója! SimpleX Chat hívások Új inkognitóprofil használata Frissítse az alkalmazást, és lépjen kapcsolatba a fejlesztőkkel. @@ -1038,14 +1038,14 @@ Kiszolgálók mentése Üdvözlőüzenet mp - A profilfrissítés el lesz küldve az Ön partnerei számára. + A profilfrissítés el lesz küldve a partnerei számára. Egyszerűsített inkognitómód Menti az üdvözlőüzenetet? Új csevegési fiók létrehozásához indítsa újra az alkalmazást. Engedély megtagadva! Függőben lévő hívás Adatbázis megnyitása… - Leállítás? + Leállítja az alkalmazást? Jelmondat szükséges Privát értesítések Ön meghívta egy partnerét @@ -1061,7 +1061,7 @@ Adatbázis-jelmondat beállítása Üzenetbuborék színe Időszakosan indul - Ez az Ön SimpleX-címe! + Ez a saját SimpleX-címe! eltávolítva Megosztás SimpleX csapat @@ -1219,7 +1219,7 @@ Védje meg a csevegési profiljait egy jelszóval! Csak a partnere tud eltűnő üzeneteket küldeni. Saját ICE-kiszolgálók - QR-kód beolvasása számítógépről + QR-kód beolvasása a számítógépről SimpleX logó Feloldás Némítás megszüntetése @@ -1262,7 +1262,7 @@ SimpleX Chat-kiszolgálók használata? Csevegési profil felfedése Videók és fájlok legfeljebb 1GB méretig - TCP kapcsolat időtúllépése + TCP-kapcsolat időtúllépése A(z) %1$s nevű profilja meg lesz osztva. Ön már kapcsolódott a következőhöz: %1$s. A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, partnerei, csevegési üzenetei és fájljai véglegesen törölve lesznek. @@ -1341,7 +1341,7 @@ Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> A csevegési profilja el lesz küldve\na partnere számára - Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban + Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban %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 @@ -1361,7 +1361,7 @@ 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. Ön a következőre módosította %s szerepkörét: „%s” Csoportmeghívó elutasítva - Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. + 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? @@ -1380,7 +1380,7 @@ A partnerei engedélyezhetik a teljes üzenet törlését. A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. Ha engedélyezni szeretné a hordozható eszköz-alkalmazás társítását a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, miután engedélyezte azt - A profilja, a partnerei és az elküldött üzenetei az Ön eszközén vannak tárolva. + A profilja, a partnerei és az elküldött üzenetei a saját eszközén vannak tárolva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> Ez a karakterlánc nem egy meghívási hivatkozás! Új csevegés indításához @@ -1399,7 +1399,7 @@ 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 - %1$s nevű csoporthoz!]]> + %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. Ön irányítja csevegését! Kód hitelesítése a számítógépen @@ -1744,13 +1744,13 @@ Használjon privát útválasztást ismeretlen kiszolgálókkal. Mindig használjon privát útválasztást. Üzenet-útválasztási mód - Közvetlen üzenetküldés, ha az IP-cím védett és az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. - Közvetlen üzenetküldés, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. 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. - NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + 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. FÁJLOK IP-cím védelme @@ -1834,7 +1834,7 @@ Kapcsolódás Hibák Függőben - Statisztikagyűjtés kezdete: %s.\nAz összes adat privát módon van tárolva az Ön eszközén. + Statisztikagyűjtés kezdete: %s.\nAz összes adat privát módon van tárolva az eszközén. Elküldött üzenetek Proxyzott kiszolgálók Újrakapcsolódik a kiszolgálókhoz? @@ -1999,7 +1999,7 @@ Mentés és újrakapcsolódás Használja az alkalmazást egy kézzel. A partnerek archiválása a későbbi csevegéshez. - TCP kapcsolat + TCP-kapcsolat Az exportált archívumot elmentheti. Tippek visszaállítása Csevegési lista átváltása: @@ -2090,7 +2090,7 @@ Hibák a kiszolgálók konfigurációjában. Hiba történt a feltételek elfogadásakor Kézbesítetlen üzenetek - A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön partnere lehet, hogy offline állapotban van. + A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van. Nincs üzenet Ez az üzenet törölve lett vagy még nem érkezett meg. Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. @@ -2109,7 +2109,7 @@ Kiszolgálóüzemeltetők Hálózatüzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. - Például, ha az Ön partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. + Például, ha a partnere egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. Jelölje ki a használni kívánt hálózatüzemeltetőket. Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. @@ -2159,7 +2159,7 @@ Elhomályosítás Hálózati decentralizáció A második előre beállított üzemeltető az alkalmazásban! - A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a jobb metaadat-adatvédelem érdekében. + A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. Alkalmazás-eszköztárak a metaadatok jobb védelme érdekében. Javított csevegési navigáció @@ -2195,7 +2195,7 @@ A csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára A tagok közötti közvetlen üzenetek le vannak tiltva. Üzleti csevegések - Az Ön ügyfeleinek adatvédelme. + Saját ügyfeleinek adatvédelme. %1$s.]]> A csevegés már létezik! Csökkentse az üzenet méretét, és küldje el újra. @@ -2240,7 +2240,7 @@ Törlés Törli a listát? Szerkesztés - Listanév… + Lista neve… Az összes lista nevének és emodzsijának különbözőnek kell lennie. Nincsenek csevegések a(z) %s nevű listában. Jegyzetek @@ -2291,7 +2291,7 @@ Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. 1 év alapértelmezett (%s) - Csevegési üzenetek törlése az Ön eszközéről. + Csevegési üzenetek törlése a saját eszközéről. Módosítja az automatikus üzenettörlést? Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. A következő TCP-port használata, amikor nincs port megadva: %1$s. @@ -2344,4 +2344,10 @@ Adatvédelmi szabályzat és felhasználási feltételek. A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára. Kiszolgálóüzemeltetők beállítása + Nem támogatott kapcsolattartási hivatkozás + Rövid hivatkozások használata (béta) + Rövid hivatkozás + Teljes hivatkozás + Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől. + SimpleX-csatornahivatkozás 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 278b94feb3..95b60a3394 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -1696,7 +1696,7 @@ negosiasi ulang enkripsi diperbolehkan negosiasi ulang enkripsi diperlukan negosiasi ulang enkripsi diizinkan untuk %s - Aktifkan flux + Aktifkan Flux di pengaturan Jaringan dan server untuk privasi metadata yang lebih baik. untuk privasi metadata lebih baik. Navigasi obrolan ditingkatkan Galat @@ -2337,4 +2337,16 @@ Atur pesan kedaluwarsa obrolan. Nama berkas media pribadi. ditolak + Anggota akan dihapus dari grup - tindakan ini tidak dapat dibatalkan! + moderator + Pesan dari anggota ini akan ditampilkan! + Sebutkan anggota 👋 + Atur obrolan ke dalam daftar + Frasa sandi di Keystore tidak dapat dibaca. Hal ini mungkin terjadi setelah pembaruan sistem yang tidak kompatibel dengan aplikasi. Jika tidak demikian, silakan hubungi pengembang. + Terima + Dengan menggunakan SimpleX Chat, Anda setuju untuk:\n- hanya mengirim konten legal di grup publik.\n- hormati pengguna lain – tidak ada spam. + Konfigurasikan operator server + Kebijakan privasi dan ketentuan penggunaan. + Obrolan pribadi, grup, dan kontak Anda tidak dapat diakses oleh operator server. + Frasa sandi di Keystore tidak dapat dibaca, silakan masukkan secara manual. Hal ini mungkin terjadi setelah pembaruan sistem yang tidak kompatibel dengan aplikasi. Jika tidak demikian, silakan hubungi pengembang. 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 08a8f9b87e..4373091266 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2353,7 +2353,7 @@ Ricevi una notifica quando menzionato. Aiuta gli amministratori a moderare i loro gruppi. Menziona i membri 👋 - Privacy e sicurezza migliorate + Privacy e sicurezza migliori Invio dei messaggi più veloce. Prestazioni dei gruppi migliorate Organizza le chat in elenchi @@ -2381,4 +2381,10 @@ Accetta Configura gli operatori dei server Informativa sulla privacy e condizioni d\'uso. + Questo link richiede una versione più recente dell\'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile. + Link completo + Link breve + Link del canale SimpleX + Link di connessione non supportato + Usa link brevi (BETA) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index ab78cf51f3..1103e3a0e6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -371,7 +371,7 @@ מופעל מופעל עבור איש הקשר מופעל עבורך - הודעות נעלמות אסורות בקבוצה זו. + הודעות נעלמות אסורות. %d דקה %d שנ׳ הודעות נעלמות @@ -489,10 +489,10 @@ הקבוצה תימחק עבורך – לא ניתן לבטל זאת! הסתר העדפות קבוצה - חברי קבוצה יכולים למחוק הודעות שנשלחו באופן בלתי הפיך. (24 שעות) - חברי הקבוצה יכולים לשלוח הודעות נעלמות. - חברי הקבוצה יכולים לשלוח הודעות ישירות. - חברי הקבוצה יכולים לשלוח הודעות קוליות. + משתמשים יכולים למחוק הודעות שנשלחו באופן בלתי הפיך. (24 שעות) + משתמשים יכולים לשלוח הודעות נעלמות. + משתמשים יכולים לשלוח הודעות ישירות. + יכולים לשלוח הודעות קוליות. אפשר השמדה עצמית אם תבחרו לדחות השולח לא יקבל התראה על כך. אם תאשרו, שרתי העברת ההודעות יוכלו לראות את ה־IP שלכם, וספק האינטרנט שלכם – את השרתים אליהם אתם מחוברים. @@ -505,7 +505,7 @@ התעלם מיד ייבא מסד נתונים - חסין מפני ספאם ושימוש לרעה + חסין מפני ספאם לייבא מסד נתונים של צ׳אט\? תמונה נשלחה התמונה תתקבל כאשר איש הקשר יסיים להעלות אותה. @@ -521,10 +521,10 @@ זהות נסתרת באמצעות קישור קבוצה זהות נסתרת באמצעות קישור חד־פעמי קישור חיבור לא תקין - אפשרו ל-SimpleX לפעול ברקע בתיבת הדו-שיח הבאה. אחרת, ההתראות יושבתו.]]> + אפשר זאת בתיבת הדו-שיח הבאה כדי לקבל התראות על הודעות חדשות באופן מיידי.]]> התראות מיידיות מושבתות! הזמן חברי קבוצה - הוזמן + הזמין את עקיף (%1$s) מצב זהות נסתרת מגן על הפרטיות שלך על ידי שימוש בפרופיל אקראי חדש עבור כל איש קשר. גרסת מסד נתונים לא תואמת @@ -556,14 +556,14 @@ הזמן לקבוצה הזמן חברי קבוצה מחיקה בלתי הפיכה של הודעות אסורה בצ׳אט זה. - מחיקה בלתי הפיכה של הודעות אסורה בקבוצה זו. + מחיקת הודעות בלתי הפיכה אסורה. להצטרף בתור %s זה מאפשר חיבורים אנונימיים רבים ללא שום נתונים משותפים ביניהם בפרופיל צ׳אט יחיד. זה יכול לקרות כאשר: \n1. פג תוקפן של ההודעות בלקוח השולח לאחר 2 ימים או בשרת לאחר 30 ימים. \n2. פיענוח הצפנת הודעה נכשל, מכיוון שאתם או איש הקשר שלכם השתמשתם בגיבוי ישן של מסד הנתונים. \n3. החיבור נפגע. - ניתן לשנות זאת מאוחר יותר באמצעות ההגדרות. + איך זה משפיע על הסוללה זה יכול לקרות כאשר אתם או איש הקשר שלכם השתמשתם בגיבוי ישן של מסד הנתונים. להצטרף לקבוצה\? הצטרף @@ -608,7 +608,7 @@ קישור הזמנה חד־פעמי מרקדאון בהודעות רשת ושרתים - הגדרות רשת + הגדרות מתקדמות ארכיון מסד נתונים חדש הודעות חבר קבוצה @@ -690,7 +690,7 @@ תגובות אמוג׳י להודעות אסורות בקבוצה זו. אפשר לאנשי הקשר להוסיף תגובות אמוג׳י להודעות. אפשר תגובות אמוג׳י להודעות רק אם איש הקשר מאפשר אותן. - חברי הקבוצה יכולים להוסיף תגובות אמוג׳י להודעות. + משתמשים יכולים להוסיף תגובות אמוג׳י להודעות. רק אתם יכולים להוסיף תגובות אמוג׳י להודעות. רק איש הקשר שלכם יכול להוסיף תגובות אמוג׳י להודעות. פתח @@ -737,7 +737,7 @@ שרת מוגדר מראש פרטיות מוגדרת מחדש אנשים יכולים להתחבר אליכם רק דרך הקישורים שאתם משתפים. - פרוטוקול וקוד פתוחים – כל אחד יכול להריץ את השרתים. + כל אחד יכול לארח שרתים. תקופתי נא להזין את הסיסמה הקודמת לאחר שחזור גיבוי מסד הנתונים, לא ניתן לבטל פעולה זו. לאסור מחיקה בלתי הפיכה של הודעות. @@ -746,7 +746,7 @@ אנא בידקו את חיבור האינטרנט שלכם עם %1$s ונסו שוב. ייתכן שטביעת האצבע של התעודה בכתובת השרת שגויה פתיחת מסוף צ׳אט - פתיחת פרופילי צ׳אט + שנה פרופילי צ׳אט ממתין כתובת שרת מוגדר מראש סיסמה להצגה @@ -1016,7 +1016,7 @@ יותר מדי תמונות! תודה שהתקנתם את SimpleX Chat! קישור זה אינו קישור חיבור תקין! - צבעי ערכת נושא + צבעי ממשק התפקיד ישתנה ל־"%s". החבר יקבל הזמנה חדשה. השרתים לחיבורים חדשים של פרופיל הצ׳אט הנוכחי שלך הפלטפורמה הראשונה ללא כל מזהי משתמש - פרטית בעיצובה. @@ -1054,7 +1054,7 @@ שדרג ופתח צ׳אט כדי להגן על אזור הזמן, קובצי תמונה/קול משתמשים ב־UTC. העלה קובץ - שירות רקע SimpleX – הוא משתמש בכמה אחוזים מהסוללה ביום.]]> + SimpleX רץ ברקע במקום להשתמש בpush notifications.]]> כדי לקבל התראות, יש להזין את סיסמת מסד הנתונים בטל נעילה שליחה לא מורשית @@ -1239,15 +1239,15 @@ כיבוי אפליקציה אפשר לשלוח קבצים ומדיה. - מועדף + הוסף למועדפים קבצים ומדיה אין צ\'אטים מסוננים לכבות\? קבצים ומדיה אסורים! - קבצים ומדיה אסורים בקבוצה זו. - חברי הקבוצה יכולים לשלוח קבצים ומדיה. + קבצים ומדיה אסורים. + משתמשים יכולים לשלוח קבצים ומדיה. איתחול - שנוא + הסר מהמועדפים כבוי קו חוצה לאסור שליחת קבצים ומדיה. @@ -1528,8 +1528,8 @@ הצג קריאות API איטיות אפשרויות למפתח צור פרופיל - ו %d שאר האירועים - הגדר כתובת איש קשר חדש + בנוסף ל- %d אירועים אחרים + איש הקשר הגדיר כתובת חדשה לחץ לחיבור דפדפן האינטרנט המוגדר כברירת מחדל נדרש לשיחות. אנא הגדר דפדפן ברירת מחדל במערכת, ושתף מידע נוסף עם המפתחים. השיחה הזו מוגנת באמצעות הצפנה קצה-אל-קצה. @@ -1609,7 +1609,7 @@ החיבור עצר נתיב קובץ לא חוקי שיתפת נתיב קובץ לא חוקי. דווח על הבעיה למפתחי האפליקציה. - %1$d הודעות שנערכו על ידי %2$s + %1$d הודעות נחסמו על ידי %2$s %d הודעות סומנו כנמחקות האם לחזור על בקשת החיבור? חסום @@ -1632,13 +1632,13 @@ טעינה של הקובץ שימוש ממחשב שולחני חסומים %s - מחק איש קשר + איש קשר נמחק %d אירועי קבוצה %s, %s ו-%d חברים איש הקשר %1$s השתנה ל-%2$s כתובת איש קשר הוסרה תמונת פרופיל הוסרה - הגדר תמונת פרופיל חדשה + הגדיר תמונת פרופיל חדשה עדכן פרופיל מצב לא ידוע נוצר ב @@ -1722,7 +1722,7 @@ מרובע, עיגול, או כל דבר ביניהם העבר ושמור הודעות מתי שמתחבר שחיות קוליות ווידאו. - לא מצליח לשלוח הודעה + לא ניתן לשלוח הודעה הודעות קוליות לא מאופשרות שגיאת קובץ זמני קבצים ומדיה לא מאופשרים @@ -1748,7 +1748,7 @@ מצב הקובץ:%s ריק כהה - מצב צבעוני + ערכת נושא שחור בהיר אפס צבע @@ -1767,8 +1767,8 @@ הגדר ערכת נושא ברירת מחדל אפס ערכת נושא למשתמש החל ל - מצב כל הצבעים - חברי הקבוצה יכולים לשלוח קישורי SimpleX + ערכת נושא + משתמשים יכולים לשלוח קישורי SimpleXצ עשה שהצאט\'ים שלך יראו אחרת! הגדרות רשת הקישור הזה שומש כבר במכשיר אחר, אנא צור קישור חדש במחשב. @@ -1918,4 +1918,177 @@ שרת XFTP חלש אנשי קשר בארכיון + דיווח בארכיון + הפרה של הנחיות קהילתיות + %1$d שגיאת קבצים:\n%2$s + %1$d הקבצים עדיין בהורדה. + הסכם לתנאים + %1$d ההורדה של הקובץ/ים עדיין לא הסתיימה. + התנאים המקובלים עלי + שנה + סיבה אחרת + כתובת עסקית + שיפור בסידור של הודעות לפי תאריכים. + שיפור ביצועים לקבוצות + התקשר + לא ניתן להתקשר לחבר קבוצה + %s.]]> + להעביר דיווח לארכיון? + מסד נתונים של הצא\'טים + בדוק עבור הודעות חדשות כל 10 דקות + אישרת את תנאי השימוש ב:%s. + הצ\'אט יימחק עבור כל החברים - לא ניתן לבטל את זה! + לחץ על כפתור מידע ליד שדה כתובת כדי לאפשר שימוש במיקרופון. + %s.]]> + %s.]]> + העבר אנשי קשר לארכיון לשוחח מאוחר יותר + לשנות את מחיקת ההודעה האוטומטית? + הצ\'אט כבר קיים! + הכל + %1$d שגיאה/ות קובץ אחר/ות. + %1$d הקובץ/ים נכשל/ו בהורדה. + כל ההודעות החדשות מחברים אלו יוסתרו! + לחסום את חברי הקבוצה לכולם? + הדיווח הועבר לארכיון ע\"י %s + עם איש קשר אחד בלבד - שתף באופן אישי או באמצעות כל מסנג\'ר.]]> + טשטוש בשביל שיפור הפרטיות. + דיווח 1 + כל הצ\'אטים יוסרו מהרשימת %s, והרשימה תימחק + שנה רשימה + לאפשר שיחות? + הצ\'אט יימחק עבורך - אי אפשר לבטל את זה! + שיחות לא מורשות! + שאל + %s.]]> + מכשירי שיואמי: אנא תאפשר הפעלה אוטומטית בהגדרות הטלפון שלך כדי שההתראות על הודעות חדשות יפעלו.]]> + %1$s ההודעות לא הועברו. + מוצפנים מקצה לקצה, עם אבטחה פוסט-קוונטית בהודעות ישירות.]]> + טישטוש + עסקי + %s, קבל את תנאי השימוש.]]> + כל הדיווחים אצלך יועברו לארכיון. + להעביר לארכיון %d דיווחים? + להעביר לארכיון את כל הדיווחים? + העבר דיווח לארכיון + דיווחים בארכיון + אפשר לדווח על הודעות למנהלים. + %1$d הקובץ/ים נמחקו. + %s.]]> + המסד נתונים יוצא בהצלחה + %1$s כבר באנשי קשר.]]> + לא ניתן לשלוח הודעה לחבר קבוצה + קבל הזמנה + על המפעילים + לא ניתן להתקשר לאיש קשר + שיפור לשיחות + שנה את הסדר + ארכיון + אני מסכים + שיפור בפרטיות ובאבטחה + אבטחה יותר טובה✅ + ממשק משתמש יותר נוח + "גרסאת שרת היעד %1$s אינה תואמת עם שרת ההעברה %2$s." + מחק עד 20 הודעות בבת אחת. + הודעות ישירות בין חברים חסומות. + השבת מחיקת הודעות + אל תשתמש בתעודות עם פרוקסי. + ירד + %d דיווחים + יורד %s (%s) + השבת מחיקת הודעות אוטומטית? + כפילויות + תנאי שימוש + מחק בלי להתריע + הורדה + הודעות ישירות בין חברים אסורות בצ\'אט זה. + אל תחמיץ הודעות חשובות. + הורד גרסאות חדשות מ GitHub. + פרופיל לא הולם + לפרטיות מטא דאטא טובה יותר. + אפשר Flux בהגדרות רשת ושרתים בשביל לשפר את הפרטיות של המטא דאטא + מעביר %1$s הודעות + שגיאה בקריאת משפט-סיסמה של מסד נתונים + משתמשים יכולים לדווח על הודעות לאחראי תוכן + שגיאת אתחול ב WebView, וודא שיש לך WebView מותקן והוא תותך בארכיטקטורה arm64\nשגיאה: %s + שגיאה בשמירת פרוקסי + לפרופיל צ\'אט %s: + למסלול פרטי + שגיאה בשמירת ההגדרות + שגיאה בהעברת ההודעות + שגיאה בהחלפת פרופיל + אפשר לוגים + איך זה משפר את הפרטיות + שיפור בגלילה בצ\'אט + תוכן לא הולם + שגיאה בקבלת תנאי שימוש + שגיאות בתצורת השרתים. + שרת ההעברות %1$s לא הצליח להתחבר לשרת היעד %2$s.נסה שוב במועד מאוחר יותר. + דיווחים מחברי הקבוצה + שרתי קבצים ומדיה + קישור לא תקין + שגיאה ביצירת רשימת צא\'טים חדשה + שגיאת עדכון רשימת צ\'אטים + שגיאה בטעינת רשימות הצא\'טים + ערוך + ודא שתצורת ה- proxy נכונה. + הזמן + זה מגן על כתובת ה- IP והחיבורים שלך. + עזוב צ\'אט + תקן + לתקן חיבור? + מחיקה מהירה יותר של קבוצות. + שליחת הודעות מהירה יותר. + העבר הודעות ללא קבצים? + הזמן לצ\'אט + שרת ההעברות: %1$s\nשגיאת שרת יעד: %2$s + שרת ההעברות: %1$s\nשגיאה: %2$s + למדיה חברתית + הגדל את גודל הגופן. + רשימה + מעודפים + קבוצות + שם הרשימה.. + שם הרשימה והאמוג\'י צריכים להיות שונים משאר הרשימות. + שמור שיחה + הזמן + ממשק בשפה הליטאית + הקובץ נחסם ע\"י מפעיל השרת:\n%1$s. + בשבילי + לכל האחראי תוכן + העבר הודעות… + שגיאה בשמירת המסד נתונים + שגיאה בהוספת שרת + שגיאת עדכון שם שרת + להעביר %1$s הודעה/ות? + שגיאה בשליחת הדיווח + שגיאה בשמירת שרתים + לעזוב את הצ\'אט? + העבר עד 20 הודעות בבת אחת. + קבל התראה כאשר מתייגים אותך + עזור לאחראי תוכן בדיווחים על תוכן בעייתי בקבוצות + הוסף רשימה + הדגשה נוספת 2 + בשימוש שלך ב- SimpleX Chat אתה מסכים ל:\n\n- לשלוח רק תוכן חוקי בקבוצות ציבוריות.\n\n- לכבד את שאר המשתמשים - לא לשלוח ספאם. + %s.]]> + %s.]]> + הוסף חברים + שרתי הודעות שנוספו + שרתי מדיה וקבצים שנוספו + הוסף לרשימה + חיבורים פעילים + הוסף את חברי הצוות שלך לשיחות. + האפליקציה תמיד רצה ברקע + הגדרות כתובת + סרגל הכלים של האפליקציה + סשן לאפליקציה + כתובת או קישור חד פעמי? + הוסף חברי צוות + %s.]]> + צ\'אט + צא\'טים עסקיים + "הזבל נמחק" + אנשי קשר + איש הקשר יימחק - לא ניתן לבטל זאת! + איש הקשר נמחק. + ספאם diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index d0b3cd5f53..d07cb6db39 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -2183,7 +2183,7 @@ Netwerk decentralisatie De tweede vooraf ingestelde operator in de app! Als uw contactpersoon bijvoorbeeld berichten ontvangt via een SimpleX Chat-server, worden deze door uw app via een Flux-server verzonden. - Flux inschakelen + Schakel Flux in bij Netwerk- en serverinstellingen voor betere privacy van metagegevens. Geen bericht App-werkbalken Vervagen @@ -2373,4 +2373,15 @@ Bijgewerkte voorwaarden Wachtwoord in Keystore kan niet worden gelezen. Dit kan zijn gebeurd na een systeemupdate die niet compatibel is met de app. Als dit niet het geval is, neem dan contact op met de ontwikkelaars. in behandeling + Accepteer + Door SimpleX Chat te gebruiken, gaat u ermee akkoord:\n- alleen legale content te versturen in openbare groepen.\n- andere gebruikers te respecteren – geen spam. + SimpleX channel link + Voor deze link is een nieuwere app-versie vereist. Werk de app bij of vraag je contactpersoon om een compatibele link te sturen. + Volledige link + Niet-ondersteunde verbindingslink + Gebruik korte links (BETA) + Korte link + Serveroperators configureren + Privacybeleid en gebruiksvoorwaarden. + Privéchats, groepen en uw contacten zijn niet toegankelijk voor serverbeheerders. 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 ce6d5fbfa8..ad2a7e67be 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -496,7 +496,7 @@ Забороняйте надсилання повідомлень, які зникають. Забороняйте невідворотне видалення повідомлень. Учасники можуть надсилати голосові повідомлення. - %dm + %dм Нове в %s Самознищуючий пароль Італійський інтерфейс @@ -528,7 +528,7 @@ Встановити на 1 день Забороняйте реакції на повідомлення. Реакції на повідомлення заборонені в цьому чаті. - %ds + %dс хвилини Китайський та іспанський інтерфейс підключення %1$d @@ -756,7 +756,7 @@ %d година %d тиждень %d тижні - %dw + %dтиж запропоновано %s: %2s З опційним вітанням. Приховуйте екран додатка в останніх програмах. @@ -876,7 +876,7 @@ Надіслано о: %s Видалено о: %s %s (поточне) - %dh + %dч %d день %d днів скасовано %s @@ -985,7 +985,7 @@ Змінити роль Ви все ще отримуватимете дзвінки та сповіщення від приглушених профілів, коли вони активні. %d місяці - %dmth + %dміс Надіслані повідомлення будуть видалені після встановленого часу. Відкриття бази даних… Помилка встановлення адреси @@ -1133,7 +1133,7 @@ Підключитися Щоб показати ваш схований профіль, введіть повний пароль у поле пошуку на сторінці Ваші профілі. Підтвердити пароль - %dd + %dд Захистіть свої чат-профілі паролем! Помилка дешифрування Сервер вимагає авторизації для завантаження, перевірте пароль @@ -2179,7 +2179,7 @@ Текст поточних умов не вдалося завантажити, ви можете переглянути умови за цим посиланням: Помилки в конфігурації серверів. Умови приймаються з: %s. - Увімкнути flux + Увімкніть Flux у налаштуваннях мережі та серверів для кращої конфіденційності метаданих Умови приймаються до: %s. Продовжити Створити одноразове посилання @@ -2373,4 +2373,15 @@ Учасників буде видалено з чату – це неможливо скасувати! Розблокувати учасників для всіх? Оновлені умови + Приватні чати, групи та ваші контакти недоступні для операторів сервера. + Прийняти + Використовуючи SimpleX Chat, ви погоджуєтесь на:\n- надсилати тільки легальний контент у публічних групах.\n- поважати інших користувачів – без спаму. + Налаштувати операторів сервера + Політика конфіденційності та умови використання + Використовувати короткі посилання (BETA) + Це посилання вимагає новішої версії додатку. Будь ласка, оновіть додаток або попросіть вашого контакту надіслати сумісне посилання. + Повне посилання + Коротке посилання + Посилання на канал SimpleX + Несумісне посилання для підключення diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 311d492892..99931215e7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -828,7 +828,7 @@ Chào buổi chiều! Giao diện tiếng Pháp Đã tìm thấy máy tính - Đầy đủ đường dẫn + Toàn bộ đường dẫn Từ Thư viện Tên đầy đủ: Tuyệt đối phi tập trung - chỉ hiển thị cho thành viên. @@ -2352,4 +2352,10 @@ Các cuộc trò chuyện riêng tư, nhóm và liên hệ của bạn không thể truy cập được đối với các bên vận hành máy chủ. Chấp nhận Định cấu hình các bên vận hành máy chủ + Đường dẫn này yêu cầu một phiên bản ứng dụng mới hơn. Vui lòng nâng cấp ứng dụng hoặc yêu cầu liên hệ của một gửi cho một đường dẫn tương thích. + Đường dẫn kênh SimpleX + Đường dẫn kết nối không được hỗ trợ + Sử dụng đường dẫn ngắn (BETA) + Toàn bộ đường dẫn + Đường dẫn ngắn 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 24abd90a31..1958885843 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 @@ -2365,4 +2365,10 @@ 使用 SimpleX Chat 代表您同意:\n- 在公开群中只发送合法内容\n- 尊重其他用户 – 没有垃圾信息。 服务器运营方无法访问私密聊天、群组和你的联系人。 配置服务器运营方 + 不支持的连接链接 + 使用短链接(测试) + SimpleX 频道链接 + 短链接 + 此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。 + 完整链接 From 96b962809fb7f8f6916e7332fe05cf32dceb4e39 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 23 Apr 2025 13:09:16 +0100 Subject: [PATCH 186/289] core: fix connecting via short links --- src/Simplex/Chat/Library/Commands.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 890a27f573..5c544e3397 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3159,9 +3159,9 @@ processChatCommand' vr = \case hash :: ConnReqContact -> ConnReqUriHash hash = ConnReqUriHash . C.sha256Hash . strEncode getShortLinkConnReq :: User -> ConnShortLink m -> CM (ConnectionRequestUri m) - getShortLinkConnReq User {userId} l = do + getShortLinkConnReq user l = do l' <- restoreShortLink' l - (cReq, cData) <- withAgent (\a -> getConnShortLink a userId l') + (cReq, cData) <- withAgent (\a -> getConnShortLink a (aUserId user) l') case cData of ContactLinkData {direct} | not direct -> throwChatError CEUnsupportedConnReq _ -> pure () From 5351fa68d06bf24f2910b4393b72da4f6983e6e1 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 23 Apr 2025 12:27:30 +0000 Subject: [PATCH 187/289] ci: switch to sha256 and skip 8.10.7 on release (#5837) * ci: skip 8.10.7 on release * ci: switch to sha256 * script/reproduce-builds: make it executable * scripts/reproduce-builds: rename to simplex-chat-reproduce-builds * ci: bump actions * ci: 20.04 is deprecated * scripts/reproduce-builds: remove Ubuntu 20.04 * docs: adjust reproduce script * ci: skip 8.10.7 in stable or release for Linux * ci: really skup 8.10.7 in stable or release * ci: remove useless linux checks * ci: remove timeout from mac tests * ci: fix action names * ci: setup swap for 8.10.7 * ci: bump swap to 30gb * ci: simplify * ci: 10 -> 3 retries * ci: retry only in stable or release --- .github/actions/prepare-build/action.yml | 5 - .github/actions/prepare-release/action.yml | 4 +- .github/actions/swap/action.yml | 44 ++++++++ .github/workflows/build.yml | 102 +++++++++++++----- docs/SERVER.md | 6 +- ...ds.sh => simplex-chat-reproduce-builds.sh} | 12 +-- 6 files changed, 132 insertions(+), 41 deletions(-) create mode 100644 .github/actions/swap/action.yml rename scripts/{reproduce-builds.sh => simplex-chat-reproduce-builds.sh} (88%) mode change 100644 => 100755 diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml index 6682641419..ce75b7a57c 100644 --- a/.github/actions/prepare-build/action.yml +++ b/.github/actions/prepare-build/action.yml @@ -24,11 +24,6 @@ inputs: runs: using: "composite" steps: - - name: Skip unreliable ghc 8.10.7 build on stable branch - shell: bash - if: inputs.ghc_ver == '8.10.7' && inputs.github_ref == 'refs/heads/stable' - run: exit 0 - - name: Setup Haskell uses: simplex-chat/setup-haskell-action@v2 with: diff --git a/.github/actions/prepare-release/action.yml b/.github/actions/prepare-release/action.yml index e44e6ef0f2..e0d32bd596 100644 --- a/.github/actions/prepare-release/action.yml +++ b/.github/actions/prepare-release/action.yml @@ -19,7 +19,7 @@ inputs: runs: using: "composite" steps: - - name: Linux upload AppImage to release + - name: Upload file with specific name if: startsWith(inputs.github_ref, 'refs/tags/v') uses: simplex-chat/upload-release-action@v2 with: @@ -28,7 +28,7 @@ runs: asset_name: ${{ inputs.bin_name }} tag: ${{ inputs.github_ref }} - - name: Linux update AppImage hash + - name: Add hash to release notes if: startsWith(inputs.github_ref, 'refs/tags/v') uses: simplex-chat/action-gh-release@v2 env: diff --git a/.github/actions/swap/action.yml b/.github/actions/swap/action.yml new file mode 100644 index 0000000000..87d670b147 --- /dev/null +++ b/.github/actions/swap/action.yml @@ -0,0 +1,44 @@ +name: 'Set Swap Space' +description: 'Add moar swap' +branding: + icon: 'crop' + color: 'orange' +inputs: + swap-size-gb: + description: 'Swap space to create, in Gigabytes.' + required: false + default: '10' +runs: + using: "composite" + steps: + - name: Swap space report before modification + shell: bash + run: | + echo "Memory and swap:" + free -h + echo + swapon --show + echo + - name: Set Swap + shell: bash + run: | + export SWAP_FILE=$(swapon --show=NAME | tail -n 1) + echo "Swap file: $SWAP_FILE" + if [ -z "$SWAP_FILE" ]; then + SWAP_FILE=/opt/swapfile + else + sudo swapoff $SWAP_FILE + sudo rm $SWAP_FILE + fi + sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE + sudo chmod 600 $SWAP_FILE + sudo mkswap $SWAP_FILE + sudo swapon $SWAP_FILE + - name: Swap space report after modification + shell: bash + run: | + echo "Memory and swap:" + free -h + echo + swapon --show + echo diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de0b976bcc..ca1bc79510 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,7 +73,7 @@ jobs: - name: Build changelog id: build_changelog if: startsWith(github.ref, 'refs/tags/v') - uses: simplex-chat/release-changelog-builder-action@v4 + uses: simplex-chat/release-changelog-builder-action@v5 with: configuration: .github/changelog_conf.json failOnError: true @@ -84,7 +84,7 @@ jobs: - name: Create release if: startsWith(github.ref, 'refs/tags/v') - uses: simplex-chat/action-gh-release@v1 + uses: simplex-chat/action-gh-release@v2 with: body: ${{ steps.build_changelog.outputs.changelog }} prerelease: true @@ -106,26 +106,38 @@ jobs: fail-fast: false matrix: include: - - os: 20.04 + - os: 22.04 ghc: "8.10.7" - - os: 20.04 - ghc: ${{ needs.variables.outputs.GHC_VER }} - cli_asset_name: simplex-chat-ubuntu-20_04-x86-64 - desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb + should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} - os: 22.04 ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-ubuntu-22_04-x86-64 desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb + should_run: true + - os: 24.04 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-ubuntu-24_04-x86-64 + desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb + should_run: true steps: - name: Checkout Code + if: matrix.should_run == true uses: actions/checkout@v3 + - name: Setup swap + if: matrix.ghc == '8.10.7' && matrix.should_run == true + uses: ./.github/actions/swap + with: + swap-size-gb: 30 + # Otherwise we run out of disk space with Docker build - name: Free disk space + if: matrix.should_run == true shell: bash run: ./scripts/ci/linux_util_free_space.sh - name: Restore cached build + if: matrix.should_run == true uses: actions/cache@v4 with: path: | @@ -134,9 +146,11 @@ jobs: key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - name: Set up Docker Buildx + if: matrix.should_run == true uses: simplex-chat/docker-setup-buildx-action@v3 - name: Build and cache Docker image + if: matrix.should_run == true uses: simplex-chat/docker-build-push-action@v6 with: context: . @@ -152,6 +166,7 @@ jobs: # --cap-add SYS_ADMIN # --security-opt apparmor:unconfined - name: Start container + if: matrix.should_run == true shell: bash run: | docker run -t -d \ @@ -165,6 +180,7 @@ jobs: build/${{ matrix.os }}:latest - name: Prepare cabal.project.local + if: matrix.should_run == true shell: bash run: | echo "ignore-project: False" >> cabal.project.local @@ -173,6 +189,7 @@ jobs: # chmod/git commands are used to workaround permission issues when cache is restored - name: Build CLI + if: matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*' @@ -188,22 +205,23 @@ jobs: strip /out/simplex-chat - name: Copy tests from container + if: matrix.should_run == true shell: bash run: | docker cp builder:/out/simplex-chat-test . - name: Copy CLI from container and prepare it id: linux_cli_prepare - if: startsWith(github.ref, 'refs/tags/v') && matrix.cli_asset_name + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: bash run: | docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }} path="${{ github.workspace }}/${{ matrix.cli_asset_name }}" echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload CLI - if: startsWith(github.ref, 'refs/tags/v') && matrix.cli_asset_name + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true uses: ./.github/actions/prepare-release with: bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }} @@ -213,7 +231,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Build Desktop - if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | scripts/desktop/build-lib-linux.sh @@ -222,16 +240,16 @@ jobs: - name: Prepare Desktop id: linux_desktop_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: bash run: | path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb ) echo "package_path=$path" >> $GITHUB_OUTPUT - echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload Desktop uses: ./.github/actions/prepare-release - if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true with: bin_path: ${{ steps.linux_desktop_build.outputs.package_path }} bin_name: ${{ matrix.desktop_asset_name }} @@ -240,22 +258,22 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Build AppImage - if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | scripts/desktop/make-appimage-linux.sh - name: Prepare AppImage id: linux_appimage_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true shell: bash run: | path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage) echo "appimage_path=$path" >> $GITHUB_OUTPUT - echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload AppImage - if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true uses: ./.github/actions/prepare-release with: bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }} @@ -265,15 +283,33 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Fix permissions for cache + if: matrix.should_run == true shell: bash run: | sudo chmod -R 777 dist-newstyle ~/.cabal sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal - name: Run tests + if: matrix.should_run == true + timeout-minutes: 120 shell: bash run: | - ./simplex-chat-test + i=1 + attempts=1 + ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3 + while [ "$i" -le "$attempts" ]; do + if ./simplex-chat-test; then + break + else + echo "Attempt $i failed, retrying..." + i=$((i + 1)) + sleep 1 + fi + done + if [ "$i" -gt "$attempts" ]; then + echo "All "$attempts" attempts failed." + exit 1 + fi # ========================= # MacOS Build @@ -332,7 +368,7 @@ jobs: cabal build -j --enable-tests path=$(cabal list-bin simplex-chat) echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') @@ -356,7 +392,7 @@ jobs: scripts/ci/build-desktop-mac.sh path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg) echo "package_path=$path" >> $GITHUB_OUTPUT - echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload Desktop if: startsWith(github.ref, 'refs/tags/v') @@ -369,9 +405,25 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Run tests - timeout-minutes: 40 + timeout-minutes: 120 shell: bash - run: cabal test --test-show-details=direct + run: | + i=1 + attempts=1 + ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3 + while [ "$i" -le "$attempts" ]; do + if cabal test --test-show-details=direct; then + break + else + echo "Attempt $i failed, retrying..." + i=$((i + 1)) + sleep 1 + fi + done + if [ "$i" -gt "$attempts" ]; then + echo "All "$attempts" attempts failed." + exit 1 + fi # ========================= # Windows Build @@ -443,7 +495,7 @@ jobs: rm -rf dist-newstyle/src/direct-sq* path=$(cabal list-bin simplex-chat | tail -n 1) echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') @@ -467,7 +519,7 @@ jobs: rm -rf dist-newstyle/src/direct-sq* path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') echo "package_path=$path" >> $GITHUB_OUTPUT - echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload Desktop if: startsWith(github.ref, 'refs/tags/v') diff --git a/docs/SERVER.md b/docs/SERVER.md index 57f49b5588..f45403be8a 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -1603,19 +1603,19 @@ To reproduce the build you must have: 1. Download script: ```sh - curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/master/scripts/reproduce-builds.sh' + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/master/scripts/simplexmq-reproduce-builds.sh' ``` 2. Make it executable: ```sh - chmod +x reproduce-builds.sh + chmod +x simplexmq-reproduce-builds.sh ``` 3. Execute the script with the required tag: ```sh - ./reproduce-builds.sh 'v6.3.1' + ./simplexmq-reproduce-builds.sh 'v6.3.1' ``` The script executes these steps (please review the script to confirm): diff --git a/scripts/reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh old mode 100644 new mode 100755 similarity index 88% rename from scripts/reproduce-builds.sh rename to scripts/simplex-chat-reproduce-builds.sh index 1334fec0ec..408d1d4a53 --- a/scripts/reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -24,13 +24,13 @@ cleanup() { } trap 'cleanup' EXIT INT -mkdir -p "$init_dir/$TAG/from-source" "$init_dir/$TAG/prebuilt" +mkdir -p "$init_dir/$TAG-$repo_name/from-source" "$init_dir/$TAG-$repo_name/prebuilt" git -C "$tempdir" clone "$repo.git" &&\ cd "$tempdir/${repo_name}" &&\ git checkout "$TAG" -for os in 20.04 22.04; do +for os in 22.04 24.04; do os_url="$(printf '%s' "$os" | tr '.' '_')" # Build image @@ -57,11 +57,11 @@ for os in 20.04 22.04; do docker cp \ builder:/out/simplex-chat \ - "$init_dir/$TAG/from-source/simplex-chat-ubuntu-${os_url}-x86-64" + "$init_dir/$TAG-$repo_name/from-source/simplex-chat-ubuntu-${os_url}-x86-64" # Download prebuilt postgresql binary curl -L \ - --output-dir "$init_dir/$TAG/prebuilt/" \ + --output-dir "$init_dir/$TAG-$repo_name/prebuilt/" \ -O \ "$repo/releases/download/${TAG}/simplex-chat-ubuntu-${os_url}-x86-64" @@ -87,7 +87,7 @@ cd "$init_dir" # Final stage: compare hashes # Path to binaries -path_bin="$init_dir/$TAG" +path_bin="$init_dir/$TAG-$repo_name" # Assume everything is okay for now bad=0 @@ -114,7 +114,7 @@ done # If everything is still okay, compute checksums file if [ "$bad" = 0 ]; then - sha256sum "$path_bin"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' > "$path_bin/_sha256sums" + sha256sum "$path_bin"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' -e "s|-$repo_name||g" > "$path_bin/_sha256sums" printf 'Checksums computed - %s\n' "$path_bin/_sha256sums" fi From 3257b60b703adc047e4fe1ad3e7aac55b7626612 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 23 Apr 2025 13:27:58 +0100 Subject: [PATCH 188/289] core: 6.3.3.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 a01345a910..291083368e 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.3.0 +version: 6.3.3.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 8c87851af7..d971656a26 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, 3, 0] +minRemoteCtrlVersion = AppVersion [6, 3, 3, 1] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 3, 0] +minRemoteHostVersion = AppVersion [6, 3, 3, 1] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version 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 189/289] 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 83b3d631f5e529d87a6b7ebac0bb73d8578c577e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 23 Apr 2025 18:14:43 +0100 Subject: [PATCH 190/289] 6.3.3: ios 273, android 285, desktop 99 --- 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 096d65cd64..5346e706a2 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.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.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 */; }; @@ -531,8 +531,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.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.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 = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a */, ); path = Libraries; sourceTree = ""; @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1988,7 +1988,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2013,7 +2013,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2038,7 +2038,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2055,11 +2055,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2075,11 +2075,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2100,7 +2100,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2115,7 +2115,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2137,7 +2137,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2152,7 +2152,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2174,7 +2174,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2200,7 +2200,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2225,7 +2225,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2251,7 +2251,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2310,7 +2310,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 272; + CURRENT_PROJECT_VERSION = 273; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2325,7 +2325,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.2; + MARKETING_VERSION = 6.3.3; 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 1e320972be..2dc5be4210 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.2 -android.version_code=283 +android.version_name=6.3.3 +android.version_code=285 -desktop.version_name=6.3.2 -desktop.version_code=98 +desktop.version_name=6.3.3 +desktop.version_code=99 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 36252339315cffcf8f2e271e0dfbf5c67d2e7d2d Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 24 Apr 2025 08:11:43 +0000 Subject: [PATCH 191/289] flatpak: update metainfo (#5846) --- .../flatpak/chat.simplex.simplex.metainfo.xml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 823b8562fc..82987e211a 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,29 @@
+ + https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +

New in v6.3.1-3:

+
    +
  • support for connecting via short connection links.
  • +
  • fix related to backward/forward compatibility of the app in some rare cases.
  • +
  • scrolling/navigation improvements.
  • +
  • faster onboarding (conditions and operators are combined to one screen).
  • +
+

New in v6.3.0:

+
    +
  • Mention members and get notified when mentioned.
  • +
  • Send private reports to moderators.
  • +
  • Delete, block and change role for multiple members at once
  • +
  • Faster sending messages and faster deletion.
  • +
  • Organize chats into lists to keep track of what's important.
  • +
  • Jump to found and forwarded messages.
  • +
  • Private media file names.
  • +
  • Message expiration in chats.
  • +
+
+
https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html 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 192/289] 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 623a46e41853902235d533e25ca2e7ddb2ae2da8 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 25 Apr 2025 11:16:41 +0100 Subject: [PATCH 193/289] website: new languages, update some texts (#5849) * website: new languages, update some texts * update --- website/langs/ar.json | 5 +- website/langs/bg.json | 2 +- website/langs/cs.json | 9 +- website/langs/de.json | 7 +- website/langs/en.json | 49 ++- website/langs/es.json | 9 +- website/langs/fi.json | 7 +- website/langs/fr.json | 9 +- website/langs/he.json | 5 +- website/langs/hu.json | 7 +- website/langs/it.json | 7 +- website/langs/ja.json | 5 +- website/langs/nl.json | 9 +- website/langs/pl.json | 7 +- website/langs/pt_BR.json | 7 +- website/langs/ru.json | 285 +++++++++--------- website/langs/uk.json | 7 +- website/langs/zh_Hans.json | 5 +- website/src/_data/languages.json | 25 +- .../_includes/sections/simplex_unique.html | 2 +- website/src/img/flags/fi.svg | 5 + website/src/img/flags/hu.svg | 7 + website/src/img/flags/il.svg | 14 + 23 files changed, 261 insertions(+), 233 deletions(-) create mode 100644 website/src/img/flags/fi.svg create mode 100644 website/src/img/flags/hu.svg create mode 100644 website/src/img/flags/il.svg diff --git a/website/langs/ar.json b/website/langs/ar.json index f257c1c747..fd759d5491 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -31,7 +31,7 @@ "simplex-explained-tab-2-p-1": "لكل اتصال، تستخدم قائمتي انتظار منفصلتين للمُراسلة لإرسال واستلام الرسائل عبر خوادم مختلفة.", "simplex-explained-tab-2-p-2": "تقوم الخوادم بتمرير الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", "simplex-explained-tab-3-p-1": "تحتوي الخوادم على بيانات اعتماد مجهولة منفصلة لكل قائمة انتظار، ولا تعرف المستخدمين الذين ينتمون إليهم.", - "copyright-label": "مشروع مفتوح المصدر © SimpleX 2020-2024", + "copyright-label": "مشروع مفتوح المصدر © SimpleX 2020-2025", "simplex-chat-protocol": "بروتوكول دردشة SimpleX", "developers": "المطورين", "hero-subheader": "أول نظام مُراسلة
دون معرّفات مُستخدم", @@ -133,8 +133,7 @@ "sign-up-to-receive-our-updates": "اشترك للحصول على آخر مستجداتنا", "enter-your-email-address": "أدخل عنوان بريدك الإلكتروني", "get-simplex": "احصل على تطبيق سطح المكتب SimpleX", - "why-simplex-is": "لماذا SimpleX", - "unique": "فريد من نوعه", + "why-simplex-is-unique": "لماذا SimpleX فريد من نوعه", "learn-more": "اقرأ أكثر", "more-info": "معلومات أكثر", "hide-info": "معلومات أقل", diff --git a/website/langs/bg.json b/website/langs/bg.json index 963f850e53..21fb93c2cb 100644 --- a/website/langs/bg.json +++ b/website/langs/bg.json @@ -21,7 +21,7 @@ "smp-protocol": "СМП Протокол", "chat-protocol": "Чат протокол", "donate": "Дарете", - "copyright-label": "© 2020-2024 SimpleX | Проект с отворен код", + "copyright-label": "© 2020-2025 SimpleX | Проект с отворен код", "simplex-chat-protocol": "SimpleX Чат протокол", "terminal-cli": "Системна конзола", "terms-and-privacy-policy": "Условия и политика за поверителност", diff --git a/website/langs/cs.json b/website/langs/cs.json index 5d7727ba0a..47ab2c99d0 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -14,7 +14,7 @@ "features": "Funkce", "why-simplex": "Proč SimpleX", "simplex-privacy": "SimpleX soukromí", - "simplex-explained": "Simplex vysvětlení", + "simplex-explained": "SimpleX vysvětlení", "simplex-explained-tab-1-text": "1. Co vidí uživatel", "simplex-explained-tab-2-text": "2. Jak to funguje", "simplex-explained-tab-3-text": "3. Co vidí servery", @@ -25,7 +25,7 @@ "smp-protocol": "SMP protokol", "chat-protocol": "Chat protokol", "donate": "Darovat", - "copyright-label": "© 2020-2024 SimpleX | Projekt s otevřeným zdrojovým kódem", + "copyright-label": "© 2020-2025 SimpleX | Projekt s otevřeným zdrojovým kódem", "simplex-chat-protocol": "SimpleX Chat protokol", "terminal-cli": "Terminálové rozhraní příkazového řádku", "terms-and-privacy-policy": "Ochrana soukromí", @@ -115,8 +115,7 @@ "sign-up-to-receive-our-updates": "Přihlaste se k odběru novinek", "enter-your-email-address": "vložte svou e-mailovou adresu", "get-simplex": "Získat SimpleX desktop app", - "why-simplex-is": "Proč je SimpleX", - "unique": "jedinečný", + "why-simplex-is-unique": "Proč je SimpleX jedinečný", "learn-more": "Další informace", "more-info": "Více informací", "hide-info": "Skrýt informace", @@ -180,7 +179,7 @@ "privacy-matters-overlay-card-1-p-2": "Internetoví prodejci vědí, že lidé s nižšími příjmy častěji provádějí urgentní nákupy, takže mohou účtovat vyšší ceny nebo odebírat slevy.", "privacy-matters-overlay-card-1-p-4": "Platforma SimpleX chrání soukromí vašich připojení lépe než jakákoli jiná alternativa a plně zabraňuje tomu, aby byl váš sociální graf dostupný všem společnostem nebo organizacím. I když lidé používají servery poskytované SimpleX Chat, neznáme počet uživatelů ani jejich připojení.", "privacy-matters-overlay-card-2-p-2": "Chcete-li být objektivní a činit nezávislá rozhodnutí, musíte mít svůj informační prostor pod kontrolou. Je to možné pouze v případě, že používáte soukromou komunikační platformu, která nemá přístup k vašemu sociálnímu grafu.", - "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery. Používání SimpleX je jako mít jinou “ vypalovačku” e-mail nebo telefon pro každý kontakt a žádné potíže s jejich správou.", + "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery.", "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", "simplex-unique-overlay-card-1-p-1": "Na rozdíl od jiných platforem pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům. Nespoléhá se na telefonní čísla, adresy založené na doméně (jako je e-mail nebo XMPP), uživatelská jména, veřejné klíče nebo dokonce náhodná čísla k identifikaci svých uživatelů —. Nevíme', kolik lidí používá naše servery SimpleX.", "invitation-hero-header": "Byl vám zaslán odkaz pro připojení na SimpleX Chat", diff --git a/website/langs/de.json b/website/langs/de.json index 00c34478e7..f2ec261ab7 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -21,7 +21,7 @@ "smp-protocol": "SMP-Protokoll", "chat-bot-example": "Beispiel für einen Chatbot", "donate": "Spenden", - "copyright-label": "© 2020-2024 SimpleX | Open-Source-Projekt", + "copyright-label": "© 2020-2025 SimpleX | Open-Source-Projekt", "chat-protocol": "Chat-Protokoll", "simplex-chat-protocol": "SimpleX-Chat-Protokoll", "terminal-cli": "Terminal-Kommandozeilen-Schnittstelle", @@ -175,11 +175,10 @@ "comparison-section-list-point-7": "P2P-Netzwerke haben entweder eine zentrale Verwaltung oder das gesamte Netzwerk kann kompromittiert werden", "see-here": "Siehe hier", "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Plattformen außer dem Internet.", - "unique": "einmalig", "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Plattformen weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder sogar Zufallszahlen, um seine Benutzer zu identifizieren — Wir wissen nicht, wie viele Personen unsere SimpleX-Server verwenden.", - "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden. Die Nutzung von SimpleX entspricht der Nutzung von unterschiedlichen Mailservern oder Telefonen für jeden einzelnen Kontakt und vermeidet dabei eine mühsame Verwaltung.", + "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden.", "simplex-network-overlay-card-1-li-5": "Alle bekannten P2P-Netzwerke können anfällig für Sybil-Angriffe sein, da jeder Knoten ermittelbar ist und das Netzwerk als Ganzes funktioniert. Bekannte Maßnahmen zur Verhinderung erfordern entweder eine zentralisierte Komponente oder einen teuren Ausführungsnachweis. Das SimpleX-Netzwerk bietet keine Ermittlung der Server, ist fragmentiert und arbeitet mit mehreren isolierten Subnetzwerken, wodurch netzwerkweite Angriffe unmöglich werden.", "simplex-network-overlay-card-1-li-3": "P2P löst nicht das Problem des MITM-Angriffs und die meisten bestehenden Implementierungen nutzen für den initialen Schlüsselaustausch keine Out-of-Band-Nachrichten. Im Gegensatz hierzu nutzt SimpleX für den initialen Schlüsselaustausch Out-of-Band-Nachrichten oder zum Teil schon bestehende sichere und vertrauenswürdige Verbindungen.", "tap-the-connect-button-in-the-app": "Drücken Sie die „Verbinden“-Taste in der Applikation", @@ -206,7 +205,7 @@ "simplex-unique-overlay-card-2-p-2": "Auch wenn die optionale Benutzeradresse zum Versenden von Spam-Kontaktanfragen verwendet werden kann, können Sie sie ändern oder ganz löschen, ohne dass Ihre Verbindungen verloren gehen.", "simplex-unique-overlay-card-4-p-2": "Die SimpleX-Plattform verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an, welches die Erstellung von Diensten ermöglicht, mit denen Nutzer über SimpleX-Chat interagieren können — Wir sind gespannt, welche SimpleX-Dienste Sie erstellen können.", "simplex-unique-card-4-p-2": "Sie können SimpleX mit Ihren eigenen Servern oder mit den von uns zur Verfügung gestellten Servern verwenden — und sich trotzdem mit jedem Benutzer verbinden.", - "why-simplex-is": "Warum ist SimpleX", + "why-simplex-is-unique": "Warum ist SimpleX einmalig", "contact-hero-p-1": "Die öffentlichen Schlüssel und die Adresse der Nachrichtenwarteschlange in diesem Link werden NICHT über das Netzwerk gesendet, wenn Sie diese Seite aufrufen — sie sind in dem Hash-Fragment der Link-URL enthalten.", "if-you-already-installed-simplex-chat-for-the-terminal": "Wenn Sie SimpleX schon für das Terminal installiert haben", "simplex-network-3-desc": "Die Server stellen unidirektionale Warteschlangen zur Verfügung, um die Benutzer miteinander zu verbinden. Sie haben aber keinen Einblick in den Verbindungs-Graphen des Netzwerks — Diesen haben nur die Benutzer selbst.", diff --git a/website/langs/en.json b/website/langs/en.json index e57b3375de..f9691e2594 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -7,21 +7,21 @@ "why-simplex": "Why SimpleX", "simplex-privacy": "SimpleX privacy", "simplex-network": "SimpleX network", - "simplex-explained": "Simplex explained", + "simplex-explained": "SimpleX explained", "simplex-explained-tab-1-text": "1. What users experience", "simplex-explained-tab-2-text": "2. How does it work", "simplex-explained-tab-3-text": "3. What servers see", "simplex-explained-tab-1-p-1": "You can create contacts and groups, and have two-way conversations, as in any other messenger.", "simplex-explained-tab-1-p-2": "How can it work with unidirectional queues and without user profile identifiers?", "simplex-explained-tab-2-p-1": "For each connection you use two separate messaging queues to send and receive messages via different servers.", - "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversation or connections.", + "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversations or connections.", "simplex-explained-tab-3-p-1": "The servers have separate anonymous credentials for each queue, and do not know which users they belong to.", "simplex-explained-tab-3-p-2": "Users can further improve metadata privacy by using Tor to access servers, preventing corellation by IP address.", "chat-bot-example": "Chat bot example", "smp-protocol": "SMP protocol", "chat-protocol": "Chat protocol", "donate": "Donate", - "copyright-label": "© 2020-2024 SimpleX | Open-Source Project", + "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Privacy Policy", @@ -65,7 +65,7 @@ "simplex-private-card-4-point-2": "To use SimpleX via Tor please install Orbot app and enable SOCKS5 proxy (or VPN on iOS).", "simplex-private-card-5-point-1": "SimpleX uses content padding for each encryption layer to frustrate message size attacks.", "simplex-private-card-5-point-2": "It makes messages of different sizes look the same to the servers and network observers.", - "simplex-private-card-6-point-1": "Many communication platforms are vulnerable to MITM attacks by servers or network providers.", + "simplex-private-card-6-point-1": "Many communication networks are vulnerable to MITM attacks by servers or network providers.", "simplex-private-card-6-point-2": "To prevent it SimpleX apps pass one-time keys out-of-band, when you share an address as a link or a QR code.", "simplex-private-card-7-point-1": "To guarantee integrity the messages are sequentially numbered and include the hash of the previous message.", "simplex-private-card-7-point-2": "If any message is added, removed or changed the recipient will be alerted.", @@ -92,7 +92,7 @@ "simplex-unique-4-title": "You own SimpleX network", "simplex-unique-4-overlay-1-title": "Fully decentralised — users own the SimpleX network", "hero-overlay-card-1-p-1": "Many users asked: if SimpleX has no user identifiers, how can it know where to deliver messages?", - "hero-overlay-card-1-p-2": "To deliver messages, instead of user IDs used by all other platforms, SimpleX uses temporary anonymous pairwise identifiers of message queues, separate for each of your connections — there are no long term identifiers.", + "hero-overlay-card-1-p-2": "To deliver messages, instead of user IDs used by all other networks, SimpleX uses temporary anonymous pairwise identifiers of message queues, separate for each of your connections — there are no long term identifiers.", "hero-overlay-card-1-p-3": "You define which server(s) to use to receive the messages, your contacts — the servers you use to send the messages to them. Every conversation is likely to use two different servers.", "hero-overlay-card-1-p-4": "This design prevents leaking any users' metadata on the application level. To further improve privacy and protect your IP address you can connect to messaging servers via Tor.", "hero-overlay-card-1-p-5": "Only client devices store user profiles, contacts and groups; the messages are sent with 2-layer end-to-end encryption.", @@ -108,38 +108,38 @@ "simplex-network-overlay-card-1-li-1": "P2P networks rely on some variant of DHT to route messages. DHT designs have to balance delivery guarantee and latency. SimpleX has both better delivery guarantee and lower latency than P2P, because the message can be redundantly passed via several servers in parallel, using the servers chosen by the recipient. In P2P networks the message is passed through O(log N) nodes sequentially, using nodes chosen by the algorithm.", "simplex-network-overlay-card-1-li-2": "SimpleX design, unlike most P2P networks, has no global user identifiers of any kind, even temporary, and only uses temporary pairwise identifiers, providing better anonymity and metadata protection.", "simplex-network-overlay-card-1-li-3": "P2P does not solve MITM attack problem, and most existing implementations do not use out-of-band messages for the initial key exchange. SimpleX uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange.", - "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic - it can work over standard web protocols, e.g. WebSockets.", + "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic — it can work over standard web protocols, e.g. WebSockets.", "simplex-network-overlay-card-1-li-5": "All known P2P networks may be vulnerable to Sybil attack, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive proof of work. SimpleX network has no server discoverability, it is fragmented and operates as multiple isolated sub-networks, making network-wide attacks impossible.", "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connection and cannot be used by an attacker to amplify the traffic in the whole network.", "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", "privacy-matters-overlay-card-1-p-2": "Online retailers know that people with lower incomes are more likely to make urgent purchases, so they may charge higher prices or remove discounts.", "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as 'poverty premium'.", - "privacy-matters-overlay-card-1-p-4": "SimpleX platform protects the privacy of your connections better than any alternative, fully preventing your social graph becoming available to any companies or organizations. Even when people use servers provided by SimpleX Chat, we do not know the number of users or their connections.", + "privacy-matters-overlay-card-1-p-4": "SimpleX network protects the privacy of your connections better than any alternative, fully preventing your social graph becoming available to any companies or organizations. Even when people use servers preconfigured in SimpleX Chat apps, server operators do not know the number of users or their connections.", "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", - "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use private communication platform that does not have access to your social graph.", - "privacy-matters-overlay-card-2-p-3": "SimpleX is the first platform that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", + "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use private communication network that does not have access to your social graph.", + "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", "privacy-matters-overlay-card-3-p-1": "Everyone should care about privacy and security of their communications — harmless conversations can put you in danger, even if you have nothing to hide.", "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their 'anonymous' accounts, even in democratic countries.", "privacy-matters-overlay-card-3-p-4": "It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks — who we are connected with.", - "simplex-unique-overlay-card-1-p-1": "Unlike other messaging platforms, SimpleX has no identifiers assigned to the users. It does not rely on phone numbers, domain-based addresses (like email or XMPP), usernames, public keys or even random numbers to identify its users — we don't know how many people use our SimpleX servers.", - "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers. Using SimpleX is like having a different “burner” email or phone for each contact, and no hassle to manage them.", - "simplex-unique-overlay-card-1-p-3": "This design protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers. To hide your IP address from the servers, you can connect to SimpleX servers via Tor.", - "simplex-unique-overlay-card-2-p-1": "Because you have no identifier on the SimpleX platform, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", + "simplex-unique-overlay-card-1-p-1": "Unlike other messaging networks, SimpleX has no identifiers assigned to the users. It does not rely on phone numbers, domain-based addresses (like email or XMPP), usernames, public keys or even random numbers to identify its users — SimpleX server operators don't know how many people use their servers.", + "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers.", + "simplex-unique-overlay-card-1-p-3": "This design protects the privacy of who you are communicating with, hiding it from SimpleX network servers and from any observers. To hide your IP address from the servers, you can connect to SimpleX servers via Tor.", + "simplex-unique-overlay-card-2-p-1": "Because you have no identifier on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", "simplex-unique-overlay-card-2-p-2": "Even with the optional user address, while it can be used to send spam contact requests, you can change or completely delete it without losing any of your connections.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat stores all user data only on client devices using a portable encrypted database format that can be exported and transferred to any supported device.", "simplex-unique-overlay-card-3-p-2": "The end-to-end encrypted messages are held temporarily on SimpleX relay servers until received, then they are permanently deleted.", "simplex-unique-overlay-card-3-p-3": "Unlike federated networks servers (email, XMPP or Matrix), SimpleX servers don't store user accounts, they only relay messages, protecting the privacy of both parties.", "simplex-unique-overlay-card-3-p-4": "There are no identifiers or ciphertext in common between sent and received server traffic — if anybody is observing it, they cannot easily determine who communicates with whom, even if TLS is compromised.", - "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the pre-configured servers provided by us.", - "simplex-unique-overlay-card-4-p-2": "SimpleX platform uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you can build.", - "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX platform, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", - "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX platform servers and any observers.", - "simplex-unique-card-1-p-2": "Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users — not even random numbers.", - "simplex-unique-card-2-p-1": "Because you have no identifier or fixed address on the SimpleX platform, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", + "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the servers preconfigured in the apps.", + "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you will build.", + "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", + "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX network servers and any observers.", + "simplex-unique-card-1-p-2": "Unlike any other existing messaging network, SimpleX has no identifiers assigned to the users — not even random numbers.", + "simplex-unique-card-2-p-1": "Because you have no identifier or fixed address on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", "simplex-unique-card-3-p-1": "SimpleX stores all user data on client devices in a portable encrypted database format — it can be transferred to another device.", "simplex-unique-card-3-p-2": "The end-to-end encrypted messages are held temporarily on SimpleX relay servers until received, then they are permanently deleted.", - "simplex-unique-card-4-p-1": "The SimpleX network is fully decentralised and independent of any crypto-currency or any other platform, other than the Internet.", + "simplex-unique-card-4-p-1": "The SimpleX network is fully decentralised and independent of any crypto-currency or any other network, other than the Internet.", "simplex-unique-card-4-p-2": "You can use SimpleX with your own servers or with the servers provided by us — and still connect to any user.", "join": "Join", "we-invite-you-to-join-the-conversation": "We invite you to join the conversation", @@ -149,8 +149,7 @@ "sign-up-to-receive-our-updates": "Sign up to receive our updates", "enter-your-email-address": "Enter your email address", "get-simplex": "Get SimpleX desktop app", - "why-simplex-is": "Why SimpleX is", - "unique": "unique", + "why-simplex-is-unique": "Why SimpleX is unique", "learn-more": "Learn more", "more-info": "More info", "hide-info": "Hide info", @@ -172,7 +171,7 @@ "use-this-command": "Use this command:", "see-simplex-chat": "See SimpleX Chat", "github-repository": "GitHub repository", - "the-instructions--source-code": "the instructions how to download or compile it from the source code.", + "the-instructions--source-code": "for the instructions how to download or compile it from the source code.", "if-you-already-installed-simplex-chat-for-the-terminal": "If you already installed SimpleX Chat for the terminal", "if-you-already-installed": "If you already installed", "simplex-chat-for-the-terminal": "SimpleX Chat for the terminal", @@ -213,7 +212,7 @@ "comparison-section-list-point-4a": "SimpleX relays cannot compromise e2e encryption. Verify security code to mitigate attack on out-of-band channel", "comparison-section-list-point-4": "If operator’s servers are compromised. Verify security code in Signal and some other apps to mitigate it", "comparison-section-list-point-5": "Does not protect users' metadata privacy", - "comparison-section-list-point-6": "While P2P are distributed, they are not federated - they operate as a single network", + "comparison-section-list-point-6": "While P2P are distributed, they are not federated — they operate as a single network", "comparison-section-list-point-7": "P2P networks either have a central authority or the whole network can be compromised", "see-here": "see here", "guide-dropdown-1": "Quick start", @@ -226,7 +225,7 @@ "guide-dropdown-8": "App settings", "guide-dropdown-9": "Making connections", "guide": "Guide", - "docs-dropdown-1": "SimpleX platform", + "docs-dropdown-1": "SimpleX network", "docs-dropdown-2": "Accessing Android files", "docs-dropdown-3": "Accessing chat database", "docs-dropdown-4": "Host SMP Server", diff --git a/website/langs/es.json b/website/langs/es.json index 56055abf67..b3cfc83969 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -10,7 +10,7 @@ "simplex-explained-tab-3-p-2": "El usuario puede mejorar aún más la privacidad de sus metadatos haciendo uso de la red Tor para acceder a los servidores, evitando así la correlación por dirección IP.", "smp-protocol": "Protocolo SMP", "donate": "Donación", - "copyright-label": "© 2020-2024 SimpleX | Proyecto de Código Abierto", + "copyright-label": "© 2020-2025 SimpleX | Proyecto de Código Abierto", "simplex-chat-protocol": "Protocolo de SimpleX Chat", "terms-and-privacy-policy": "Política de Privacidad", "hero-header": "Privacidad redefinida", @@ -40,7 +40,7 @@ "simplex-private-card-7-point-2": "Si se añade, elimina o modifica algún mensaje, el destinatario es avisado.", "simplex-private-card-8-point-1": "Los servidores SimpleX actúan como nodos de mezcla de baja latencia — los mensajes entrantes y salientes siguen un orden diferente.", "simplex-private-card-9-point-1": "Cada cola de mensajes transmite los mensajes en un solo sentido, con las distintas direcciones de envío y recepción.", - "simplex-explained": "Simplex explicado", + "simplex-explained": "SimpleX explicado", "simplex-explained-tab-1-p-1": "Puedes crear contactos y grupos, y mantener conversaciones bidireccionales como en cualquier aplicación de mensajería.", "simplex-explained-tab-1-p-2": "¿Cómo puede funcionar con colas unidireccionales y sin identificadores de usuario?", "simplex-explained-tab-2-p-1": "Por cada conexión se usan dos colas de mensajes separadas para que el envío y recepción se hagan a través de servidores diferentes.", @@ -122,7 +122,7 @@ "privacy-matters-overlay-card-3-p-2": "Una de las historias más estremecedoras es la experiencia de Mohamedou Ould Salahi descrita en sus memorias y plasmada en la película The Mauritanian. Fue internado en la prisión militar de Guantánamo sin juicio previo, donde fue torturado durante 15 años tras una llamada telefónica a su pariente en Afganistán, bajo sospecha de estar implicado en los atentados del 11-S a pesar de que vivía en Alemania desde hacía 10 años.", "privacy-matters-overlay-card-3-p-4": "No basta con usar mensajería cifrada de extremo a extremo, todos deberíamos utilizar las aplicaciones de mensajería que protegen la privacidad de nuestras redes personales y con quién estamos conectados.", "privacy-matters-overlay-card-3-p-3": "Personas corrientes son detenidas por lo que comparten en Internet, incluso a través de sus cuentas \"anónimas\" e incluso en países democráticos.", - "simplex-unique-overlay-card-1-p-2": "Para enviar mensajes, SimpleX utiliza direcciones anónimas por pares de colas de mensajes unidireccionales, separadas para mensajes recibidos y enviados, y normalmente a través de servidores diferentes. Utilizar SimpleX es como tener un correo electrónico “desechable” o un teléfono diferente para cada contacto, pero sin las complicaciones que implicaría gestionarlos.", + "simplex-unique-overlay-card-1-p-2": "Para enviar mensajes, SimpleX utiliza direcciones anónimas por pares de colas de mensajes unidireccionales, separadas para mensajes recibidos y enviados, y normalmente a través de servidores diferentes.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat almacena todos los datos de usuario únicamente en los dispositivos cliente usando un formato cifrado y portable de la base de datos, la cual puede ser exportada y transferida a cualquier dispositivo compatible.", "simplex-unique-overlay-card-3-p-3": "A diferencia de los servidores de redes federadas (correo electrónico, XMPP o Matrix), los servidores SimpleX no almacenan cuentas de usuario, sólo retransmiten mensajes, protegiendo así la privacidad de ambas partes.", "simplex-unique-overlay-card-4-p-1": "Puede usar SimpleX con sus propios servidores y aún así comunicarte con personas conectadas a los servidores preconfigurados y proporcionados por nosotros.", @@ -138,8 +138,7 @@ "donate-here-to-help-us": "Para ayudarnos haga una donación aquí", "enter-your-email-address": "Escriba su dirección de correo electrónico", "get-simplex": "Obtenga SimpleX desktop app", - "why-simplex-is": "Por qué SimpleX es", - "unique": "único", + "why-simplex-is-unique": "Por qué SimpleX es único", "learn-more": "Descubra más", "more-info": "Más información", "hide-info": "Ocultar información", diff --git a/website/langs/fi.json b/website/langs/fi.json index 68c0d4f1b4..460aa7989b 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -112,7 +112,7 @@ "simplex-explained-tab-1-p-1": "Voit luoda yhteyshenkilöitä ja ryhmiä sekä käydä kaksisuuntaisia keskusteluja kuten missä tahansa muussa viestisovelluksessa.", "simplex-explained-tab-3-p-1": "Palvelimilla on erilliset anonyymit tunnistetiedot kullekin jonolle, eivätkä ne tiedä, mille käyttäjille ne kuuluvat.", "donate": "Lahjoita", - "copyright-label": "© 2020-2023 SimpleX | Avoin projekti", + "copyright-label": "© 2020-2025 SimpleX | Avoin projekti", "hero-p-1": "Muissa sovelluksissa on käyttäjätunnuksia: Signal, Matrix, Session, Briar, Jami, Cwtch, jne.
SimpleX ei käytä niitä, ei edes satunnaisia numeroita.
Tämä parantaa yksityisyyttäsi radikaalisti.", "simplex-private-1-title": "2 kerrosta päästä päähän salattua viestintää", "simplex-private-2-title": "Lisäkerros palvelimen salaukselle", @@ -168,7 +168,7 @@ "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, jopa demokraattisissa maissa.", "privacy-matters-overlay-card-3-p-4": "Ei riitä, että käytät päästä päähän salattua viestintäsovellusta, meidän kaikkien pitäisi käyttää viestintäsovelluksia, jotka suojelevat henkilökohtaisten verkostojemme yksityisyyttä — keiden kanssa olemme yhteydessä.", "simplex-unique-overlay-card-1-p-1": "Toisin kuin muut viestintäalustat, SimpleX:llä ei ole mitään tunnisteita käyttäjille. Se ei luota puhelinnumeroihin, verkkotunnuksiin perustuviin osoitteisiin (kuten sähköposti tai XMPP), käyttäjänimiin, julkisiin avaimiin tai edes satunnaisiin numeroihin tunnistaakseen käyttäjänsä — emme tiedä kuinka monta ihmistä käyttää SimpleX-palvelimiamme.", - "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää parittaisia nimettömiä osoitteita kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta. SimpleX:n käyttö on kuin eri “kertakäyttöinen” sähköposti tai puhelin jokaiselle yhteydelle, eikä sinun tarvitse vaivautua niiden hallitsemiseen.", + "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää parittaisia nimettömiä osoitteita kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta.", "simplex-unique-overlay-card-1-p-3": "Tämä suunnittelu suojaa sitä, kenen kanssa kommunikoit, piilottamalla sen SimpleX-alustan palvelimilta ja kaikilta havainnoijilta. Piilottaaksesi IP-osoitteesi palvelimilta, voit yhdistää SimpleX-palvelimiin Tor-verkon kautta.", "simplex-unique-overlay-card-2-p-1": "Koska sinulla ei ole tunnistetta SimpleX-alustalla, kukaan ei voi ottaa sinuun yhteyttä, ellei jaa kertakäyttöistä tai väliaikaista käyttäjäosoitetta, kuten QR-koodia tai linkkiä.", "simplex-unique-overlay-card-2-p-2": "Jopa valinnaisen käyttäjäosoitteen kanssa, vaikka sitä voitaisiin käyttää roskapostiyhteyspyyntöjen lähettämiseen, voit vaihtaa sen tai poistaa sen kokonaan menettämättä mitään yhteyksiäsi.", @@ -187,8 +187,7 @@ "sign-up-to-receive-our-updates": "Tilaa päivityksemme", "enter-your-email-address": "Syötä sähköpostiosoitteesi", "get-simplex": "Hanki SimpleX desktop app", - "why-simplex-is": "Miksi SimpleX on", - "unique": "ainutlaatuinen", + "why-simplex-is-unique": "Miksi SimpleX on ainutlaatuinen", "learn-more": "Lue lisää", "more-info": "Lisätietoja", "contact-hero-subheader": "Skannaa QR-koodi SimpleX Chat -sovelluksella puhelimessasi tai tabletissasi.", diff --git a/website/langs/fr.json b/website/langs/fr.json index ba2c4fb00c..efc8d6c9c6 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -7,7 +7,7 @@ "why-simplex": "Pourquoi SimpleX", "simplex-privacy": "Confidentialité SimpleX", "simplex-network": "Réseau SimpleX", - "simplex-explained": "Simplex expliqué", + "simplex-explained": "SimpleX expliqué", "simplex-explained-tab-1-text": "1. L'expérience des utilisateurs", "simplex-explained-tab-2-text": "2. Comment ça marche", "simplex-explained-tab-3-text": "3. Ce que voient les serveurs", @@ -21,7 +21,7 @@ "smp-protocol": "Protocole SMP", "chat-protocol": "Protocole de chat", "donate": "Faire un don", - "copyright-label": "© 2020-2024 SimpleX | Projet Open-Source", + "copyright-label": "© 2020-2025 SimpleX | Projet Open-Source", "simplex-chat-protocol": "Protocole SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Politique de confidentialité", @@ -118,7 +118,7 @@ "privacy-matters-overlay-card-3-p-3": "Des personnes lambda se font arrêter pour ce qu'elles partagent en ligne, même via leurs comptes \"anonymes\", même dans les pays démocratiques.", "privacy-matters-overlay-card-3-p-4": "Il ne suffit pas d'utiliser une messagerie chiffrée de bout en bout, nous devrions tous utiliser des messageries qui protègent la vie privée de nos réseaux personnels — les personnes avec lesquelles nous sommes connectés.", "simplex-unique-overlay-card-1-p-1": "Contrairement aux autres plateformes de messagerie, SimpleX n'a pas d'identifiant attribué aux utilisateurs. Il ne s'appuie pas sur des numéros de téléphone, des adresses basées sur des domaines (comme l'e-mail ou XMPP), des noms d'utilisateur, des clés publiques ou même des nombres aléatoires pour identifier ses utilisateurs — nous ne savons pas combien de personnes utilisent nos serveurs SimpleX.", - "simplex-unique-overlay-card-1-p-2": "Pour envoyer des messages, SimpleX utilise des adresses anonymes par paires de files d'attente de messages unidirectionnelles, séparées pour les messages reçus et envoyés, généralement via des serveurs différents. Utiliser SimpleX, c'est comme avoir un e-mail ou un téléphone jetable différent pour chaque contact, sans aucune difficulté pour les gérer.", + "simplex-unique-overlay-card-1-p-2": "Pour envoyer des messages, SimpleX utilise des adresses anonymes par paires de files d'attente de messages unidirectionnelles, séparées pour les messages reçus et envoyés, généralement via des serveurs différents.", "simplex-unique-overlay-card-1-p-3": "Cette approche protège la confidentialité des personnes avec lesquelles vous communiquez, en la cachant des serveurs de la plateforme SimpleX et de tout observateur. Pour cacher votre adresse IP aux serveurs, vous pouvez vous connecter aux serveurs SimpleX via Tor.", "simplex-unique-overlay-card-2-p-1": "Parce que vous n'avez pas d'identifiant sur la plateforme SimpleX, personne ne peut vous contacter sauf si vous partagez une adresse d'utilisateur unique ou temporaire, sous forme de code QR ou de lien .", "simplex-unique-overlay-card-2-p-2": "Même avec l'adresse utilisateur facultative, bien qu'elle puisse être utilisée pour envoyer des demandes de contact de spam, vous pouvez la modifier ou la supprimer complètement sans perdre aucune de vos connexions.", @@ -144,8 +144,7 @@ "sign-up-to-receive-our-updates": "Inscrivez-vous pour recevoir nos mises à jour", "enter-your-email-address": "Entrez votre adresse e-mail", "get-simplex": "Obtenir SimpleX desktop app", - "why-simplex-is": "Pourquoi SimpleX est", - "unique": "unique", + "why-simplex-is-unique": "Pourquoi SimpleX est unique", "learn-more": "En savoir plus", "more-info": "Plus d'infos", "hide-info": "Masquer les infos", diff --git a/website/langs/he.json b/website/langs/he.json index 878f0e51b2..4a61999896 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -53,7 +53,7 @@ "smp-protocol": "פרוטוקול SMP", "chat-protocol": "פרוטוקול צ'אט", "donate": "תרומה", - "copyright-label": "© 2020-2024 SimpleX | פרויקט קוד פתוח", + "copyright-label": "© 2020-2025 SimpleX | פרויקט קוד פתוח", "hero-p-1": "לאפליקציות אחרות יש מזהי משתמש: Signal, Matrix, Session, Briar, Jami, Cwtch וכו'.
ל-SimpleX אין, אפילו לא מספרים אקראיים.
זה משפר באופן קיצוני את הפרטיות שלך.", "hero-overlay-2-title": "מדוע מזהי משתמש מזיקים לפרטיות?", "feature-6-title": "שיחות שמע ווידאו
מוצפנות מקצה לקצה", @@ -163,10 +163,9 @@ "simplex-unique-overlay-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", "simplex-unique-overlay-card-4-p-2": "פלטפורמת SimpleX משתמשת בפרוטוקול פתוח ומספקת SDK ליצירת צ'אט בוטים, המאפשר הטמעה של שירותים שמשתמשים יכולים לתקשר איתם באמצעות אפליקציות SimpleX Chat — אנחנו ממש מצפים לראות אילו שירותי SimpleX אתם יכולים לבנות.", "contact-hero-p-2": "עדיין לא הורדתם את ה-SimpleX Chat?", - "why-simplex-is": "מדוע SimpleX הוא", + "why-simplex-is-unique": "מדוע SimpleX הוא ייחודי", "simplex-network-section-header": "רשת SimpleX", "tap-the-connect-button-in-the-app": "הקישו על הלחצן 'התחבר' באפליקציה", - "unique": "ייחודי", "simplex-network-1-overlay-linktext": "בעיות של רשתות P2P", "protocol-2-text": "XMPP, Matrix", "simplex-network-overlay-card-1-li-4": "יישומי P2P יכולים להיחסם על ידי ספקי אינטרנט מסוימים (כמו BitTorrent). SimpleX הוא אגנוסטי לתעבורה - הוא יכול לעבוד על פרוטוקולי אינטרנט סטנדרטיים, למשל WebSockets.", diff --git a/website/langs/hu.json b/website/langs/hu.json index 60eeeafe13..f502291e52 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -7,7 +7,7 @@ "why-simplex": "Miért válassza a SimpleXet", "simplex-privacy": "A SimpleX adatvédelme", "simplex-network": "A SimpleX-hálózat", - "simplex-explained": "A Simplex bemutatása", + "simplex-explained": "A SimpleX bemutatása", "simplex-explained-tab-1-text": "1. Felhasználói élmény", "simplex-explained-tab-2-text": "2. Hogyan működik", "simplex-explained-tab-3-text": "3. Mit látnak a kiszolgálók", @@ -113,7 +113,7 @@ "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", "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ő platformoktó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 — nem tudjuk, hogy hányan használják a SimpleX-kiszolgálóinkat.", - "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. A SimpleX használata olyan, mintha minden egyes kapcsolatnak más-más “eldobható” e-mail-címe vagy telefonja lenne és nem kell ezeket gondosan kezelni.", + "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-platform 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-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, 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.", @@ -139,8 +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ép-alkalmazásának letöltése", - "why-simplex-is": "A SimpleX mitől", - "unique": "egyedülálló", + "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", diff --git a/website/langs/it.json b/website/langs/it.json index 502ab6d886..032706ebab 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -10,7 +10,7 @@ "simplex-explained-tab-3-p-1": "I server hanno credenziali anonime separate per ogni coda e non sanno a quali utenti appartengano.", "chat-protocol": "Protocollo di chat", "donate": "Dona", - "copyright-label": "© 2020-2024 SimpleX | Progetto Open-Source", + "copyright-label": "© 2020-2025 SimpleX | Progetto Open-Source", "simplex-chat-protocol": "Protocollo di SimpleX Chat", "terminal-cli": "Terminale CLI", "terms-and-privacy-policy": "Informativa sulla privacy", @@ -82,8 +82,7 @@ "we-invite-you-to-join-the-conversation": "Ti invitiamo a unirti alla conversazione", "enter-your-email-address": "Inserisci il tuo indirizzo email", "get-simplex": "Ottieni SimpleX desktop app", - "why-simplex-is": "Perché SimpleX è", - "unique": "unico", + "why-simplex-is-unique": "Perché SimpleX è unico", "learn-more": "Maggiori informazioni", "more-info": "Mostra info", "hide-info": "Nascondi info", @@ -187,7 +186,7 @@ "join-us-on-GitHub": "Unisciti a noi su GitHub", "simplex-chat-for-the-terminal": "SimpleX Chat per il terminale", "privacy-matters-overlay-card-3-p-4": "Non è sufficiente usare un messenger crittografato end-to-end, tutti dovremmo usare i messenger che proteggono la privacy delle nostre reti personali — con chi siamo connessi.", - "simplex-unique-overlay-card-1-p-2": "Per recapitare i messaggi, SimpleX usa indirizzi anonimi a coppie di code di messaggi unidirezionali, separate per i messaggi ricevuti e inviati, di solito tramite server diversi. Usare SimpleX è come avere un'email o telefono “temporanei” per ogni contatto, e nessuna seccatura per gestirli.", + "simplex-unique-overlay-card-1-p-2": "Per recapitare i messaggi, SimpleX usa indirizzi anonimi a coppie di code di messaggi unidirezionali, separate per i messaggi ricevuti e inviati, di solito tramite server diversi.", "simplex-unique-overlay-card-1-p-1": "A differenza di altre piattaforme di messaggistica, SimpleX non ha alcun identificatore assegnato agli utenti. Non si basa su numeri di telefono, indirizzi basati su domini (come email o XMPP), nomi utente, chiavi pubbliche o persino numeri casuali per identificare i suoi utenti — non sappiamo quante persone usano i nostri server SimpleX.", "simplex-unique-overlay-card-3-p-3": "A differenza dei server di reti federate (email, XMPP o Matrix), i server SimpleX non conservano gli account utente, ma trasmettono solo i messaggi, proteggendo la privacy di entrambe le parti.", "simplex-unique-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", diff --git a/website/langs/ja.json b/website/langs/ja.json index c24883fb00..05365e2271 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -52,7 +52,7 @@ "chat-protocol": "チャットプロトコル", "chat-bot-example": "チャットボットの例", "donate": "寄付", - "copyright-label": "© 2020-2024 SimpleX | Open-Source Project", + "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", "hero-p-1": "他のアプリにはユーザー ID があります: Signal、Matrix、Session、Briar、Jami、Cwtch など。
SimpleX にはありません。乱数さえもありません
これにより、プライバシーが大幅に向上します。", "copy-the-command-below-text": "以下のコマンドをコピーしてチャットで使用します:", "simplex-private-card-9-point-1": "各メッセージ キューは、異なる送信アドレスと受信アドレスを使用してメッセージを一方向に渡します。", @@ -193,13 +193,12 @@ "simplex-unique-overlay-card-3-p-2": "エンドツーエンドで暗号化されたメッセージは、SimpleXのリレーサーバーで受信するまで一時的に保持され、その後永久に削除されます。", "simplex-private-card-7-point-1": "整合性を保証するために、メッセージには連続した番号が付けられ、前のメッセージのハッシュが含まれます。", "contact-hero-p-2": "SimpleX Chat をまだダウンロードしていませんか?", - "why-simplex-is": "なぜSimpleXなのか", + "why-simplex-is-unique": "なぜSimpleXなのか唯一", "simplex-network-section-header": "SimpleX ネットワーク", "simplex-private-10-title": "一時的な匿名のペア識別子", "privacy-matters-1-overlay-1-linkText": "プライバシーの保護はコストを削減します", "tap-the-connect-button-in-the-app": "アプリの 「接続」 ボタンをタップします", "comparison-section-list-point-4a": "SimpleX リレーは e2e 暗号化を侵害できません。 セキュリティ コードを検証して帯域外チャネルへの攻撃を軽減します", - "unique": "唯一", "simplex-network-1-overlay-linktext": "P2Pネットワークの問題点", "no-private": "いいえ - プライベート", "simplex-unique-1-title": "プライバシーが完全に守られます", diff --git a/website/langs/nl.json b/website/langs/nl.json index 4db6126c2c..30e751c9f9 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -7,7 +7,7 @@ "why-simplex": "Waarom SimpleX", "simplex-privacy": "SimpleX privacy", "simplex-network": "SimpleX netwerk", - "simplex-explained": "Simplex uitgelegd", + "simplex-explained": "SimpleX uitgelegd", "simplex-explained-tab-1-text": "1. Wat gebruikers ervaren", "simplex-explained-tab-3-text": "3. Wat servers zien", "simplex-explained-tab-1-p-1": "U kunt contacten en groepen maken en tweerichtings gesprekken voeren, zoals in elke andere messenger.", @@ -17,7 +17,7 @@ "chat-bot-example": "Chatbot voorbeeld", "smp-protocol": "SMP protocol", "donate": "Doneer", - "copyright-label": "© 2020-2024 SimpleX | Open-sourceproject", + "copyright-label": "© 2020-2025 SimpleX | Open-sourceproject", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Privacybeleid", @@ -116,8 +116,7 @@ "join-us-on-GitHub": "Volg ons op GitHub", "donate-here-to-help-us": "Doneer hier om ons te helpen", "sign-up-to-receive-our-updates": "Schrijf u in om onze updates te ontvangen", - "unique": "uniek", - "why-simplex-is": "Waarom is SimpleX", + "why-simplex-is-unique": "Waarom is SimpleX uniek", "learn-more": "Kom meer te weten", "more-info": "Meer informatie", "hide-info": "Info verbergen", @@ -183,7 +182,7 @@ "privacy-matters-overlay-card-3-p-2": "Een van de meest schokkende verhalen is de ervaring van Mohamedou Ould Salahi beschreven in zijn memoires en getoond in The Mauritanian movie. Hij werd zonder proces in het Guantanamo kamp geplaatst en werd daar 15 jaar lang gemarteld na een telefoontje naar zijn familielid in Afghanistan, op verdenking van betrokkenheid bij aanslagen van 9/11, ook al woonde hij de afgelopen 10 jaar in Duitsland.", "privacy-matters-overlay-card-3-p-3": "Gewone mensen worden gearresteerd voor wat ze online delen, zelfs via hun 'anonieme' accounts, zelfs in democratische landen.", "privacy-matters-overlay-card-3-p-4": "Het is niet genoeg om een end-to-end versleutelde messenger te gebruiken, we zouden allemaal de messengers moeten gebruiken die de privacy van onze persoonlijke netwerken beschermen — met wie we verbonden zijn.", - "simplex-unique-overlay-card-1-p-2": "Om berichten af te leveren gebruikt SimpleX paarsgewijze anonieme adressen van unidirectionele berichten wachtrijen, gescheiden voor ontvangen en verzonden berichten, meestal via verschillende servers. Het gebruik van SimpleX is als het hebben van een andere “brander” e-mail of telefoon voor elk contact, en geen gedoe om ze te beheren.", + "simplex-unique-overlay-card-1-p-2": "Om berichten af te leveren gebruikt SimpleX paarsgewijze anonieme adressen van unidirectionele berichten wachtrijen, gescheiden voor ontvangen en verzonden berichten, meestal via verschillende servers.", "simplex-unique-overlay-card-1-p-3": "Dit ontwerp beschermt de privacy van met wie u communiceert en verbergt deze voor SimpleX platform servers en voor waarnemers. Om uw IP-adres voor de servers te verbergen, kunt u verbinding maken met SimpleX servers via Tor .", "simplex-unique-overlay-card-2-p-1": "Omdat je geen identificatie hebt op het SimpleX platform, kan niemand contact met je opnemen, tenzij je een eenmalig of tijdelijk gebruikers adres deelt, als een QR-code of een link.", "simplex-unique-overlay-card-2-p-2": "Zelfs met het optionele gebruikers adres, hoewel het kan worden gebruikt om spam-contact verzoeken te verzenden, kunt u het wijzigen of volledig verwijderen zonder uw verbindingen te verliezen.", diff --git a/website/langs/pl.json b/website/langs/pl.json index a25155e018..d45207a709 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -15,7 +15,7 @@ "smp-protocol": "Protokół SMP", "chat-protocol": "Protokół czatu", "donate": "Darowizna", - "copyright-label": "© 2020-2024 SimpleX | Projekt Open-Source", + "copyright-label": "© 2020-2025 SimpleX | Projekt Open-Source", "simplex-chat-protocol": "Protokół SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Polityka prywatności", @@ -140,7 +140,7 @@ "simplex-network-overlay-card-1-li-6": "Sieci P2P mogą być podatne na atak DRDoS, kiedy klienci mogą rozgłaszać i wzmacniać ruch, co powoduje odmowę usługi w całej sieci. Klienci SimpleX przekazują jedynie ruch ze znanego połączenia i nie mogą być wykorzystani przez atakującego do wzmocnienia ruchu w całej sieci.", "privacy-matters-overlay-card-1-p-1": "Wiele dużych firm wykorzystuje informacje o tym, z kim jesteś połączony, aby oszacować Twoje dochody, sprzedać Ci produkty, których tak naprawdę nie potrzebujesz, oraz ustalić ceny.", "simplex-unique-overlay-card-2-p-2": "Nawet w przypadku opcjonalnego adresu użytkownika, podczas gdy może on być używany do wysyłania spamowych zapytań o kontakt, możesz go zmienić lub całkowicie usunąć bez utraty jakichkolwiek połączeń.", - "simplex-unique-overlay-card-1-p-2": "Do dostarczania wiadomości SimpleX używa parami anonimowych adresów jednokierunkowych kolejek z wiadomościami, oddzielnych dla wiadomości odbieranych i wysyłanych, zwykle przez różne serwery. Korzystanie z SimpleX jest jak posiadanie wielu “jednorazowych” emailów lub osobnego telefonu dla każdego kontaktu i braku problemów z zarządzaniem nimi.", + "simplex-unique-overlay-card-1-p-2": "Do dostarczania wiadomości SimpleX używa parami anonimowych adresów jednokierunkowych kolejek z wiadomościami, oddzielnych dla wiadomości odbieranych i wysyłanych, zwykle przez różne serwery.", "simplex-unique-overlay-card-1-p-3": "Taka konstrukcja chroni prywatność tego, z kim się komunikujesz, ukrywając ją przed serwerami platformy SimpleX i przed wszelkimi obserwatorami. Aby ukryć swój adres IP przed serwerami, możesz połączyć się z serwerami SimpleX za pośrednictwem sieci Tor.", "simplex-unique-overlay-card-3-p-4": "Nie ma żadnych identyfikatorów ani szyfrogramów wspólnych między wysyłanym i odbieranym ruchem serwera — jeśli ktokolwiek to obserwuje, nie może łatwo określić, kto komunikuje się z kim, nawet jeśli bezpieczeństwo protokołu TLS zostało zagrożone.", "simplex-unique-overlay-card-2-p-1": "Ponieważ nie masz identyfikatora na platformie SimpleX, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku.", @@ -154,13 +154,12 @@ "simplex-unique-card-1-p-2": "W przeciwieństwie do każdej innej istniejącej platformy komunikacyjnej, SimpleX nie ma żadnych identyfikatorów przypisanych do użytkowników — nawet losowych liczb.", "tap-the-connect-button-in-the-app": "Stuknij przycisk 'połącz się' w aplikacji", "join-the-REDDIT-community": "Dołącz do społeczności REDDIT", - "unique": "unikalny", "hide-info": "Ukryj informacje", "simplex-unique-card-4-p-2": "Możesz używać SimpleX z własnymi serwerami lub z serwerami dostarczonymi przez nas — i nadal łączyć się z dowolnym użytkownikiem.", "we-invite-you-to-join-the-conversation": "Zapraszamy do udziału w rozmowie", "enter-your-email-address": "Wpisz swój adres e-mail", "get-simplex": "Pobierz SimpleX desktop app", - "why-simplex-is": "Dlaczego SimpleX jest", + "why-simplex-is-unique": "Dlaczego SimpleX jest unikalny", "join": "Dołącz do", "join-us-on-GitHub": "Dołącz do nas na GitHubie", "sign-up-to-receive-our-updates": "Zapisz się, aby otrzymywać nasze aktualizacje", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index b25888591e..77854f5be1 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -25,7 +25,7 @@ "smp-protocol": "Protocolo SMP", "chat-protocol": "Protocolo de bate-papo", "donate": "Doar", - "copyright-label": "© 2020-2024 SimpleX | Projeto de Código Livre", + "copyright-label": "© 2020-2025 SimpleX | Projeto de Código Livre", "simplex-chat-protocol": "Protocolo Chat SimpleX", "terminal-cli": "CLI Terminal", "hero-header": "Privacidade redefinida", @@ -127,7 +127,7 @@ "privacy-matters-overlay-card-2-p-3": "O SimpleX é a primeira plataforma que não tem nenhum identificador de usuário por design, protegendo assim seu gráfico de conexões melhor do que qualquer alternativa conhecida.", "privacy-matters-overlay-card-3-p-3": "Pessoas comuns são presas pelo que compartilham online, mesmo por meio de suas contas \"anônimas\", mesmo em países democráticos.", "privacy-matters-overlay-card-3-p-4": "Não basta usar um mensageiro criptografado de ponta-a-ponta, todos nós devemos usar os mensageiros que protegem a privacidade de nossas redes pessoais — com quem estamos conectados.", - "simplex-unique-overlay-card-1-p-2": "Para entregar mensagens, o SimpleX usa endereços anônimos em pares de filas de mensagens unidirecionais, separadas para mensagens recebidas e enviadas, geralmente por meio de servidores diferentes. Usar o SimpleX é como ter um email ou telefone de “gravação” diferente para cada contato, sem a necessidade de gerenciá-los.", + "simplex-unique-overlay-card-1-p-2": "Para entregar mensagens, o SimpleX usa endereços anônimos em pares de filas de mensagens unidirecionais, separadas para mensagens recebidas e enviadas, geralmente por meio de servidores diferentes.", "simplex-unique-overlay-card-1-p-3": "Esse design protege a privacidade de quem está se comunicando com você, ocultando-a dos servidores da plataforma SimpleX e de quaisquer observadores. Para ocultar seu endereço IP dos servidores, você pode se conectar aos servidores do SimpleX via Tor.", "simplex-unique-overlay-card-2-p-2": "Mesmo com o endereço de usuário opcional, embora ele possa ser usado para enviar solicitações de contato de spam, você pode alterá-lo ou excluí-lo completamente sem perder nenhuma das suas conexões.", "simplex-unique-overlay-card-2-p-1": "Como você não tem um identificador na plataforma SimpleX, ninguém pode entrar em contato com você, a menos que compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", @@ -147,8 +147,7 @@ "sign-up-to-receive-our-updates": "Inscreva-se para receber nossas atualizações", "enter-your-email-address": "Digite seu endereço de email", "get-simplex": "Obtenha o SimpleX aplicativo desktop", - "why-simplex-is": "Por que o SimpleX é", - "unique": "único", + "why-simplex-is-unique": "Por que o SimpleX é único", "learn-more": "Saiba mais", "more-info": "Mais informações", "contact-hero-header": "Você recebeu um endereço para se conectar no SimpleX Chat", diff --git a/website/langs/ru.json b/website/langs/ru.json index c584d93355..335b19ab11 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -1,259 +1,258 @@ { "copy-the-command-below-text": "скопируйте приведенную ниже команду и используйте ее в чате:", - "copyright-label": "© 2020-2024 SimpleX | Проект с открытым исходным кодом", + "copyright-label": "© 2020-2025 SimpleX | Проект с открытым исходным кодом", "chat-bot-example": "Пример Чат бота", "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", - "simplex-private-card-1-point-2": "Криптобокс NaCL в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометации TLS.", - "contact-hero-p-1": "Открытые ключи и адрес очереди сообщений по этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", + "simplex-private-card-1-point-2": "NaCL cryptobox в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометированного TLS.", + "contact-hero-p-1": "Публичные ключи и адрес очереди сообщений в этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", "guide-dropdown-5": "Управление данными", "scan-the-qr-code-with-the-simplex-chat-app": "Отсканируйте QR-код с помощью приложения SimpleX Chat", - "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными посредниками для доставки сообщений.", + "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными серверами доставки сообщений.", "simplex-unique-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", - "feature-7-title": "Портативное, зашифрованное хранилище в приложении — можно перенести профиль на другое устройство", - "no-federated": "Нет - федеративный", - "hero-2-header": "Как начать общаться приватно", + "feature-7-title": "Зашифрованное база данных — Вы можете перенести профиль на другое устройство", + "no-federated": "Нет - федеративные", + "hero-2-header": "Установите конфиденциальное соединение", "simplex-unique-overlay-card-3-p-3": "В отличие от серверов федеративных сетей (электронной почты, XMPP или Matrix), серверы SimpleX не хранят учетные записи пользователей, они только ретранслируют сообщения, защищая конфиденциальность обеих сторон.", - "hero-subheader": "Первый мессенджер
не нуждающийся в идентификаторах
пользователя", - "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй - это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистан под подозревается в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", - "signing-key-fingerprint": "Отпечаток ключа подписи (SHA-256)", + "hero-subheader": "Первый мессенджер
без идентификаторов пользователей", + "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", + "signing-key-fingerprint": "Идентификатор ключа подписи (SHA-256)", "simplex-network-2-desc": "Серверные узлы SimpleX НЕ хранят профили пользователей, контакты и доставленные сообщения, НЕ подключаются друг к другу, и НЕ имеют каталога серверов.", "simplex-privacy": "Конфиденциальность SimpleX", "docs-dropdown-5": "Свой XFTP Сервер", - "simplex-private-card-3-point-2": "Отпечаток сервера и привязка канала предотвращают MITM атаки и Атаки повторного воспроизведения.", + "simplex-private-card-3-point-2": "Идентификатор сервера и привязка к TLS сессии предотвращают атаки перехвата (MITM) и повторного использования.", "docs-dropdown-3": "Доступ к в базе данных чата", "installing-simplex-chat-to-terminal": "Установка SimpleX Chat в терминале", "use-this-command": "Используйте эту команду:", - "simplex-explained": "Простое объяснение вкратце", + "simplex-explained": "Как SimpleX работает", "to-make-a-connection": "Чтобы установить соединение:", - "comparison-section-list-point-6": "Хотя P2P распределены, они не являются федеративными, то есть P2P - работают как единая сеть", - "hero-overlay-2-textlink": "Как работает SimpleX?", + "comparison-section-list-point-6": "Хотя P2P распределены, они не являются федеративными, то есть P2P — работают как единая сеть", + "hero-overlay-2-textlink": "Как SimpleX работает?", "simplex-chat-via-f-droid": "SimpleX Chat в F-Droid", - "privacy-matters-overlay-card-1-p-1": "Многие крупные компании используют информацию о том, с кем вы связаны, чтобы оценить ваш доход, продавать вам больше товаров, которые вам на самом деле не нужны, и определять из этой информации, выгодные для них цены.", - "privacy-matters-1-overlay-1-title": "Конфиденциальность экономит ваши деньги", - "simplex-private-card-5-point-1": "SimpleX использует заполнение содержимого для каждого уровня шифрования, чтобы предотвратить атаки на размер сообщения.", - "privacy-matters-2-overlay-1-linkText": "Конфиденциальность дает вам власть", - "hero-overlay-3-title": "Оценки безопасности", - "enter-your-email-address": "Email адрес", - "simplex-explained-tab-1-text": "1. Как это видят пользователи", + "privacy-matters-overlay-card-1-p-1": "Многие крупные компании используют информацию о том, с кем Вы связаны, чтобы оценить Ваш доход, продавать Вам больше товаров, которые Вам на самом деле не нужны, и определять из этой информации, выгодные для них цены.", + "privacy-matters-1-overlay-1-title": "Конфиденциальность экономит Ваши деньги", + "simplex-private-card-5-point-1": "SimpleX использует дополнение содержания для каждого уровня шифрования, чтобы скрыть размер сообщений.", + "privacy-matters-2-overlay-1-linkText": "Конфиденциальность дает Вам власть", + "hero-overlay-3-title": "Аудит безопасности", + "enter-your-email-address": "Введите Ваш адрес Email", + "simplex-explained-tab-1-text": "1. Что видят пользователи", "tap-to-close": "Нажмите, чтобы закрыть", "simplex-unique-card-4-p-2": "Вы можете использовать SimpleX со своими собственными серверами или с серверами, предоставленными нами — и при этом подключаться к любому пользователю SimpleX.", - "hero-overlay-card-3-p-2": "В ноябре 2022 года Trail of Bits провела обзор криптографии и сетевых компонентов SimpleX. Дополнительная информация.", - "hero-overlay-card-3-p-3": "В июле 2024 года Trail of Bits провела обзор криптографического дизайна протоколов SimpleX. Дополнительная информация.", - "feature-1-title": "Сообщения зашифрованные E2E-шифрованием
с поддержкой markdown и редактированием", + "hero-overlay-card-3-p-2": "В ноябре 2022 года Trail of Bits провела аудит криптографии и сетевых компонентов SimpleX. Дополнительная информация.", + "hero-overlay-card-3-p-3": "В июле 2024 года Trail of Bits провела аудит криптографического дизайна протоколов SimpleX. Дополнительная информация.", + "feature-1-title": "Сообщения с E2E-шифрованием
с форматированием и редактированием", "comparison-point-4-text": "Единая или Централизованная сеть", "guide-dropdown-9": "Установление соединений", - "simplex-unique-1-overlay-1-title": "Полная конфиденциальность вашей личности, профиля, контактов и других метаданных", - "hero-overlay-card-2-p-4": "SimpleX защищает от этих атак, поскольку в его конструкции нет никаких идентификаторов пользователей. И, если вы используете режим инкогнито, у вас будет другое отображаемое имя для каждого контакта, что позволит избежать какого-либо пересечения между ними.", - "privacy-matters-overlay-card-2-p-2": "Чтобы быть объективным и принимать независимые решения, вам необходимо контролировать свое информационное пространство. Это возможно только в том случае, если вы используете приватную, коммуникационную платформу, которая не имеет доступа к вашему социальному графу.", - "hero-overlay-card-2-p-1": "Когда у пользователей есть постоянные идентификаторы, даже если это просто случайное число, например идентификатор сеанса, существует риск того, что провайдер или злоумышленник могут наблюдайте за тем, как подключены пользователи и сколько сообщений они отправляют.", - "feature-3-title": "Децентрализованные группы — только
их участники знают, что они существуют", + "simplex-unique-1-overlay-1-title": "Полная конфиденциальность Вашей личности, профиля, контактов и метаданных", + "hero-overlay-card-2-p-4": "SimpleX защищает от этих атак, поскольку он не использует никакие идентификаторы профилей пользователей. И, если Вы используете режим инкогнито, у Вас будет другое отображаемое имя для каждого контакта, что позволит избежать какого-либо пересечения между ними.", + "privacy-matters-overlay-card-2-p-2": "Чтобы быть объективным и принимать независимые решения, необходимо контролировать свое информационное пространство. Это возможно только, если Вы используете конфиденциальную коммуникационную сеть, которая не имеет доступа к контактам Вашей социальной сети.", + "hero-overlay-card-2-p-1": "Когда у пользователя есть постоянный идентификатор, даже если это просто случайное число, например Session ID, существует риск того, что провайдер или злоумышленник могут наблюдать за тем, как пользователи соединены и сколько сообщений они отправляют.", + "feature-3-title": "Децентрализованные группы — известные только участникам", "glossary": "Глоссарий", "simplex-network-overlay-1-title": "Сравнение с протоколами обмена сообщениями P2P", - "comparison-section-list-point-7": "Сети P2P либо имеют центральный орган управления, либо вся сеть может быть скомпрометирована", - "simplex-unique-overlay-card-4-p-1": "Вы можете использовать SimpleX со своими собственными серверами или предоставленными нами серверами, при этом имея возможность общаться с любым пользователем.", - "simplex-explained-tab-3-p-1": "Серверы имеют отдельные, Анонимные учётные данные для каждой очереди и не знают, к каким пользователям они принадлежат.", - "docs-dropdown-1": "Платформа SimpleX", - "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым, Сквозным шифрованием.", + "comparison-section-list-point-7": "Сети P2P либо имеют центральный компонент, либо вся сеть может быть атакована", + "simplex-unique-overlay-card-4-p-1": "Вы можете использовать SimpleX со своими собственными серверами или с серверами предустановленными в приложении, при этом имея возможность общаться с любым пользователем.", + "simplex-explained-tab-3-p-1": "Серверы имеют отдельные, анонимные учётные данные для каждой очереди и не знают, каким пользователям они принадлежат.", + "docs-dropdown-1": "Сеть SimpleX", + "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым сквозным шифрованием.", "simplex-chat-for-the-terminal": "SimpleX Chat для терминала", - "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и большинство существующих реализаций не используют внеполосные сообщения для первоначального обмена ключами. SimpleX использует внеполосные сообщения или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", + "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и многие сети не используют сообщения вне протокола для первоначального обмена ключами. SimpleX использует сообщения вне протокола или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", "the-instructions--source-code": "SimpleX Chat.", "simplex-network-section-desc": "SimpleX Chat обеспечивает наилучшую конфиденциальность, сочетая преимущества P2P и федеративных сетей.", - "privacy-matters-section-subheader": "Сохранение конфиденциальности ваших метаданных — с кем вы общаетесь — защищает вас от:", - "if-you-already-installed": "Если вы уже установили", - "simplex-explained-tab-3-p-2": "Пользователи могут еще больше повысить свою конфиденциальность скрыв свой IP-адрес, например используя сеть Tor для доступа к серверам.", - "join": "Присоединяйся к", - "privacy-matters-section-header": "Почему приватность важна", - "hero-overlay-1-textlink": "Почему идентификаторы пользователя - вредны для приватности?", + "privacy-matters-section-subheader": "Сохранение конфиденциальности Ваших метаданных — с кем Вы общаетесь — защищает Вас от:", + "if-you-already-installed": "Если Вы уже установили", + "simplex-explained-tab-3-p-2": "Пользователи могут повысить свою конфиденциальность используя сеть Tor для доступа к серверам.", + "join": "Присоединяйтесь к", + "privacy-matters-section-header": "Почему конфиденциальность важна", + "hero-overlay-1-textlink": "Почему идентификаторы пользователя уменьшают конфиденциальность?", "on-this-page": "На этой странице", "privacy-matters-overlay-card-1-p-2": "Интернет-магазины знают, что люди с более низкими доходами с большей вероятностью совершают срочные покупки, поэтому они могут устанавливать более высокие цены или отменять скидки.", - "simplex-unique-3-overlay-1-title": "Контроль и безопасность ваших данных", + "simplex-unique-3-overlay-1-title": "Контроль и безопасность Ваших данных", "protocol-3-text": "Протоколы P2P", - "simplex-private-card-6-point-2": "Чтобы предотвратить это, приложения SimpleX передают одноразовые ключи внеполосно, когда вы делитесь адресом в виде ссылки или QR-кода.", + "simplex-private-card-6-point-2": "Чтобы предотвратить это, приложения SimpleX передают одноразовые ключи вне протокола, когда Вы делитесь адресом в виде ссылки или QR-кода.", "no": "Нет", - "contact-hero-header": "Вы получили адрес для подключения в SimpleX Chat", + "contact-hero-header": "Вы получили адрес контакта для соединения в SimpleX Chat", "feature-8-title": "Режим инкогнито —
уникальный для SimpleX Chat", - "why-simplex": "Что делает SimpleX уникальным", + "why-simplex": "Почему SimpleX", "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", - "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на вашем телефоне или планшете.", + "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на Вашем телефоне или планшете.", "simplex-unique-2-overlay-1-title": "Лучшая защита от спама и злоупотреблений", - "simplex-private-6-title": "Внеполосный
Обмен ключами", - "join-us-on-GitHub": "Присоединяйтесь к нам на GitHub", + "simplex-private-6-title": "Обмен ключами
вне протокола", + "join-us-on-GitHub": "Присоединяйтесь на GitHub", "comparison-section-header": "Сравнение с другими протоколами", - "invitation-hero-header": "Вы получили одноразовую ссылку для подключения в SimpleX Chat", - "no-secure": "Нет - безопасно", - "hero-overlay-card-1-p-2": "Для доставки сообщений вместо идентификаторов пользователей, используемых всеми другими платформами, SimpleX использует временные, анонимные, попарные идентификаторы очередей сообщений, отдельно для каждого из ваших контактов — нет долгосрочных идентификаторов.", + "invitation-hero-header": "Вы получили одноразовую ссылку для соединения в SimpleX Chat", + "no-secure": "Нет - безопасный", + "hero-overlay-card-1-p-2": "Для доставки сообщений вместо идентификаторов пользователей, используемых другими сетями, SimpleX использует временные, анонимные, парные идентификаторы очередей сообщений, отдельные для каждого Вашего контакта — без долгосрочных идентификаторов.", "simplex-explained-tab-1-p-2": "Как это может работать с однонаправленными очередями и без идентификаторов профиля пользователя?", "simplex-network-1-header": "В отличие от P2P-сетей", "jobs": "Присоединиться к команде", "simplex-private-card-7-point-2": "Если какое-либо сообщение будет добавлено, удалено или изменено, получатель будет предупрежден об этом.", - "simplex-unique-3-title": "Только вы контролируете
свои данные", + "simplex-unique-3-title": "Только Вы контролируете
Ваши данные", "guide-dropdown-3": "Секретные группы", "no-resilient": "Нет - устойчив", - "hide-info": "Спрятать информацию", + "hide-info": "Скрыть информацию", "privacy-matters-overlay-card-3-p-4": "Недостаточно просто использовать мессенджер со сквозным шифрованием, мы все должны использовать мессенджеры, которые защищают конфиденциальность наших личных сетей — с какими людьми мы связаны.", - "releases-to-this-repo-are-done-1-2-days-later": "Выпуск новых версий в этом репозитории выходит с задержкой в несколько дней", + "releases-to-this-repo-are-done-1-2-days-later": "Новые версии в этом репозитории публикуются с задержкой в несколько дней", "comparison-point-1-text": "Требуется глобальный идентификатор", "comparison-section-list-point-5": "Не защищает конфиденциальность пользовательских метаданных", - "hero-overlay-card-2-p-2": "Затем они могли бы сопоставить эту информацию с существующими общедоступными социальными сетями и определить некоторые реальные личности.", - "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные графики для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"премия за бедность\".", + "hero-overlay-card-2-p-2": "Эта информация может быть сопоставлена с социальными сетями, чтобы определить реальные личности пользователей.", + "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные сети для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"наценка за бедность\".", "comparison-point-3-text": "Зависимость от DNS", "yes": "Да", "docs-dropdown-6": "Сервера WebRTC", "newer-version-of-eng-msg": "Существует более новая версия этой страницы на английском языке.", "install-simplex-app": "Установите приложение SimpleX", - "hero-overlay-3-textlink": "Оценки безопасности", + "hero-overlay-3-textlink": "Аудит безопасности", "comparison-point-2-text": "Возможность MITM", - "scan-the-qr-code-with-the-simplex-chat-app-description": "Открытые ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Публичные ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", "guide-dropdown-8": "Настройки приложения", - "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной картины общения пользователя или подключений.", + "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной информации о разговорах и контактах пользователя.", "smp-protocol": "Протокол SMP", "open-simplex-app": "Откройте приложение SimpleX", "see-simplex-chat": "Инструкции по загрузке или компиляции SimpleX Chat из исходного кода приведены в", "terminal-cli": "Приложение для терминала (CLI)", "comparison-section-list-point-1": "Обычно требуется номера телефона, в некоторых случаях — имя пользователя", - "simplex-explained-tab-3-text": "3. Что видят сервера", - "github-repository": "репозиторий Github", + "simplex-explained-tab-3-text": "3. Что видят серверы", + "github-repository": "репозитории GitHub", "feature-5-title": "Исчезающие сообщения", "connect-in-app": "Подключитесь в приложении", "menu": "Меню", - "simplex-private-card-4-point-1": "Чтобы защитить свой IP-адрес вы можете подключаться к серверам через сеть Tor или какую-либо другую транспортную оверлейную сеть.", - "privacy-matters-3-title": "Судебное преследование не виновных", - "comparison-point-5-text": "Атака на центральный компонент или другая сетевая атака", + "simplex-private-card-4-point-1": "Чтобы защитить свой IP-адрес Вы можете подключаться к серверам через сеть Tor или какую-либо другую транспортную оверлейную сеть.", + "privacy-matters-3-title": "Преследование из-за невиновного общения", + "comparison-point-5-text": "Центральный компонент или атака на всю сеть", "click-to-see": "Нажать здесь, чтобы увидеть", - "donate-here-to-help-us": "Пожертвуйте здесь, чтобы помочь нам", + "donate-here-to-help-us": "Пожертвовать", "simplex-private-1-title": "2-уровневое
сквозное шифрование", - "simplex-unique-card-1-p-2": "В отличие от любой другой существующей платформы обмена сообщениями, SimpleX не имеет идентификаторов пользователей — нету даже случайных цифр.", - "privacy-matters-2-overlay-1-title": "Конфиденциальность дает вам власть", - "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", + "simplex-unique-card-1-p-2": "В отличие от любой другой существующей сети обмена сообщениями, SimpleX не имеет идентификаторов пользователей — нет даже случайных цифр.", + "privacy-matters-2-overlay-1-title": "Конфиденциальность дает Вам власть", + "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, Вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", "simplex-unique-4-overlay-1-title": "Полностью децентрализованная — пользователи владеют сетью SimpleX", "guide-dropdown-2": "Отправка сообщений", - "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по его смягчению требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не имеет функционала по обмену серверами, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку по всей сети - невозможно.", - "simplex-private-2-title": "Дополнительный уровень
шифрования сервера", - "hero-overlay-card-1-p-4": "Такая конструкция предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты вашего IP-адреса вы можете подключиться к серверам обмена сообщениями через сеть Tor.", + "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по уменьшению риска требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не позволяет обнаруживать серверы, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку на всю сеть невозможно.", + "simplex-private-2-title": "Дополнительный уровень
шифрования с сервером", + "hero-overlay-card-1-p-4": "Этот подход предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты Вашего IP-адреса Вы можете подключиться к серверам обмена сообщениями через сеть Tor.", "f-droid-org-repo": "Репозиторий F-Droid.org", "guide-dropdown-4": "Профили чата", "simplex-network-2-header": "В отличие от федеративных сетей", - "see-here": "подробней тут", - "simplex-private-3-title": "Безопасный аутентифицированный
протокол TLS", - "comparison-section-list-point-3": "Открытый ключ или какой-либо другой глобально уникальный идентификатор", - "hero-overlay-card-2-p-3": "Даже в самых приватных приложениях, использующих скрытые сервисы Tor v3, если вы общаетесь с двумя разными контактами через один и тот же профиль, они могут доказать, что они являются связаны с одним и тем же человеком.", - "simplex-private-4-title": "Вариант доступа
через сеть Tor", + "see-here": "подробнее здесь", + "simplex-private-3-title": "Безопасное аутентифицированное
соединение TLS", + "comparison-section-list-point-3": "Публичный ключ или какой-либо другой глобально уникальный идентификатор", + "hero-overlay-card-2-p-3": "Даже в самых конфиденциальных приложениях, использующих скрытые адреса Tor, если Вы общаетесь с двумя разными контактами через один и тот же профиль, они могут доказать, что они связаны с одним и тем же человеком.", + "simplex-private-4-title": "Возможен доступ
через сеть Tor", "privacy-matters-1-title": "Реклама и ценовая дискриминация", "simplex-unique-card-3-p-1": "SimpleX хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных — их можно перенести на другое устройство.", - "hero-overlay-1-title": "Как работает SimpleX?", + "hero-overlay-1-title": "Как SimpleX работает?", "stable-versions-built-by-f-droid-org": "Стабильные версии, созданные F-Droid.org", - "contact-hero-p-3": "Воспользуйтесь ссылками ниже, чтобы загрузить приложение.", + "contact-hero-p-3": "Используйте ссылки ниже, чтобы загрузить приложение.", "simplex-network": "Сеть SimpleX", - "privacy-matters-3-overlay-1-title": "Конфиденциальность защищает вашу свободу", + "privacy-matters-3-overlay-1-title": "Конфиденциальность защищает Вашу свободу", "docs-dropdown-7": "Перевести SimpleX Chat", "back-to-top": "Вернуться к началу", "simplex-network-1-desc": "Все сообщения отправляются через серверы, что обеспечивает лучшую конфиденциальность метаданных и надежную асинхронную доставку сообщений, избегая при этом многих", "simplex-chat-repo": "Репозиторий SimpleX Chat", "simplex-private-card-6-point-1": "Многие коммуникационные платформы уязвимы для MITM-атак со стороны серверов или сетевых провайдеров.", - "privacy-matters-3-overlay-1-linkText": "Конфиденциальность защищает вашу свободу", - "simplex-unique-overlay-card-1-p-2": "Для доставки сообщений SimpleX использует попарные, анонимные адреса однонаправленных очередей сообщений, раздельные для полученных и отправленных сообщений, обычно через разные серверы. Использование SimpleX это как иметь отдельный “одноразовый” адрес электронной почты или номер телефона для каждого контакта, при это, не обременяя вас управлять эти вручную.", - "simplex-unique-overlay-card-3-p-4": "Со стороны не видно разницу между отправлением или получением сообщений — если кто-то наблюдает за этим, он не cможет легко определить, кто с кем общается, даже если протокол TLS будет скомпрометирован.", + "privacy-matters-3-overlay-1-linkText": "Конфиденциальность защищает Вашу свободу", + "simplex-unique-overlay-card-1-p-2": "Для доставки сообщений SimpleX использует попарные, анонимные адреса однонаправленных очередей сообщений, раздельные для полученных и отправленных сообщений, обычно через разные серверы.", + "simplex-unique-overlay-card-3-p-4": "Со стороны не видно разницы между отправлением или получением сообщений — если кто-то наблюдает за этим, он не cможет легко определить, кто с кем общается, даже если протокол TLS будет скомпрометирован.", "docs-dropdown-2": "Доступ к файлам в версии для Android", - "get-simplex": "Скачать SimpleX для ПК", - "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть вас опасности, например за ваши политические взгляды, даже если кажется, что вам \"нечего скрывать\".", + "get-simplex": "Скачать SimpleX для компьютера", + "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть Вас опасности, например за Ваши политические взгляды, даже если кажется, что Вам \"нечего скрывать\".", "simplex-unique-2-title": "Вы защищены от
спама и злоупотреблений", - "simplex-unique-overlay-card-4-p-3": "Если вы рассматриваете возможность разработки платформе SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", + "simplex-unique-overlay-card-4-p-3": "Если Вы рассматриваете возможность разработки для сети SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в Ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", "comparison-section-list-point-2": "Адреса на основе DNS", "stable-and-beta-versions-built-by-developers": "Стабильные и бета-версии, созданные разработчиками", "simplex-network-3-header": "Сеть SimpleX", - "simplex-unique-card-2-p-1": "Поскольку у вас нет идентификатора или фиксированного адреса на платформе SimpleX, никто не сможет связаться с вами, без вашего явного согласия, только если вы сами поделитесь адресом в виде QR-кода или ссылки.", - "hero-overlay-card-3-p-1": "Trail of Bits - ведущая консалтинговая компания в области безопасности и технологий, клиентами которой являются крупные технологические компании, правительственные агентства и крупные блокчейн проекты.", - "hero-header": "Иной взгляд на приватность", + "simplex-unique-card-2-p-1": "Поскольку у Вас нет идентификатора или фиксированного адреса в сети SimpleX, никто не сможет связаться с Вами без Вашего явного согласия mdash; это возможно, только если Вы сами поделитесь адресом в виде QR-кода или ссылки.", + "hero-overlay-card-3-p-1": "Trail of Bits — ведущая консалтинговая компания в области безопасности, клиентами которой являются крупные технологические компании, правительственные агентства и крупные блокчейн проекты.", + "hero-header": "Более конфиденциальный", "comparison-section-list-point-4": "Если операторы серверов скомпрометированы. В Signal, и некоторых других приложениях, есть возможность подтвердить код безопасности", - "simplex-private-card-2-point-1": "Дополнительный уровень серверного шифрования для доставки получателю, чтобы предотвратить корреляцию между полученным и отправленным трафиком сервера, если используемый протокол TLS скомпрометированный.", - "f-droid-page-simplex-chat-repo-section-text": "Чтобы добавить его в свой клиент F-Droid, отсканируйте QR-код или воспользуйтесь этим URL-адресом:", - "guide-dropdown-7": "Приватность и безопасность", - "join-the-REDDIT-community": "Присоединяйтесь к нам в сообществе REDDIT", - "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, обеспечивая лучшую конфиденциальность метаданных, чем другие альтернативы.", + "simplex-private-card-2-point-1": "Дополнительный уровень шифрования между сервером и получателем для предотвращения корреляции полученного и отправленного трафика сервера, в случае компрометированного TLS.", + "f-droid-page-simplex-chat-repo-section-text": "Чтобы добавить его в свой клиент F-Droid, отсканируйте QR-код или используйте этот адрес:", + "guide-dropdown-7": "Конфиденциальность и безопасность", + "join-the-REDDIT-community": "Присоединяйтесь в REDDIT", + "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, и обеспечивает лучшую конфиденциальность метаданных, чем альтернативы.", "privacy-matters-2-title": "Манипулирование выборами", - "home": "Домашняя страница", + "home": "Главная", "chat-protocol": "Протокол чата", - "simplex-private-card-5-point-2": "Это позволяет сообщениям разного размера выглядеть одинаково для серверов и сетевых наблюдателей.", - "hero-overlay-card-1-p-1": "Многие спрашивают:Если у SimpleX нету никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", - "feature-6-title": "Зашифрованные E2E-шифрованием аудио и видео звонки", - "hero-p-1": "Другие приложения имеют ID своих пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не имет, нету даже случайных цифр.
Это значительно повышает вашу приватность.", - "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторов его пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователя.", - "simplex-unique-4-title": "Только вы владеете
сетью SimpleX", + "simplex-private-card-5-point-2": "Это делает сообщениям разного размера одинаковыми для серверов и сети.", + "hero-overlay-card-1-p-1": "Многие спрашивают: Если у SimpleX нет никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", + "feature-6-title": "Аудио и видео звонки с E2E-шифрованием", + "hero-p-1": "Другие приложения используют ID пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не использует, даже случайных цифр.
Это значительно повышает Вашу конфиденциальность.", + "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторах пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователей.", + "simplex-unique-4-title": "Вы владеете
сетью SimpleX", "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", - "blog": "Новости", - "simplex-private-card-7-point-1": "Чтобы гарантировать целостность, сообщения последовательно нумеруются и включают в себя хэш предыдущего сообщения.", - "simplex-unique-overlay-card-4-p-2": "Платформа SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем возможности увидеть, какие сервисы SimpleX вы сможете создать.", + "blog": "Блог", + "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хэш предыдущего сообщения.", + "simplex-unique-overlay-card-4-p-2": "Сеть SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем сервисы SimpleX, которые Вы создадите.", "simplex-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", "contact-hero-p-2": "Еще не скачали SimpleX Chat?", - "why-simplex-is": "Почему SimpleX", + "why-simplex-is-unique": "Почему SimpleX уникальный", "simplex-network-section-header": "Сеть SimpleX", "simplex-private-10-title": "Временные анонимные парные идентификаторы", - "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит ваши деньги", + "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит Ваши деньги", "tap-the-connect-button-in-the-app": "Нажмите на кнопку ’подключиться’ в приложении", "comparison-section-list-point-4a": "Сервера SimpleX не могут скомпрометировать сквозное шифрование", - "unique": "уникальный", "simplex-network-1-overlay-linktext": "проблем P2P сетей", - "no-private": "Нет - приватно", - "simplex-unique-1-title": "У вас есть полная
конфиденциальность", + "no-private": "Нет - конфиденциальный", + "simplex-unique-1-title": "У Вас есть полная
конфиденциальность", "protocol-2-text": "XMPP, Matrix", "guide": "Руководство", - "simplex-network-overlay-card-1-li-4": "Реализации P2P могут быть заблокированы некоторыми интернет-провайдерами (например, BitTorrent). SimpleX не зависит от транспорта - он может работать по стандартным веб-протоколам, например WebSockets.", - "hero-overlay-2-title": "Почему идентификаторы пользователя - вредны для приватности?", + "simplex-network-overlay-card-1-li-4": "Реализации P2P могут быть заблокированы некоторыми интернет-провайдерами (например, BitTorrent). SimpleX протокол не зависит от транспорта — он может быть реализован через стандартные веб-протоколы, например WebSockets.", + "hero-overlay-2-title": "Почему идентификаторы пользователя уменьшают конфиденциальность?", "simplex-explained-tab-2-text": "2. Как это работает", "docs-dropdown-4": "Свой SMP Сервер", - "feature-4-title": "Зашифрованные E2E-шифрованием голосовые сообщения", - "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши социальные графики для искажения нашего мнения из реального мира и манипулируют нашими голосами.", - "privacy-matters-overlay-card-2-p-3": "SimpleX - это первая платформа, которая по своей конструкции не имеет никаких идентификаторов пользователей, таким образом защищая график ваших контактов лучше, чем любая известная альтернатива.", - "learn-more": "Учить больше", + "feature-4-title": "Голосовые сообщения с E2E-шифрованием", + "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши контакты в социальных сетях, чтобы изменять наши мнения и манипулировать нашими голосами.", + "privacy-matters-overlay-card-2-p-3": "SimpleX — это первая сеть, которая не имеет никаких идентификаторов пользователей, таким образом защищая Ваши контакты лучше, чем любая известная альтернатива.", + "learn-more": "Узнать больше", "donate": "Пожертвовать", "simplex-private-8-title": "Смешивание сообщений
для уменьшения корреляции", "scan-qr-code-from-mobile-app": "Отсканируйте QR-код в мобильном приложении", "simplex-private-card-3-point-3": "Возобновление соединения отключено для предотвращения сеансовых атак.", - "simplex-private-card-10-point-1": "SimpleX использует временные анонимные попарные адреса и учетные данные для каждого контакта пользователя или члена группы.", + "simplex-private-card-10-point-1": "SimpleX использует временные анонимные парные адреса и учетные данные для каждого контакта пользователя или члена группы.", "guide-dropdown-6": "Аудио и видео Звонки", - "more-info": "Больше информации", + "more-info": "Дополнительная информации", "no-decentralized": "Нет - децентрализованный", "protocol-1-text": "Signal, большие платформы", - "hero-2-header-desc": "В видео показано, как подключиться к своему другу с помощью его одноразового QR-кода, лично или по видеосвязи. Вы также можете подключиться, поделившись ссылкой-приглашением.", - "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и усиливать/увеличивать объём трафика, что приводит к отказу в обслуживании по всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для нагрузки трафика всей сети.", - "if-you-already-installed-simplex-chat-for-the-terminal": "Если вы уже установили SimpleX Chat для терминала", + "hero-2-header-desc": "В видео показано, как подключиться к Вашему другу через одноразовый QR-код, при встрече или во время видеосвязи. Вы также можете соединится, поделившись ссылкой-приглашением.", + "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": "Протокол с двойным храповым механизмом —
обмен сообщениями OTR с идеальной секретностью пересылки и восстановлением после взлома.", + "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": "Введите ваш Email, чтобы получать рассылку обновлений от нас", + "simplex-unique-overlay-card-2-p-1": "Поскольку у Вас нет идентификатора в сети SimpleX, никто не сможет связаться с Вами, если Вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", + "sign-up-to-receive-our-updates": "Подпишитесь на нашу рассылку новостей", "guide-dropdown-1": "Быстрый старт", - "simplex-explained-tab-2-p-1": "Для каждого подключения вы используете две отдельные очереди обмена сообщениями, то есть отправка и получения сообщений происходит через разные серверы.", - "simplex-private-section-header": "Что делает SimpleX приватным", - "we-invite-you-to-join-the-conversation": "Мы приглашаем вас присоединиться к беседе", - "feature-2-title": "Изображения, видео и файлы
зашифрованные E2E-шифрованием", + "simplex-explained-tab-2-p-1": "Для каждого контакта Вы используете две отдельные очереди сообщений, отправляя и получая сообщения через разные серверы.", + "simplex-private-section-header": "Что делает SimpleX конфиденциальным", + "we-invite-you-to-join-the-conversation": "Мы приглашаем Вас присоединиться к разговору", + "feature-2-title": "Изображения, видео и файлы
с E2E-шифрованием", "simplex-private-9-title": "Однонаправленные
очереди сообщений", - "simplex-unique-overlay-card-1-p-3": "Этот дизайн защищает конфиденциальность того, с кем вы общаетесь, скрывая это от серверов SimpleX и от любых наблюдателей из вне. Чтобы скрыть свой IP-адрес от серверов, вы можете подключиться к серверам SimpleX через сеть Tor.", + "simplex-unique-overlay-card-1-p-3": "Этот дизайн защищает конфиденциальность Ваших контактов, скрывая их от серверов SimpleX и от любых внешних наблюдателей. Чтобы скрыть свой IP-адрес от серверов, Вы можете подключиться к серверам SimpleX через сеть Tor.", "developers": "Разработчики", - "simplex-private-7-title": "Проверка целостности
сообщения", - "privacy-matters-overlay-card-1-p-4": "Платформа SimpleX защищает конфиденциальность ваших контактов лучше, чем любая другая альтернатива, полностью предотвращая доступ к вашему социальному графику каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", - "hero-overlay-card-1-p-6": "Подробнее читайте в техническом документе SimpleX.", + "simplex-private-7-title": "Проверка неизменности
сообщений", + "privacy-matters-overlay-card-1-p-4": "Сеть SimpleX защищает конфиденциальность Ваших контактов лучше, чем альтернативы, предотвращая доступ к Вашей социальной сети каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", + "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", "terms-and-privacy-policy": "Политика Конфиденциальности", - "simplex-network-overlay-card-1-li-1": "Сети P2P полагаются на тот или иной вариант DHT для маршрутизации сообщений. Проекты DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети - O(log N).", - "privacy-matters-section-label": "Убедитесь, что ваш мессенджер не может получить доступ к вашим данным!", + "simplex-network-overlay-card-1-li-1": "Сети P2P используют DHT (распределенные хэш-таблицы) для маршрутизации сообщений. DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети — O(log N).", + "privacy-matters-section-label": "Убедитесь, что Ваш мессенджер не может получить доступ к Вашим данным!", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных которую можно перенести на другое устройство.", "simplex-network-3-desc": "серверы предоставляют однонаправленные очереди для подключения пользователей, но у них нет видимости графика сетевых подключений — это делают только пользователи.", - "simplex-unique-card-1-p-1": "SimpleX защищает конфиденциальность вашего профиля, контактов и метаданных, скрывая их от серверов платформы SimpleX и любых наблюдателей.", - "simplex-private-card-3-point-1": "Для соединений клиент-сервер используется только протокол TLS 1.2/1.3 с надежными алгоритмами.", - "simplex-unique-card-4-p-1": "Сеть SimpleX полностью децентрализована и независима от любой криптовалюты/блокчейна или любой другой платформы, кроме Интернета.", - "features": "Возможности", - "hero-overlay-card-1-p-3": "Вы определяете, какие серверы будете использовать для получения сообщений, а ваши контакты — серверы, которые вы используете для отправки им сообщений. Каждый новый чат, скорее всего, будет вестись на двух разных серверах.", + "simplex-unique-card-1-p-1": "SimpleX защищает конфиденциальность Вашего профиля, контактов и метаданных, скрывая их от серверов сети SimpleX и любых наблюдателей.", + "simplex-private-card-3-point-1": "Для соединений клиента и сервера используется только протокол TLS 1.2/1.3 с безопасными алгоритмами.", + "simplex-unique-card-4-p-1": "Сеть SimpleX полностью децентрализована и независима от любой криптовалюты/блокчейна или любой другой сети, кроме Интернета.", + "features": "Функции", + "hero-overlay-card-1-p-3": "Вы определяете, какие серверы использовать для получения сообщений, а Ваши контакты — серверы, которые Вы используете для отправки им сообщений. Каждый новый чат, скорее всего, будет вестись на двух разных серверах.", "docs-dropdown-9": "Скачать", "simplex-chat-protocol": "Протокол SimpleX Chat", - "simplex-unique-overlay-card-1-p-1": "В отличие от других платформ обмена сообщениями, SimpleX не имеет идентификаторов, присвоенных пользователям. Он не полагается на номера телефонов, доменные адреса (например, электронную почту или XMPP), имена пользователей, открытые ключи или даже случайные числа для идентификации своих пользователей — мы не знаем, сколько людей пользуются нашими SimpleX серверами.", - "reference": "Ссылки", - "f-droid-page-f-droid-org-repo-section-text": "Приложение SimpleX Chat от разработчиков и от репозитория F-Droid.org имеют разные ключи подписи. Если вы хотите сменить одно на другое, вам сначала нужно будет экспортировать базу данных и только потом скачать другое приложение.", - "simplex-private-5-title": "Многоуровневое
Заполнения содержимого", - "please-use-link-in-mobile-app": "Пожалуйста, воспользуйтесь ссылкой в мобильном приложении", + "simplex-unique-overlay-card-1-p-1": "В отличие от других сетей обмена сообщениями, SimpleX не имеет идентификаторов, присвоенных пользователям. Он не полагается на номера телефонов, доменные адреса (например, электронную почту или XMPP), имена пользователей, публичные ключи или даже случайные числа для идентификации своих пользователей — операторы серверов SimpleX не знают, сколько людей пользуются их серверами.", + "reference": "Документы", + "f-droid-page-f-droid-org-repo-section-text": "Приложение SimpleX Chat от разработчиков и от репозитория F-Droid.org имеют разные ключи подписи. Если Вы хотите сменить одно на другое, Вам сначала нужно будет экспортировать базу данных и только потом скачать другое приложение.", + "simplex-private-5-title": "Многоуровневое
дополнение сообщений", + "please-use-link-in-mobile-app": "Пожалуйста, используйте ссылку в мобильном приложении", "please-enable-javascript": "Пожалуйста, включите JavaScript, чтобы увидеть QR-код.", - "docs-dropdown-10": "Прозрачность", + "docs-dropdown-10": "Запросы данных", "docs-dropdown-12": "Безопасность", "docs-dropdown-11": "Часто задаваемые вопросы", "docs-dropdown-14": "SimpleX для бизнеса" diff --git a/website/langs/uk.json b/website/langs/uk.json index 794c65c956..71b3254e63 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -78,7 +78,7 @@ "smp-protocol": "Протокол SMP", "chat-protocol": "Протокол чату", "donate": "Пожертвувати", - "copyright-label": "© 2020-2024 SimpleX | Проект з відкритим кодом", + "copyright-label": "© 2020-2025 SimpleX | Проект з відкритим кодом", "simplex-chat-protocol": "Протокол чату SimpleX", "terminal-cli": "Термінал CLI", "hero-header": "Приватність переосмислена", @@ -152,7 +152,7 @@ "simplex-network-overlay-card-1-li-2": "У дизайні SimpleX, на відміну від більшості P2P-мереж, немає жодних глобальних ідентифікаторів користувачів будь-якого виду, навіть тимчасових, та використовуються лише тимчасові парні ідентифікатори, що забезпечує кращу анонімність та захист метаданих.", "privacy-matters-overlay-card-1-p-1": "Багато великих компаній використовують інформацію про те, з ким ви з'єднані, щоб оцінити ваш дохід, продавати вам продукти, які вам дійсно не потрібні, і визначати ціни.", "privacy-matters-overlay-card-3-p-3": "Звичайних людей арештовують за те, що вони публікують онлайн, навіть через свої 'анонімні' облікові записи, навіть у демократичних країнах.", - "simplex-unique-overlay-card-1-p-2": "Для доставки повідомлень SimpleX використовує парні анонімні адреси однобічних черг повідомлень, окремо для отриманих та відправлених повідомлень, зазвичай через різні сервери. Використання SimpleX схоже на наявність різної “витратної” електронної пошти або телефону для кожного контакту, і немає неприємностей у їх управлінні.", + "simplex-unique-overlay-card-1-p-2": "Для доставки повідомлень SimpleX використовує парні анонімні адреси однобічних черг повідомлень, окремо для отриманих та відправлених повідомлень, зазвичай через різні сервери.", "simplex-unique-overlay-card-1-p-3": "Цей дизайн захищає конфіденційність осіб, з якими ви спілкуєтеся, приховуючи це від серверів платформи SimpleX та будь-яких спостерігачів. Щоб сховати свою IP-адресу від серверів, ви можете підключитися до серверів SimpleX через Tor.", "simplex-unique-overlay-card-2-p-1": "Оскільки у вас немає ідентифікатора на платформі SimpleX, ніхто не може з вами зв'язатися, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, у вигляді QR-коду або посилання.", "simplex-unique-overlay-card-2-p-2": "Навіть з необов'язковою адресою користувача, яка може бути використана для відправки спамових запитань на зв'язок, ви можете змінити або повністю видалити її, не втрачаючи жодного з ваших з'єднань.", @@ -174,8 +174,7 @@ "sign-up-to-receive-our-updates": "Підпишіться, щоб отримувати наші оновлення", "enter-your-email-address": "Введіть свою електронну адресу", "get-simplex": "Отримати SimpleX додаток для настільних комп'ютерів", - "why-simplex-is": "Чому SimpleX є", - "unique": "унікальним", + "why-simplex-is-unique": "Чому SimpleX є унікальним", "learn-more": "Дізнатися більше", "more-info": "Додаткова інформація", "hide-info": "Приховати інформацію", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index c32bc2f123..c6712e7bbf 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -57,7 +57,7 @@ "simplex-chat-protocol": "SimpleX 聊天协议", "smp-protocol": "SMP协议", "chat-protocol": "聊天协议", - "copyright-label": "© 2020-2024 SimpleX | 开源项目", + "copyright-label": "© 2020-2025 SimpleX | 开源项目", "terminal-cli": "命令行程式", "simplex-explained-tab-1-p-1": "您可以创建联系人和群组,并进行双向对话,就像是任何其他即时通讯软件一样。", "hero-p-1": "其他应用——如Signal、Matrix、Session、Briar、Jami、Cwtch 等——都需要用户 ID。
而SimpleX 不需要用户ID,连随机生成的也不需要。
这从根本上改善了您的隐私。", @@ -143,7 +143,7 @@ "we-invite-you-to-join-the-conversation": "我们邀请您加入对话", "enter-your-email-address": "输入您的电子邮箱地址", "join-the-REDDIT-community": "加入 REDDIT 社区", - "why-simplex-is": "为什么 SimpleX 是", + "why-simplex-is-unique": "为什么 SimpleX 是 独特的", "learn-more": "了解更多", "join-us-on-GitHub": "在 GitHub 上加入我们", "more-info": "更多信息", @@ -205,7 +205,6 @@ "simplex-unique-overlay-card-2-p-2": "即使使用可选的用户地址,当它被用于发送垃圾邮件联系请求,您可以更改或完全删除它而不会丢失任何连接。", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat 使用便携式加密数据库格式仅将所有用户数据存储在客户端设备上,该格式可以导出并传输到任何支持的设备。", "donate-here-to-help-us": "在这里捐款来帮助我们", - "unique": "独特的", "simplex-unique-card-1-p-2": "与任何其他现有的消息传递平台不同,SimpleX 没有分配给用户的标识符—— 甚至随机数也没有。", "simplex-unique-overlay-card-2-p-1": "因为您在 SimpleX 平台上没有标识符,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", "simplex-unique-overlay-card-3-p-2": "端到端加密的消息在被收到前会暂时保存在 SimpleX 中继服务器上,传送完成后它们会被永久删除。", diff --git a/website/src/_data/languages.json b/website/src/_data/languages.json index 0ac05063cc..340cc59da9 100644 --- a/website/src/_data/languages.json +++ b/website/src/_data/languages.json @@ -33,12 +33,31 @@ "flag": "/img/flags/es.svg", "enabled": true }, + { + "label": "fi", + "name": "Suomi", + "flag": "/img/flags/fi.svg", + "enabled": true + }, { "label": "fr", "name": "Français", "flag": "/img/flags/fr.svg", "enabled": true }, + { + "label": "he", + "name": "עִברִית", + "flag": "/img/flags/il.svg", + "enabled": true, + "rtl": true + }, + { + "label": "hu", + "name": "Magyar", + "flag": "/img/flags/hu.svg", + "enabled": true + }, { "label": "it", "name": "Italiano", @@ -89,9 +108,9 @@ }, { "label": "ru", - "name": "Russian", + "name": "Русский", "flag": "/img/flags/ru.svg", - "enabled": false + "enabled": true } - ] + ] } \ No newline at end of file diff --git a/website/src/_includes/sections/simplex_unique.html b/website/src/_includes/sections/simplex_unique.html index 6759e3a5cb..cdccb3dd5a 100644 --- a/website/src/_includes/sections/simplex_unique.html +++ b/website/src/_includes/sections/simplex_unique.html @@ -1,6 +1,6 @@
-

{{ "why-simplex-is" | i18n({}, lang ) | safe }} {{ "unique" | i18n({}, lang ) | safe }}

+

{{ "why-simplex-is-unique" | i18n({}, lang ) | safe }}

diff --git a/website/src/img/flags/fi.svg b/website/src/img/flags/fi.svg new file mode 100644 index 0000000000..470be2d07c --- /dev/null +++ b/website/src/img/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website/src/img/flags/hu.svg b/website/src/img/flags/hu.svg new file mode 100644 index 0000000000..baddf7f5ea --- /dev/null +++ b/website/src/img/flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/src/img/flags/il.svg b/website/src/img/flags/il.svg new file mode 100644 index 0000000000..f43be7e8ed --- /dev/null +++ b/website/src/img/flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + From d53c13f8be83b1c88d44400164dd8ded323ac859 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 25 Apr 2025 11:17:09 +0100 Subject: [PATCH 194/289] docs: dependencies (#5850) --- docs/dependencies/HASKELL.md | 177 ++++++ docs/dependencies/README.md | 9 + .../licences/apps/sqlcipher/LICENSE.md | 24 + .../licences/apps/vlc/COPYING.LIB | 502 ++++++++++++++++++ .../dependencies/licences/apps/webrtc/LICENSE | 24 + .../licences/haskell/OneTuple-0.4.1.1/LICENSE | 34 ++ .../licences/haskell/Only-0.1/LICENSE | 30 ++ .../haskell/QuickCheck-2.14.3/LICENSE | 29 + .../licences/haskell/SHA-1.6.4.4/LICENSE | 29 + .../licences/haskell/StateVar-1.2.2/LICENSE | 29 + .../licences/haskell/aeson-2.2.1.0/LICENSE | 0 .../haskell/ansi-terminal-1.0/LICENSE | 22 + .../ansi-terminal-types-0.11.5/LICENSE | 25 + .../licences/haskell/appar-0.1.8/LICENSE | 29 + .../haskell/asn1-encoding-0.9.6/LICENSE | 27 + .../licences/haskell/asn1-parse-0.9.5/LICENSE | 27 + .../licences/haskell/asn1-types-0.3.4/LICENSE | 27 + .../licences/haskell/assoc-1.1/LICENSE | 30 ++ .../licences/haskell/async-2.2.5/LICENSE | 30 ++ .../haskell/attoparsec-0.14.4/LICENSE | 30 ++ .../haskell/auto-update-0.1.6/LICENSE | 20 + .../haskell/base-orphans-0.9.1/LICENSE | 20 + .../haskell/base64-bytestring-1.2.1.0/LICENSE | 30 ++ .../licences/haskell/basement-0.0.16/LICENSE | 28 + .../licences/haskell/bifunctors-5.6.1/LICENSE | 26 + .../licences/haskell/bitvec-1.1.5.0/LICENSE | 0 .../haskell/blaze-builder-0.4.2.3/LICENSE | 30 ++ .../haskell/blaze-textual-0.2.3.1/LICENSE | 0 .../licences/haskell/boring-0.2.1/LICENSE | 30 ++ .../licences/haskell/byteorder-1.0.4/LICENSE | 30 ++ .../bytestring-builder-0.10.8.2.0/LICENSE | 30 ++ .../haskell/case-insensitive-1.2.1.0/LICENSE | 31 ++ .../licences/haskell/cereal-0.5.8.3/LICENSE | 30 ++ .../licences/haskell/clock-0.8.4/LICENSE | 31 ++ .../licences/haskell/colour-2.3.6/LICENSE | 20 + .../licences/haskell/comonad-5.0.8/LICENSE | 27 + .../haskell/composition-1.0.2.2/LICENSE | 30 ++ .../licences/haskell/conduit-1.3.5/LICENSE | 0 .../haskell/conduit-extra-1.3.6/LICENSE | 0 .../licences/haskell/constraints-0.14/LICENSE | 0 .../haskell/contravariant-1.5.5/LICENSE | 30 ++ .../haskell/cryptohash-md5-0.11.101.0/LICENSE | 28 + .../cryptohash-sha1-0.11.101.0/LICENSE | 28 + .../licences/haskell/crypton-0.34/LICENSE | 28 + .../haskell/crypton-x509-1.7.6/LICENSE | 27 + .../haskell/crypton-x509-store-1.6.9/LICENSE | 27 + .../crypton-x509-validation-1.6.12/LICENSE | 27 + .../haskell/cryptostore-0.3.0.1/LICENSE | 30 ++ .../haskell/data-default-0.7.1.1/LICENSE | 26 + .../data-default-class-0.1.2.0/LICENSE | 26 + .../LICENSE | 26 + .../LICENSE | 26 + .../LICENSE | 26 + .../licences/haskell/data-fix-0.3.2/LICENSE | 30 ++ .../licences/haskell/digest-0.0.1.7/LICENSE | 23 + .../haskell/distributive-0.6.2.1/LICENSE | 26 + .../licences/haskell/dlist-1.0/license.md | 28 + .../licences/haskell/easy-file-0.2.5/LICENSE | 29 + .../haskell/email-validate-2.3.2.19/LICENSE | 0 .../licences/haskell/entropy-0.4.1.10/LICENSE | 30 ++ .../haskell/fast-logger-3.2.2/LICENSE | 29 + .../haskell/file-embed-0.0.15.0/LICENSE | 25 + .../haskell/generically-0.1.1/LICENSE | 28 + .../licences/haskell/hashable-1.4.3.0/LICENSE | 30 ++ .../licences/haskell/hourglass-0.2.12/LICENSE | 27 + .../haskell/http-types-0.12.4/LICENSE | 31 ++ .../licences/haskell/http2-5.0.0/LICENSE | 0 .../haskell/indexed-traversable-0.1.3/LICENSE | 26 + .../LICENSE | 26 + .../licences/haskell/ini-0.4.2/LICENSE | 0 .../integer-conversion-0.1.0.1/LICENSE | 30 ++ .../integer-logarithms-1.0.3.1/LICENSE | 16 + .../licences/haskell/iproute-1.7.12/LICENSE | 29 + .../licences/haskell/libyaml-0.1.2/LICENSE | 0 .../haskell/lifted-base-0.2.3.12/LICENSE | 29 + .../licences/haskell/memory-0.18.0/LICENSE | 29 + .../haskell/monad-control-1.0.3.1/LICENSE | 29 + .../haskell/monad-logger-0.3.40/LICENSE | 0 .../haskell/mono-traversable-1.0.15.3/LICENSE | 0 .../licences/haskell/network-3.1.4.0/LICENSE | 29 + .../haskell/network-byte-order-0.1.7/LICENSE | 30 ++ .../haskell/network-control-0.0.2/LICENSE | 0 .../haskell/network-info-0.2.1/LICENSE | 30 ++ .../haskell/network-transport-0.5.6/LICENSE | 31 ++ .../haskell/network-udp-0.0.0/LICENSE | 29 + .../haskell/network-uri-2.6.4.2/LICENSE | 29 + .../haskell/old-locale-1.0.0.7/LICENSE | 63 +++ .../licences/haskell/old-time-1.1.0.4/LICENSE | 63 +++ .../optparse-applicative-0.18.1.0/LICENSE | 30 ++ .../licences/haskell/pem-0.2.4/LICENSE | 27 + .../haskell/prettyprinter-1.7.1/LICENSE.md | 23 + .../LICENSE.md | 23 + .../haskell/primitive-0.9.0.0/LICENSE | 30 ++ .../licences/haskell/psqueues-0.2.8.0/LICENSE | 31 ++ .../licences/haskell/random-1.2.1.1/LICENSE | 63 +++ .../haskell/record-hasfield-1.0/LICENSE | 30 ++ .../licences/haskell/resourcet-1.3.0/LICENSE | 30 ++ .../haskell/safe-exceptions-0.1.7.4/LICENSE | 20 + .../haskell/scientific-0.3.7.0/LICENSE | 30 ++ .../licences/haskell/semialign-1.3/LICENSE | 30 ++ .../haskell/semigroupoids-6.0.0.1/LICENSE | 26 + .../haskell/simple-logger-0.1.1/LICENSE | 0 .../licences/haskell/socks-0.6.1/LICENSE | 27 + .../licences/haskell/split-0.2.4/LICENSE | 27 + .../licences/haskell/splitmix-0.1.0.5/LICENSE | 30 ++ .../haskell/stm-chans-3.0.0.9/LICENSE | 35 ++ .../haskell/streaming-commons-0.2.2.6/LICENSE | 21 + .../licences/haskell/strict-0.5/LICENSE | 26 + .../licences/haskell/tagged-0.8.8/LICENSE | 30 ++ .../licences/haskell/temporary-1.3/LICENSE | 27 + .../licences/haskell/terminal-0.2.0.0/LICENSE | 30 ++ .../licences/haskell/text-iso8601-0.1/LICENSE | 30 ++ .../licences/haskell/text-short-0.1.5/LICENSE | 30 ++ .../haskell/th-abstraction-0.6.0.0/LICENSE | 13 + .../licences/haskell/th-compat-0.1.4/LICENSE | 30 ++ .../licences/haskell/these-1.2/LICENSE | 30 ++ .../haskell/time-compat-1.9.6.1/LICENSE | 30 ++ .../haskell/time-manager-0.0.1/LICENSE | 20 + .../licences/haskell/tls-1.9.0/LICENSE | 27 + .../haskell/transformers-base-0.4.6/LICENSE | 27 + .../haskell/transformers-compat-0.7.2/LICENSE | 30 ++ .../licences/haskell/type-equality-1/LICENSE | 30 ++ .../haskell/typed-process-0.2.11.1/LICENSE | 20 + .../haskell/unix-compat-0.7.1/LICENSE | 31 ++ .../licences/haskell/unix-time-0.4.11/LICENSE | 29 + .../haskell/unliftio-0.2.25.0/LICENSE | 20 + .../haskell/unliftio-core-0.2.1.0/LICENSE | 20 + .../unordered-containers-0.2.19.1/LICENSE | 30 ++ .../licences/haskell/uuid-1.3.15/LICENSE | 28 + .../haskell/uuid-types-1.0.5.1/LICENSE | 28 + .../licences/haskell/vector-0.13.1.0/LICENSE | 32 ++ .../haskell/vector-algorithms-0.9.0.1/LICENSE | 0 .../haskell/vector-stream-0.1.0.0/LICENSE | 32 ++ .../haskell/websockets-0.12.7.3/LICENCE | 0 .../licences/haskell/witherable-0.4.2/LICENSE | 30 ++ .../licences/haskell/yaml-0.11.11.2/LICENSE | 0 .../licences/haskell/zip-2.0.0/LICENSE.md | 0 .../licences/haskell/zlib-0.6.3.0/LICENSE | 24 + .../licences/haskell/zstd-0.1.3.0/LICENSE | 30 ++ 139 files changed, 4053 insertions(+) create mode 100644 docs/dependencies/HASKELL.md create mode 100644 docs/dependencies/README.md create mode 100644 docs/dependencies/licences/apps/sqlcipher/LICENSE.md create mode 100644 docs/dependencies/licences/apps/vlc/COPYING.LIB create mode 100644 docs/dependencies/licences/apps/webrtc/LICENSE create mode 100644 docs/dependencies/licences/haskell/OneTuple-0.4.1.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/Only-0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/QuickCheck-2.14.3/LICENSE create mode 100644 docs/dependencies/licences/haskell/SHA-1.6.4.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/StateVar-1.2.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/aeson-2.2.1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/ansi-terminal-1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/ansi-terminal-types-0.11.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/appar-0.1.8/LICENSE create mode 100644 docs/dependencies/licences/haskell/asn1-encoding-0.9.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/asn1-parse-0.9.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/asn1-types-0.3.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/assoc-1.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/async-2.2.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/attoparsec-0.14.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/auto-update-0.1.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/base-orphans-0.9.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/base64-bytestring-1.2.1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/basement-0.0.16/LICENSE create mode 100644 docs/dependencies/licences/haskell/bifunctors-5.6.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/bitvec-1.1.5.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/blaze-builder-0.4.2.3/LICENSE create mode 100644 docs/dependencies/licences/haskell/blaze-textual-0.2.3.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/boring-0.2.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/byteorder-1.0.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/case-insensitive-1.2.1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/cereal-0.5.8.3/LICENSE create mode 100644 docs/dependencies/licences/haskell/clock-0.8.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/colour-2.3.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/comonad-5.0.8/LICENSE create mode 100644 docs/dependencies/licences/haskell/composition-1.0.2.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/conduit-1.3.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/conduit-extra-1.3.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/constraints-0.14/LICENSE create mode 100644 docs/dependencies/licences/haskell/contravariant-1.5.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/cryptohash-md5-0.11.101.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/crypton-0.34/LICENSE create mode 100644 docs/dependencies/licences/haskell/crypton-x509-1.7.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/crypton-x509-store-1.6.9/LICENSE create mode 100644 docs/dependencies/licences/haskell/crypton-x509-validation-1.6.12/LICENSE create mode 100644 docs/dependencies/licences/haskell/cryptostore-0.3.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/data-default-0.7.1.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/data-default-class-0.1.2.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/data-default-instances-containers-0.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/data-default-instances-dlist-0.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/data-fix-0.3.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/digest-0.0.1.7/LICENSE create mode 100644 docs/dependencies/licences/haskell/distributive-0.6.2.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/dlist-1.0/license.md create mode 100644 docs/dependencies/licences/haskell/easy-file-0.2.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/email-validate-2.3.2.19/LICENSE create mode 100644 docs/dependencies/licences/haskell/entropy-0.4.1.10/LICENSE create mode 100644 docs/dependencies/licences/haskell/fast-logger-3.2.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/file-embed-0.0.15.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/generically-0.1.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/hashable-1.4.3.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/hourglass-0.2.12/LICENSE create mode 100644 docs/dependencies/licences/haskell/http-types-0.12.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/http2-5.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/indexed-traversable-0.1.3/LICENSE create mode 100644 docs/dependencies/licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/ini-0.4.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/integer-conversion-0.1.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/integer-logarithms-1.0.3.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/iproute-1.7.12/LICENSE create mode 100644 docs/dependencies/licences/haskell/libyaml-0.1.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/lifted-base-0.2.3.12/LICENSE create mode 100644 docs/dependencies/licences/haskell/memory-0.18.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/monad-control-1.0.3.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/monad-logger-0.3.40/LICENSE create mode 100644 docs/dependencies/licences/haskell/mono-traversable-1.0.15.3/LICENSE create mode 100644 docs/dependencies/licences/haskell/network-3.1.4.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/network-byte-order-0.1.7/LICENSE create mode 100644 docs/dependencies/licences/haskell/network-control-0.0.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/network-info-0.2.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/network-transport-0.5.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/network-udp-0.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/network-uri-2.6.4.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/old-locale-1.0.0.7/LICENSE create mode 100644 docs/dependencies/licences/haskell/old-time-1.1.0.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/optparse-applicative-0.18.1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/pem-0.2.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/prettyprinter-1.7.1/LICENSE.md create mode 100644 docs/dependencies/licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md create mode 100644 docs/dependencies/licences/haskell/primitive-0.9.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/psqueues-0.2.8.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/random-1.2.1.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/record-hasfield-1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/resourcet-1.3.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/safe-exceptions-0.1.7.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/scientific-0.3.7.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/semialign-1.3/LICENSE create mode 100644 docs/dependencies/licences/haskell/semigroupoids-6.0.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/simple-logger-0.1.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/socks-0.6.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/split-0.2.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/splitmix-0.1.0.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/stm-chans-3.0.0.9/LICENSE create mode 100644 docs/dependencies/licences/haskell/streaming-commons-0.2.2.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/strict-0.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/tagged-0.8.8/LICENSE create mode 100644 docs/dependencies/licences/haskell/temporary-1.3/LICENSE create mode 100644 docs/dependencies/licences/haskell/terminal-0.2.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/text-iso8601-0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/text-short-0.1.5/LICENSE create mode 100644 docs/dependencies/licences/haskell/th-abstraction-0.6.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/th-compat-0.1.4/LICENSE create mode 100644 docs/dependencies/licences/haskell/these-1.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/time-compat-1.9.6.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/time-manager-0.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/tls-1.9.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/transformers-base-0.4.6/LICENSE create mode 100644 docs/dependencies/licences/haskell/transformers-compat-0.7.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/type-equality-1/LICENSE create mode 100644 docs/dependencies/licences/haskell/typed-process-0.2.11.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/unix-compat-0.7.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/unix-time-0.4.11/LICENSE create mode 100644 docs/dependencies/licences/haskell/unliftio-0.2.25.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/unliftio-core-0.2.1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/unordered-containers-0.2.19.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/uuid-1.3.15/LICENSE create mode 100644 docs/dependencies/licences/haskell/uuid-types-1.0.5.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/vector-0.13.1.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/vector-algorithms-0.9.0.1/LICENSE create mode 100644 docs/dependencies/licences/haskell/vector-stream-0.1.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/websockets-0.12.7.3/LICENCE create mode 100644 docs/dependencies/licences/haskell/witherable-0.4.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/yaml-0.11.11.2/LICENSE create mode 100644 docs/dependencies/licences/haskell/zip-2.0.0/LICENSE.md create mode 100644 docs/dependencies/licences/haskell/zlib-0.6.3.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/zstd-0.1.3.0/LICENSE diff --git a/docs/dependencies/HASKELL.md b/docs/dependencies/HASKELL.md new file mode 100644 index 0000000000..e6b180e33b --- /dev/null +++ b/docs/dependencies/HASKELL.md @@ -0,0 +1,177 @@ +# Dependency License Report + +Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3`. + +## Direct dependencies of `simplex-chat:exe:simplex-chat` + +| Name | Version | [SPDX](https://spdx.org/licenses/) License Id | Description | Also depended upon by | +| --- | --- | --- | --- | --- | +| **`base`** | [`4.18.1.0`](http://hackage.haskell.org/package/base-4.18.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/base-4.18.1.0/src/LICENSE) | Basic libraries | *(core library)* | +| `base64-bytestring` | [`1.2.1.0`](http://hackage.haskell.org/package/base64-bytestring-1.2.1.0) | [`BSD-3-Clause`](./licences/haskell/base64-bytestring-1.2.1.0/LICENSE) | Fast base64 encoding and decoding for ByteStrings | `simplexmq`, `websockets` | +| **`bytestring`** | [`0.11.5.2`](http://hackage.haskell.org/package/bytestring-0.11.5.2) | [`BSD-3-Clause`](http://hackage.haskell.org/package/bytestring-0.11.5.2/src/LICENSE) | Fast, compact, strict and lazy byte strings with a list interface | `SHA`, `aeson`, `appar`, `asn1-encoding`, `asn1-parse`, `asn1-types`, `attoparsec`, `base64-bytestring`, `binary`, `bitvec`, `blaze-builder`, `blaze-textual`, `bytestring-builder`, `case-insensitive`, `cereal`, `conduit`, `conduit-extra`, `cryptohash-md5`, `cryptohash-sha1`, `crypton`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `digest`, `direct-sqlcipher`, `email-validate`, `entropy`, `fast-logger`, `file-embed`, `filepath`, `hashable`, `http-types`, `http2`, `integer-conversion`, `iproute`, `libyaml`, `memory`, `monad-logger`, `mono-traversable`, `network`, `network-byte-order`, `network-transport`, `network-udp`, `parsec`, `pem`, `random`, `scientific`, `simplexmq`, `socks`, `sqlcipher-simple`, `streaming-commons`, `strict`, `terminal`, `text`, `text-short`, `tls`, `typed-process`, `unix`, `unix-time`, `unliftio`, `uuid`, `uuid-types`, `vector-algorithms`, `websockets`, `yaml`, `zip`, `zlib`, `zstd` | +| `composition` | [`1.0.2.2`](http://hackage.haskell.org/package/composition-1.0.2.2) | [`BSD-3-Clause`](./licences/haskell/composition-1.0.2.2/LICENSE) | Combinators for unorthodox function composition | `simplexmq` | +| `constraints` | [`0.14`](http://hackage.haskell.org/package/constraints-0.14) | [`BSD-2-Clause`](./licences/haskell/constraints-0.14/LICENSE) | Constraint manipulation | `simplexmq` | +| **`containers`** | [`0.6.7`](http://hackage.haskell.org/package/containers-0.6.7) | [`BSD-3-Clause`](http://hackage.haskell.org/package/containers-0.6.7/src/LICENSE) | Assorted concrete container types | `QuickCheck`, `aeson`, `attoparsec`, `bifunctors`, `binary`, `cereal`, `comonad`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `data-default-instances-containers`, `hashable`, `http2`, `indexed-traversable`, `iproute`, `mono-traversable`, `network-udp`, `resourcet`, `scientific`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `th-abstraction`, `websockets`, `witherable`, `yaml`, `zip` | +| `crypton` | [`0.34`](http://hackage.haskell.org/package/crypton-0.34) | [`BSD-3-Clause`](./licences/haskell/crypton-0.34/LICENSE) | Cryptography Primitives sink | `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `uuid` | [`1.3.15`](http://hackage.haskell.org/package/uuid-1.3.15) | [`BSD-3-Clause`](./licences/haskell/uuid-1.3.15/LICENSE) | For creating, comparing, parsing and printing Universally Unique Identifiers | | +| **`directory`** | [`1.3.8.1`](http://hackage.haskell.org/package/directory-1.3.8.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/directory-1.3.8.1/src/LICENSE) | Platform-agnostic library for filesystem operations | `conduit`, `conduit-extra`, `crypton-x509-store`, `easy-file`, `fast-logger`, `file-embed`, `network`, `process`, `simplexmq`, `streaming-commons`, `temporary`, `unliftio`, `yaml`, `zip` | +| `direct-sqlcipher` | [`2.3.28`](http://hackage.haskell.org/package/direct-sqlcipher-2.3.28) | *MISSING* | *MISSING* | `simplexmq`, `sqlcipher-simple` | +| `data-default` | [`0.7.1.1`](http://hackage.haskell.org/package/data-default-0.7.1.1) | [`BSD-3-Clause`](./licences/haskell/data-default-0.7.1.1/LICENSE) | A class for types with a default value | `simplexmq` | +| **`exceptions`** | [`0.10.7`](http://hackage.haskell.org/package/exceptions-0.10.7) | [`BSD-3-Clause`](http://hackage.haskell.org/package/exceptions-0.10.7/src/LICENSE) | Extensible optionally-pure exceptions | `aeson`, `conduit`, `filepath`, `monad-logger`, `resourcet`, `safe-exceptions`, `sqlcipher-simple`, `temporary`, `terminal`, `zip` | +| **`filepath`** | [`1.4.100.4`](http://hackage.haskell.org/package/filepath-1.4.100.4) | [`BSD-3-Clause`](http://hackage.haskell.org/package/filepath-1.4.100.4/src/LICENSE) | Library for manipulating FilePaths in a cross platform way. | `conduit`, `conduit-extra`, `crypton-x509-store`, `directory`, `easy-file`, `fast-logger`, `file-embed`, `hashable`, `process`, `simplexmq`, `temporary`, `unix`, `unliftio`, `yaml`, `zip` | +| `file-embed` | [`0.0.15.0`](http://hackage.haskell.org/package/file-embed-0.0.15.0) | [`BSD-2-Clause`](./licences/haskell/file-embed-0.0.15.0/LICENSE) | Use Template Haskell to embed file contents directly. | | +| `http-types` | [`0.12.4`](http://hackage.haskell.org/package/http-types-0.12.4) | [`BSD-3-Clause`](./licences/haskell/http-types-0.12.4/LICENSE) | Generic HTTP types for Haskell (for both client and server code). | `http2`, `simplexmq` | +| `http2` | [`5.0.0`](http://hackage.haskell.org/package/http2-5.0.0) | [`BSD-3-Clause`](./licences/haskell/http2-5.0.0/LICENSE) | HTTP/2 library | `simplexmq` | +| `email-validate` | [`2.3.2.19`](http://hackage.haskell.org/package/email-validate-2.3.2.19) | [`BSD-3-Clause`](./licences/haskell/email-validate-2.3.2.19/LICENSE) | Email address validation | | +| `memory` | [`0.18.0`](http://hackage.haskell.org/package/memory-0.18.0) | [`BSD-3-Clause`](./licences/haskell/memory-0.18.0/LICENSE) | memory and related abstraction stuff | `asn1-types`, `crypton`, `crypton-x509`, `crypton-x509-validation`, `cryptostore`, `pem`, `simplexmq`, `tls` | +| **`mtl`** | [`2.3.1`](http://hackage.haskell.org/package/mtl-2.3.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/mtl-2.3.1/src/LICENSE) | Monad classes for transformers, using functional dependencies | `conduit`, `constraints`, `crypton-x509-store`, `crypton-x509-validation`, `exceptions`, `monad-logger`, `parsec`, `random`, `resourcet`, `simple-logger`, `simplexmq`, `tls`, `yaml`, `zip` | +| `unliftio` | [`0.2.25.0`](http://hackage.haskell.org/package/unliftio-0.2.25.0) | [`MIT`](./licences/haskell/unliftio-0.2.25.0/LICENSE) | The MonadUnliftIO typeclass for unlifting monads to IO (batteries included) | `http2`, `simplexmq`, `time-manager` | +| `unliftio-core` | [`0.2.1.0`](http://hackage.haskell.org/package/unliftio-core-0.2.1.0) | [`MIT`](./licences/haskell/unliftio-core-0.2.1.0/LICENSE) | The MonadUnliftIO typeclass for unlifting monads to IO | `conduit`, `conduit-extra`, `monad-logger`, `resourcet`, `simplexmq`, `typed-process`, `unliftio` | +| `ansi-terminal` | [`1.0`](http://hackage.haskell.org/package/ansi-terminal-1.0) | [`BSD-3-Clause`](./licences/haskell/ansi-terminal-1.0/LICENSE) | Simple ANSI terminal support | `prettyprinter-ansi-terminal` | +| `network` | [`3.1.4.0`](http://hackage.haskell.org/package/network-3.1.4.0) | [`BSD-3-Clause`](./licences/haskell/network-3.1.4.0/LICENSE) | Low-level networking interface | `conduit-extra`, `http2`, `iproute`, `network-udp`, `simplexmq`, `socks`, `streaming-commons`, `tls`, `websockets` | +| `network-transport` | [`0.5.6`](http://hackage.haskell.org/package/network-transport-0.5.6) | [`BSD-3-Clause`](./licences/haskell/network-transport-0.5.6/LICENSE) | Network abstraction layer | `simplexmq` | +| **`process`** | [`1.6.17.0`](http://hackage.haskell.org/package/process-1.6.17.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/process-1.6.17.0/src/LICENSE) | Process libraries | `conduit-extra`, `optparse-applicative`, `simplexmq`, `streaming-commons`, `typed-process`, `unliftio` | +| `optparse-applicative` | [`0.18.1.0`](http://hackage.haskell.org/package/optparse-applicative-0.18.1.0) | [`BSD-3-Clause`](./licences/haskell/optparse-applicative-0.18.1.0/LICENSE) | Utilities and combinators for parsing command line options | `simplexmq` | +| `record-hasfield` | [`1.0`](http://hackage.haskell.org/package/record-hasfield-1.0) | [`BSD-3-Clause`](./licences/haskell/record-hasfield-1.0/LICENSE) | A version of GHC.Records as available in future GHCs. | | +| `random` | [`1.2.1.1`](http://hackage.haskell.org/package/random-1.2.1.1) | [`BSD-3-Clause`](./licences/haskell/random-1.2.1.1/LICENSE) | Pseudo-random number generation | `QuickCheck`, `simplexmq`, `streaming-commons`, `temporary`, `uuid`, `uuid-types`, `websockets` | +| `socks` | [`0.6.1`](http://hackage.haskell.org/package/socks-0.6.1) | [`BSD-3-Clause`](./licences/haskell/socks-0.6.1/LICENSE) | Socks proxy (ver 5) | `simplexmq` | +| `scientific` | [`0.3.7.0`](http://hackage.haskell.org/package/scientific-0.3.7.0) | [`BSD-3-Clause`](./licences/haskell/scientific-0.3.7.0/LICENSE) | Numbers represented using scientific notation | `aeson`, `attoparsec`, `yaml` | +| `simple-logger` | [`0.1.1`](http://hackage.haskell.org/package/simple-logger-0.1.1) | [`MIT`](./licences/haskell/simple-logger-0.1.1/LICENSE) | A very simple but efficient logging framework | `simplexmq` | +| `simplexmq` | [`6.4.0.1`](http://hackage.haskell.org/package/simplexmq-6.4.0.1) | *MISSING* | *MISSING* | | +| `aeson` | [`2.2.1.0`](http://hackage.haskell.org/package/aeson-2.2.1.0) | [`BSD-3-Clause`](./licences/haskell/aeson-2.2.1.0/LICENSE) | Fast JSON parsing and encoding | `simplexmq`, `yaml` | +| `sqlcipher-simple` | [`0.4.18.1`](http://hackage.haskell.org/package/sqlcipher-simple-0.4.18.1) | *MISSING* | *MISSING* | `simplexmq` | +| **`stm`** | [`2.5.1.0`](http://hackage.haskell.org/package/stm-2.5.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/stm-2.5.1.0/src/LICENSE) | Software Transactional Memory | `StateVar`, `async`, `conduit-extra`, `exceptions`, `fast-logger`, `http2`, `monad-control`, `monad-logger`, `simplexmq`, `stm-chans`, `streaming-commons`, `terminal`, `transformers-base`, `typed-process`, `unliftio` | +| `async` | [`2.2.5`](http://hackage.haskell.org/package/async-2.2.5) | [`BSD-3-Clause`](./licences/haskell/async-2.2.5/LICENSE) | Run IO operations asynchronously and wait for their results | `conduit-extra`, `http2`, `simplexmq`, `streaming-commons`, `terminal`, `tls`, `typed-process`, `unliftio`, `websockets` | +| **`template-haskell`** | [`2.20.0.0`](http://hackage.haskell.org/package/template-haskell-2.20.0.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/template-haskell-2.20.0.0/src/LICENSE) | Support library for Template Haskell | `OneTuple`, `QuickCheck`, `aeson`, `bifunctors`, `bytestring`, `containers`, `email-validate`, `exceptions`, `file-embed`, `filepath`, `monad-logger`, `network-uri`, `primitive`, `scientific`, `semigroupoids`, `sqlcipher-simple`, `tagged`, `text`, `text-short`, `th-abstraction`, `th-compat`, `unordered-containers`, `uuid-types`, `yaml` | +| **`text`** | [`2.0.2`](http://hackage.haskell.org/package/text-2.0.2) | [`BSD-2-Clause`](http://hackage.haskell.org/package/text-2.0.2/src/LICENSE) | An efficient packed Unicode text type. | `aeson`, `attoparsec`, `blaze-builder`, `blaze-textual`, `case-insensitive`, `conduit`, `conduit-extra`, `direct-sqlcipher`, `fast-logger`, `hashable`, `http-types`, `ini`, `integer-conversion`, `monad-logger`, `mono-traversable`, `optparse-applicative`, `parsec`, `prettyprinter`, `prettyprinter-ansi-terminal`, `scientific`, `simple-logger`, `simplexmq`, `sqlcipher-simple`, `streaming-commons`, `strict`, `terminal`, `text-iso8601`, `text-short`, `uuid`, `uuid-types`, `websockets`, `yaml`, `zip` | +| **`time`** | [`1.12.2`](http://hackage.haskell.org/package/time-1.12.2) | [`BSD-2-Clause`](http://hackage.haskell.org/package/time-1.12.2/src/LICENSE) | A time library | `aeson`, `directory`, `easy-file`, `iso8601-time`, `simplexmq`, `sqlcipher-simple`, `text-iso8601`, `time-compat`, `unix`, `unliftio`, `uuid`, `zip` | +| `tls` | [`1.9.0`](http://hackage.haskell.org/package/tls-1.9.0) | [`BSD-3-Clause`](./licences/haskell/tls-1.9.0/LICENSE) | TLS/SSL protocol native implementation (Server and Client) | `simplexmq` | +| `terminal` | [`0.2.0.0`](http://hackage.haskell.org/package/terminal-0.2.0.0) | [`BSD-3-Clause`](./licences/haskell/terminal-0.2.0.0/LICENSE) | Portable terminal interaction library | | +| `attoparsec` | [`0.14.4`](http://hackage.haskell.org/package/attoparsec-0.14.4) | [`BSD-3-Clause`](./licences/haskell/attoparsec-0.14.4/LICENSE) | Fast combinator parsing for bytestrings and text | `conduit-extra`, `email-validate`, `ini`, `simplexmq`, `sqlcipher-simple`, `websockets`, `yaml` | +| `websockets` | [`0.12.7.3`](http://hackage.haskell.org/package/websockets-0.12.7.3) | [`BSD-3-Clause`](./licences/haskell/websockets-0.12.7.3/LICENCE) | A sensible and clean way to write WebSocket-capable servers in Haskell. | `simplexmq` | +| `zip` | [`2.0.0`](http://hackage.haskell.org/package/zip-2.0.0) | [`BSD-3-Clause`](./licences/haskell/zip-2.0.0/LICENSE.md) | Operations on zip archives | | + +## Indirect transitive dependencies + +| Name | Version | [SPDX](https://spdx.org/licenses/) License Id | Description | Depended upon by | +| --- | --- | --- | --- | --- | +| `OneTuple` | [`0.4.1.1`](http://hackage.haskell.org/package/OneTuple-0.4.1.1) | [`BSD-3-Clause`](./licences/haskell/OneTuple-0.4.1.1/LICENSE) | Singleton Tuple | `aeson`, `indexed-traversable-instances` | +| `Only` | [`0.1`](http://hackage.haskell.org/package/Only-0.1) | [`BSD-3-Clause`](./licences/haskell/Only-0.1/LICENSE) | The 1-tuple type or single-value "collection" | `sqlcipher-simple` | +| `QuickCheck` | [`2.14.3`](http://hackage.haskell.org/package/QuickCheck-2.14.3) | [`BSD-3-Clause`](./licences/haskell/QuickCheck-2.14.3/LICENSE) | Automatic testing of Haskell programs | `aeson` | +| `SHA` | [`1.6.4.4`](http://hackage.haskell.org/package/SHA-1.6.4.4) | [`BSD-3-Clause`](./licences/haskell/SHA-1.6.4.4/LICENSE) | Implementations of the SHA suite of message digest functions | `websockets` | +| `StateVar` | [`1.2.2`](http://hackage.haskell.org/package/StateVar-1.2.2) | [`BSD-3-Clause`](./licences/haskell/StateVar-1.2.2/LICENSE) | State variables | `contravariant` | +| **`array`** | [`0.5.5.0`](http://hackage.haskell.org/package/array-0.5.5.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/array-0.5.5.0/src/LICENSE) | Mutable and immutable arrays | `SHA`, `attoparsec`, `binary`, `cereal`, `containers`, `deepseq`, `fast-logger`, `http-types`, `http2`, `indexed-traversable`, `integer-logarithms`, `stm`, `streaming-commons`, `text` | +| `bifunctors` | [`5.6.1`](http://hackage.haskell.org/package/bifunctors-5.6.1) | [`BSD-3-Clause`](./licences/haskell/bifunctors-5.6.1/LICENSE) | Bifunctors | `semigroupoids` | +| **`binary`** | [`0.8.9.1`](http://hackage.haskell.org/package/binary-0.8.9.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/binary-0.8.9.1/src/LICENSE) | Binary serialisation for Haskell values using lazy ByteStrings | `SHA`, `constraints`, `network-transport`, `scientific`, `strict`, `text`, `text-short`, `these`, `unix-time`, `uuid`, `uuid-types`, `websockets` | +| `blaze-builder` | [`0.4.2.3`](http://hackage.haskell.org/package/blaze-builder-0.4.2.3) | [`BSD-3-Clause`](./licences/haskell/blaze-builder-0.4.2.3/LICENSE) | Efficient buffered output. | `blaze-textual`, `sqlcipher-simple` | +| `blaze-textual` | [`0.2.3.1`](http://hackage.haskell.org/package/blaze-textual-0.2.3.1) | [`BSD-3-Clause`](./licences/haskell/blaze-textual-0.2.3.1/LICENSE) | Fast rendering of common datatypes | `sqlcipher-simple` | +| `boring` | [`0.2.1`](http://hackage.haskell.org/package/boring-0.2.1) | [`BSD-3-Clause`](./licences/haskell/boring-0.2.1/LICENSE) | Boring and Absurd types | `constraints` | +| `base-orphans` | [`0.9.1`](http://hackage.haskell.org/package/base-orphans-0.9.1) | [`MIT`](./licences/haskell/base-orphans-0.9.1/LICENSE) | Backwards-compatible orphan instances for base | `distributive`, `semigroupoids`, `time-compat`, `transformers-base`, `witherable` | +| `basement` | [`0.0.16`](http://hackage.haskell.org/package/basement-0.0.16) | [`BSD-3-Clause`](./licences/haskell/basement-0.0.16/LICENSE) | Foundation scrap box of array & string | `crypton`, `cryptostore`, `memory`, `pem`, `socks` | +| `bitvec` | [`1.1.5.0`](http://hackage.haskell.org/package/bitvec-1.1.5.0) | [`BSD-3-Clause`](./licences/haskell/bitvec-1.1.5.0/LICENSE) | Space-efficient bit vectors | `vector-algorithms` | +| `byteorder` | [`1.0.4`](http://hackage.haskell.org/package/byteorder-1.0.4) | [`BSD-3-Clause`](./licences/haskell/byteorder-1.0.4/LICENSE) | Exposes the native endianness or byte ordering of the system. | `iproute` | +| `bytestring-builder` | [`0.10.8.2.0`](http://hackage.haskell.org/package/bytestring-builder-0.10.8.2.0) | [`BSD-3-Clause`](./licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE) | The new bytestring builder, packaged outside of GHC | `websockets` | +| `clock` | [`0.8.4`](http://hackage.haskell.org/package/clock-0.8.4) | [`BSD-3-Clause`](./licences/haskell/clock-0.8.4/LICENSE) | High-resolution clock functions: monotonic, realtime, cputime. | `websockets` | +| `colour` | [`2.3.6`](http://hackage.haskell.org/package/colour-2.3.6) | [`MIT`](./licences/haskell/colour-2.3.6/LICENSE) | A model for human colour/color perception | `ansi-terminal`, `ansi-terminal-types` | +| `comonad` | [`5.0.8`](http://hackage.haskell.org/package/comonad-5.0.8) | [`BSD-3-Clause`](./licences/haskell/comonad-5.0.8/LICENSE) | Comonads | `bifunctors`, `semigroupoids` | +| `conduit` | [`1.3.5`](http://hackage.haskell.org/package/conduit-1.3.5) | [`MIT`](./licences/haskell/conduit-1.3.5/LICENSE) | Streaming data processing library. | `conduit-extra`, `libyaml`, `monad-logger`, `yaml`, `zip` | +| `conduit-extra` | [`1.3.6`](http://hackage.haskell.org/package/conduit-extra-1.3.6) | [`MIT`](./licences/haskell/conduit-extra-1.3.6/LICENSE) | Batteries included conduit: adapters for common libraries. | `monad-logger`, `zip` | +| `contravariant` | [`1.5.5`](http://hackage.haskell.org/package/contravariant-1.5.5) | [`BSD-3-Clause`](./licences/haskell/contravariant-1.5.5/LICENSE) | Contravariant functors | `semigroupoids` | +| `cereal` | [`0.5.8.3`](http://hackage.haskell.org/package/cereal-0.5.8.3) | [`BSD-3-Clause`](./licences/haskell/cereal-0.5.8.3/LICENSE) | A binary serialization library | `socks`, `tls`, `zip` | +| `cryptohash-md5` | [`0.11.101.0`](http://hackage.haskell.org/package/cryptohash-md5-0.11.101.0) | [`BSD-3-Clause`](./licences/haskell/cryptohash-md5-0.11.101.0/LICENSE) | Fast, pure and practical MD5 implementation | `uuid` | +| `cryptohash-sha1` | [`0.11.101.0`](http://hackage.haskell.org/package/cryptohash-sha1-0.11.101.0) | [`BSD-3-Clause`](./licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE) | Fast, pure and practical SHA-1 implementation | `uuid` | +| `crypton-x509` | [`1.7.6`](http://hackage.haskell.org/package/crypton-x509-1.7.6) | [`BSD-3-Clause`](./licences/haskell/crypton-x509-1.7.6/LICENSE) | X509 reader and writer | `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `crypton-x509-store` | [`1.6.9`](http://hackage.haskell.org/package/crypton-x509-store-1.6.9) | [`BSD-3-Clause`](./licences/haskell/crypton-x509-store-1.6.9/LICENSE) | X.509 collection accessing and storing methods | `crypton-x509-validation`, `simplexmq`, `tls` | +| `crypton-x509-validation` | [`1.6.12`](http://hackage.haskell.org/package/crypton-x509-validation-1.6.12) | [`BSD-3-Clause`](./licences/haskell/crypton-x509-validation-1.6.12/LICENSE) | X.509 Certificate and CRL validation | `cryptostore`, `simplexmq`, `tls` | +| `cryptostore` | [`0.3.0.1`](http://hackage.haskell.org/package/cryptostore-0.3.0.1) | [`BSD-3-Clause`](./licences/haskell/cryptostore-0.3.0.1/LICENSE) | Serialization of cryptographic data types | `simplexmq` | +| `case-insensitive` | [`1.2.1.0`](http://hackage.haskell.org/package/case-insensitive-1.2.1.0) | [`BSD-3-Clause`](./licences/haskell/case-insensitive-1.2.1.0/LICENSE) | Case insensitive string comparison | `http-types`, `http2`, `simplexmq`, `websockets`, `zip` | +| `uuid-types` | [`1.0.5.1`](http://hackage.haskell.org/package/uuid-types-1.0.5.1) | [`BSD-3-Clause`](./licences/haskell/uuid-types-1.0.5.1/LICENSE) | Type definitions for Universally Unique Identifiers | `aeson`, `uuid` | +| **`deepseq`** | [`1.4.8.1`](http://hackage.haskell.org/package/deepseq-1.4.8.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/deepseq-1.4.8.1/src/LICENSE) | Deep evaluation of data structures | `Only`, `QuickCheck`, `aeson`, `attoparsec`, `bitvec`, `blaze-builder`, `bytestring`, `bytestring-builder`, `case-insensitive`, `constraints`, `containers`, `crypton`, `data-fix`, `dlist`, `filepath`, `hashable`, `hourglass`, `memory`, `network`, `network-transport`, `network-uri`, `pretty`, `primitive`, `process`, `psqueues`, `random`, `safe-exceptions`, `scientific`, `splitmix`, `strict`, `tagged`, `text`, `text-short`, `these`, `time`, `time-compat`, `unliftio`, `unordered-containers`, `uuid-types`, `vector`, `zstd` | +| `digest` | [`0.0.1.7`](http://hackage.haskell.org/package/digest-0.0.1.7) | [`BSD-2-Clause`](./licences/haskell/digest-0.0.1.7/LICENSE) | Various hashes for bytestrings; CRC32 and Adler32 for now. | `zip` | +| `dlist` | [`1.0`](http://hackage.haskell.org/package/dlist-1.0) | [`BSD-3-Clause`](./licences/haskell/dlist-1.0/license.md) | Difference lists | `aeson`, `data-default-instances-dlist`, `zip` | +| `distributive` | [`0.6.2.1`](http://hackage.haskell.org/package/distributive-0.6.2.1) | [`BSD-3-Clause`](./licences/haskell/distributive-0.6.2.1/LICENSE) | Distributive functors -- Dual to Traversable | `comonad`, `semigroupoids` | +| `data-default-class` | [`0.1.2.0`](http://hackage.haskell.org/package/data-default-class-0.1.2.0) | [`BSD-3-Clause`](./licences/haskell/data-default-class-0.1.2.0/LICENSE) | A class for types with a default value | `crypton-x509-validation`, `data-default`, `data-default-instances-containers`, `data-default-instances-dlist`, `data-default-instances-old-locale`, `tls` | +| `data-default-instances-containers` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-containers-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-containers-0.0.1/LICENSE) | Default instances for types in containers | `data-default` | +| `data-default-instances-dlist` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-dlist-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-dlist-0.0.1/LICENSE) | Default instances for types in dlist | `data-default` | +| `data-default-instances-old-locale` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-old-locale-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE) | Default instances for types in old-locale | `data-default` | +| `data-fix` | [`0.3.2`](http://hackage.haskell.org/package/data-fix-0.3.2) | [`BSD-3-Clause`](./licences/haskell/data-fix-0.3.2/LICENSE) | Fixpoint data types | `aeson` | +| `fast-logger` | [`3.2.2`](http://hackage.haskell.org/package/fast-logger-3.2.2) | [`BSD-3-Clause`](./licences/haskell/fast-logger-3.2.2/LICENSE) | A fast logging system | `monad-logger`, `simple-logger` | +| **`ghc-bignum`** | [`1.3`](http://hackage.haskell.org/package/ghc-bignum-1.3) | [`BSD-3-Clause`](http://hackage.haskell.org/package/ghc-bignum-1.3/src/LICENSE) | GHC BigNum library | `base`, `bitvec`, `hashable`, `integer-gmp`, `integer-logarithms` | +| **`ghc-boot-th`** | [`9.6.3`](http://hackage.haskell.org/package/ghc-boot-th-9.6.3) | [`BSD-3-Clause`](http://hackage.haskell.org/package/ghc-boot-th-9.6.3/src/LICENSE) | Shared functionality between GHC and the @template-haskell@ library | `template-haskell` | +| **`ghc-prim`** | [`0.10.0`](http://hackage.haskell.org/package/ghc-prim-0.10.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/ghc-prim-0.10.0/src/LICENSE) | GHC primitives | *(core library)* | +| `generically` | [`0.1.1`](http://hackage.haskell.org/package/generically-0.1.1) | [`BSD-3-Clause`](./licences/haskell/generically-0.1.1/LICENSE) | Generically newtype to use with DerivingVia | `aeson` | +| `hourglass` | [`0.2.12`](http://hackage.haskell.org/package/hourglass-0.2.12) | [`BSD-3-Clause`](./licences/haskell/hourglass-0.2.12/LICENSE) | simple performant time related library | `asn1-encoding`, `asn1-types`, `crypton-x509`, `crypton-x509-validation`, `cryptostore`, `simplexmq` | +| `hashable` | [`1.4.3.0`](http://hackage.haskell.org/package/hashable-1.4.3.0) | [`BSD-3-Clause`](./licences/haskell/hashable-1.4.3.0/LICENSE) | A class for types that can be converted to a hash value | `aeson`, `async`, `case-insensitive`, `constraints`, `data-fix`, `mono-traversable`, `network-transport`, `psqueues`, `scientific`, `semialign`, `semigroupoids`, `simplexmq`, `strict`, `text-short`, `these`, `time-compat`, `unordered-containers`, `uuid-types`, `witherable` | +| **`integer-gmp`** | [`1.1`](http://hackage.haskell.org/package/integer-gmp-1.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/integer-gmp-1.1/src/LICENSE) | Integer library based on GMP | *(core library)* | +| `libyaml` | [`0.1.2`](http://hackage.haskell.org/package/libyaml-0.1.2) | [`BSD-3-Clause`](./licences/haskell/libyaml-0.1.2/LICENSE) | Low-level, streaming YAML interface. | `yaml` | +| `old-locale` | [`1.0.0.7`](http://hackage.haskell.org/package/old-locale-1.0.0.7) | [`BSD-3-Clause`](./licences/haskell/old-locale-1.0.0.7/LICENSE) | locale library | `blaze-textual`, `data-default-instances-old-locale`, `old-time` | +| `old-time` | [`1.1.0.4`](http://hackage.haskell.org/package/old-time-1.1.0.4) | [`BSD-3-Clause`](./licences/haskell/old-time-1.1.0.4/LICENSE) | Time library | `unix-time` | +| `lifted-base` | [`0.2.3.12`](http://hackage.haskell.org/package/lifted-base-0.2.3.12) | [`BSD-3-Clause`](./licences/haskell/lifted-base-0.2.3.12/LICENSE) | lifted IO operations from the base library | `monad-logger` | +| `mono-traversable` | [`1.0.15.3`](http://hackage.haskell.org/package/mono-traversable-1.0.15.3) | [`MIT`](./licences/haskell/mono-traversable-1.0.15.3/LICENSE) | Type classes for mapping, folding, and traversing monomorphic containers | `conduit` | +| `monad-control` | [`1.0.3.1`](http://hackage.haskell.org/package/monad-control-1.0.3.1) | [`BSD-3-Clause`](./licences/haskell/monad-control-1.0.3.1/LICENSE) | Lift control operations, like exception catching, through monad transformers | `lifted-base`, `monad-logger`, `zip` | +| `monad-logger` | [`0.3.40`](http://hackage.haskell.org/package/monad-logger-0.3.40) | [`MIT`](./licences/haskell/monad-logger-0.3.40/LICENSE) | A class of monads which can log messages. | `simple-logger` | +| `monad-loops` | [`0.4.3`](http://hackage.haskell.org/package/monad-loops-0.4.3) | [`LicenseRef-PublicDomain`](http://hackage.haskell.org/package/monad-loops-0.4.3) | Monadic loops | `monad-logger` | +| `ini` | [`0.4.2`](http://hackage.haskell.org/package/ini-0.4.2) | [`BSD-3-Clause`](./licences/haskell/ini-0.4.2/LICENSE) | Configuration files in the INI format. | `simplexmq` | +| `indexed-traversable` | [`0.1.3`](http://hackage.haskell.org/package/indexed-traversable-0.1.3) | [`BSD-2-Clause`](./licences/haskell/indexed-traversable-0.1.3/LICENSE) | FunctorWithIndex, FoldableWithIndex, TraversableWithIndex | `aeson`, `comonad`, `indexed-traversable-instances`, `semialign`, `witherable` | +| `indexed-traversable-instances` | [`0.1.1.2`](http://hackage.haskell.org/package/indexed-traversable-instances-0.1.1.2) | [`BSD-2-Clause`](./licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE) | More instances of FunctorWithIndex, FoldableWithIndex, TraversableWithIndex | `semialign`, `witherable` | +| `unordered-containers` | [`0.2.19.1`](http://hackage.haskell.org/package/unordered-containers-0.2.19.1) | [`BSD-3-Clause`](./licences/haskell/unordered-containers-0.2.19.1/LICENSE) | Efficient hashing-based container types | `aeson`, `indexed-traversable-instances`, `ini`, `mono-traversable`, `semialign`, `semigroupoids`, `witherable`, `yaml` | +| `ansi-terminal-types` | [`0.11.5`](http://hackage.haskell.org/package/ansi-terminal-types-0.11.5) | [`BSD-3-Clause`](./licences/haskell/ansi-terminal-types-0.11.5/LICENSE) | Types and functions used to represent SGR aspects | `ansi-terminal` | +| `integer-conversion` | [`0.1.0.1`](http://hackage.haskell.org/package/integer-conversion-0.1.0.1) | [`BSD-3-Clause`](./licences/haskell/integer-conversion-0.1.0.1/LICENSE) | Conversion from strings to Integer | `aeson`, `text-iso8601` | +| `integer-logarithms` | [`1.0.3.1`](http://hackage.haskell.org/package/integer-logarithms-1.0.3.1) | [`MIT`](./licences/haskell/integer-logarithms-1.0.3.1/LICENSE) | Integer logarithms. | `aeson`, `scientific` | +| `entropy` | [`0.4.1.10`](http://hackage.haskell.org/package/entropy-0.4.1.10) | [`BSD-3-Clause`](./licences/haskell/entropy-0.4.1.10/LICENSE) | A platform independent entropy source | `uuid`, `websockets` | +| `network-byte-order` | [`0.1.7`](http://hackage.haskell.org/package/network-byte-order-0.1.7) | [`BSD-3-Clause`](./licences/haskell/network-byte-order-0.1.7/LICENSE) | Network byte order utilities | `http2` | +| `network-control` | [`0.0.2`](http://hackage.haskell.org/package/network-control-0.0.2) | [`BSD-3-Clause`](./licences/haskell/network-control-0.0.2/LICENSE) | Library to control network protocols | `http2` | +| `network-udp` | [`0.0.0`](http://hackage.haskell.org/package/network-udp-0.0.0) | [`BSD-3-Clause`](./licences/haskell/network-udp-0.0.0/LICENSE) | UDP library | `simplexmq` | +| `network-info` | [`0.2.1`](http://hackage.haskell.org/package/network-info-0.2.1) | [`BSD-3-Clause`](./licences/haskell/network-info-0.2.1/LICENSE) | Access the local computer's basic network configuration | `simplexmq`, `uuid` | +| `network-uri` | [`2.6.4.2`](http://hackage.haskell.org/package/network-uri-2.6.4.2) | [`BSD-3-Clause`](./licences/haskell/network-uri-2.6.4.2/LICENSE) | URI manipulation | `aeson` | +| `unix-compat` | [`0.7.1`](http://hackage.haskell.org/package/unix-compat-0.7.1) | [`BSD-3-Clause`](./licences/haskell/unix-compat-0.7.1/LICENSE) | Portable POSIX-compatibility layer. | `fast-logger` | +| `unix-time` | [`0.4.11`](http://hackage.haskell.org/package/unix-time-0.4.11) | [`BSD-3-Clause`](./licences/haskell/unix-time-0.4.11/LICENSE) | Unix time parser/formatter and utilities | `fast-logger`, `http2`, `network-control`, `tls` | +| **`parsec`** | [`3.1.16.1`](http://hackage.haskell.org/package/parsec-3.1.16.1) | [`BSD-2-Clause`](http://hackage.haskell.org/package/parsec-3.1.16.1/src/LICENSE) | Monadic parser combinators | `network-uri` | +| `pem` | [`0.2.4`](http://hackage.haskell.org/package/pem-0.2.4) | [`BSD-3-Clause`](./licences/haskell/pem-0.2.4/LICENSE) | Privacy Enhanced Mail (PEM) format reader and writer. | `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore` | +| `appar` | [`0.1.8`](http://hackage.haskell.org/package/appar-0.1.8) | [`BSD-3-Clause`](./licences/haskell/appar-0.1.8/LICENSE) | A simple applicative parser | `iproute` | +| **`pretty`** | [`1.1.3.6`](http://hackage.haskell.org/package/pretty-1.1.3.6) | [`BSD-3-Clause`](http://hackage.haskell.org/package/pretty-1.1.3.6/src/LICENSE) | Pretty-printing library | `template-haskell` | +| `primitive` | [`0.9.0.0`](http://hackage.haskell.org/package/primitive-0.9.0.0) | [`BSD-3-Clause`](./licences/haskell/primitive-0.9.0.0/LICENSE) | Primitive memory-related operations | `aeson`, `bitvec`, `conduit`, `conduit-extra`, `integer-conversion`, `resourcet`, `scientific`, `vector`, `vector-algorithms` | +| `iproute` | [`1.7.12`](http://hackage.haskell.org/package/iproute-1.7.12) | [`BSD-3-Clause`](./licences/haskell/iproute-1.7.12/LICENSE) | IP Routing Table | `network-udp`, `simplexmq` | +| `prettyprinter` | [`1.7.1`](http://hackage.haskell.org/package/prettyprinter-1.7.1) | [`BSD-2-Clause`](./licences/haskell/prettyprinter-1.7.1/LICENSE.md) | A modern, easy to use, well-documented, extensible pretty-printer. | `optparse-applicative`, `prettyprinter-ansi-terminal`, `terminal` | +| `prettyprinter-ansi-terminal` | [`1.1.3`](http://hackage.haskell.org/package/prettyprinter-ansi-terminal-1.1.3) | [`BSD-2-Clause`](./licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md) | ANSI terminal backend for the »prettyprinter« package. | `optparse-applicative` | +| `psqueues` | [`0.2.8.0`](http://hackage.haskell.org/package/psqueues-0.2.8.0) | [`BSD-3-Clause`](./licences/haskell/psqueues-0.2.8.0/LICENSE) | Pure priority search queues | `network-control` | +| `resourcet` | [`1.3.0`](http://hackage.haskell.org/package/resourcet-1.3.0) | [`BSD-3-Clause`](./licences/haskell/resourcet-1.3.0/LICENSE) | Deterministic allocation and freeing of scarce resources. | `conduit`, `conduit-extra`, `libyaml`, `monad-logger`, `yaml`, `zip` | +| `iso8601-time` | [`0.1.5`](http://hackage.haskell.org/package/iso8601-time-0.1.5) | [`MIT`](http://hackage.haskell.org/package/iso8601-time-0.1.5) | Convert to/from the ISO 8601 time format | `simplexmq` | +| `safe-exceptions` | [`0.1.7.4`](http://hackage.haskell.org/package/safe-exceptions-0.1.7.4) | [`MIT`](./licences/haskell/safe-exceptions-0.1.7.4/LICENSE) | Safe, consistent, and easy exception handling | `unliftio` | +| `semigroupoids` | [`6.0.0.1`](http://hackage.haskell.org/package/semigroupoids-6.0.0.1) | [`BSD-2-Clause`](./licences/haskell/semigroupoids-6.0.0.1/LICENSE) | Semigroupoids: Category sans id | `semialign` | +| `semialign` | [`1.3`](http://hackage.haskell.org/package/semialign-1.3) | [`BSD-3-Clause`](./licences/haskell/semialign-1.3/LICENSE) | Align and Zip type-classes from the common Semialign ancestor. | `aeson` | +| `asn1-encoding` | [`0.9.6`](http://hackage.haskell.org/package/asn1-encoding-0.9.6) | [`BSD-3-Clause`](./licences/haskell/asn1-encoding-0.9.6/LICENSE) | ASN1 data reader and writer in RAW, BER and DER forms | `asn1-parse`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `asn1-parse` | [`0.9.5`](http://hackage.haskell.org/package/asn1-parse-0.9.5) | [`BSD-3-Clause`](./licences/haskell/asn1-parse-0.9.5/LICENSE) | Simple monadic parser for ASN1 stream types. | `crypton-x509` | +| `asn1-types` | [`0.3.4`](http://hackage.haskell.org/package/asn1-types-0.3.4) | [`BSD-3-Clause`](./licences/haskell/asn1-types-0.3.4/LICENSE) | ASN.1 types | `asn1-encoding`, `asn1-parse`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | +| `split` | [`0.2.4`](http://hackage.haskell.org/package/split-0.2.4) | [`BSD-3-Clause`](./licences/haskell/split-0.2.4/LICENSE) | Combinator library for splitting lists. | `mono-traversable` | +| `splitmix` | [`0.1.0.5`](http://hackage.haskell.org/package/splitmix-0.1.0.5) | [`BSD-3-Clause`](./licences/haskell/splitmix-0.1.0.5/LICENSE) | Fast Splittable PRNG | `QuickCheck`, `random` | +| `assoc` | [`1.1`](http://hackage.haskell.org/package/assoc-1.1) | [`BSD-3-Clause`](./licences/haskell/assoc-1.1/LICENSE) | swap and assoc: Symmetric and Semigroupy Bifunctors | `bifunctors`, `strict`, `these` | +| `stm-chans` | [`3.0.0.9`](http://hackage.haskell.org/package/stm-chans-3.0.0.9) | [`BSD-3-Clause`](./licences/haskell/stm-chans-3.0.0.9/LICENSE) | Additional types of channels for STM. | `monad-logger` | +| `strict` | [`0.5`](http://hackage.haskell.org/package/strict-0.5) | [`BSD-3-Clause`](./licences/haskell/strict-0.5/LICENSE) | Strict data types and String IO. | `aeson` | +| `streaming-commons` | [`0.2.2.6`](http://hackage.haskell.org/package/streaming-commons-0.2.2.6) | [`MIT`](./licences/haskell/streaming-commons-0.2.2.6/LICENSE) | Common lower-level functions needed by various streaming data libraries | `conduit-extra`, `websockets` | +| `easy-file` | [`0.2.5`](http://hackage.haskell.org/package/easy-file-0.2.5) | [`BSD-3-Clause`](./licences/haskell/easy-file-0.2.5/LICENSE) | Cross-platform File handling | `fast-logger` | +| `auto-update` | [`0.1.6`](http://hackage.haskell.org/package/auto-update-0.1.6) | [`MIT`](./licences/haskell/auto-update-0.1.6/LICENSE) | Efficiently run periodic, on-demand actions | `fast-logger`, `time-manager` | +| `tagged` | [`0.8.8`](http://hackage.haskell.org/package/tagged-0.8.8) | [`BSD-3-Clause`](./licences/haskell/tagged-0.8.8/LICENSE) | Haskell 98 phantom types to avoid unsafely passing dummy arguments | `aeson`, `assoc`, `bifunctors`, `boring`, `comonad`, `distributive`, `indexed-traversable-instances`, `semialign`, `semigroupoids` | +| `th-abstraction` | [`0.6.0.0`](http://hackage.haskell.org/package/th-abstraction-0.6.0.0) | [`ISC`](./licences/haskell/th-abstraction-0.6.0.0/LICENSE) | Nicer interface for reified information about data types | `aeson`, `bifunctors` | +| `th-compat` | [`0.1.4`](http://hackage.haskell.org/package/th-compat-0.1.4) | [`BSD-3-Clause`](./licences/haskell/th-compat-0.1.4/LICENSE) | Backward- (and forward-)compatible Quote and Code types | `network-uri` | +| `these` | [`1.2`](http://hackage.haskell.org/package/these-1.2) | [`BSD-3-Clause`](./licences/haskell/these-1.2/LICENSE) | An either-or-both data type. | `aeson`, `semialign`, `strict` | +| `time-compat` | [`1.9.6.1`](http://hackage.haskell.org/package/time-compat-1.9.6.1) | [`BSD-3-Clause`](./licences/haskell/time-compat-1.9.6.1/LICENSE) | Compatibility package for time | `aeson`, `text-iso8601` | +| `time-manager` | [`0.0.1`](http://hackage.haskell.org/package/time-manager-0.0.1) | [`MIT`](./licences/haskell/time-manager-0.0.1/LICENSE) | Scalable timer | `http2`, `simplexmq` | +| `temporary` | [`1.3`](http://hackage.haskell.org/package/temporary-1.3) | [`BSD-3-Clause`](./licences/haskell/temporary-1.3/LICENSE) | Portable temporary file and directory support | `simplexmq` | +| **`transformers`** | [`0.6.1.0`](http://hackage.haskell.org/package/transformers-0.6.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/transformers-0.6.1.0/src/LICENSE) | Concrete functor and monad transformers | `QuickCheck`, `StateVar`, `attoparsec`, `bifunctors`, `boring`, `comonad`, `conduit`, `conduit-extra`, `constraints`, `contravariant`, `crypton-x509`, `distributive`, `exceptions`, `indexed-traversable`, `monad-control`, `monad-logger`, `mono-traversable`, `mtl`, `network-transport`, `optparse-applicative`, `primitive`, `resourcet`, `safe-exceptions`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `streaming-commons`, `strict`, `tagged`, `temporary`, `terminal`, `tls`, `transformers-base`, `transformers-compat`, `typed-process`, `unliftio`, `unliftio-core`, `witherable`, `yaml`, `zip` | +| `transformers-base` | [`0.4.6`](http://hackage.haskell.org/package/transformers-base-0.4.6) | [`BSD-3-Clause`](./licences/haskell/transformers-base-0.4.6/LICENSE) | Lift computations from the bottom of a transformer stack | `lifted-base`, `monad-control`, `monad-logger`, `zip` | +| `transformers-compat` | [`0.7.2`](http://hackage.haskell.org/package/transformers-compat-0.7.2) | [`BSD-3-Clause`](./licences/haskell/transformers-compat-0.7.2/LICENSE) | A small compatibility shim for the transformers library | `comonad`, `monad-control`, `monad-logger`, `optparse-applicative`, `semigroupoids`, `transformers-base` | +| `attoparsec` | [`0.14.4`](http://hackage.haskell.org/package/attoparsec-0.14.4) | [`BSD-3-Clause`](./licences/haskell/attoparsec-0.14.4/LICENSE) | Fast combinator parsing for bytestrings and text | `attoparsec` | +| `text-iso8601` | [`0.1`](http://hackage.haskell.org/package/text-iso8601-0.1) | [`BSD-3-Clause`](./licences/haskell/text-iso8601-0.1/LICENSE) | Converting time to and from ISO 8601 text. | `aeson` | +| `text-short` | [`0.1.5`](http://hackage.haskell.org/package/text-short-0.1.5) | [`BSD-3-Clause`](./licences/haskell/text-short-0.1.5/LICENSE) | Memory-efficient representation of Unicode text strings | `aeson` | +| `type-equality` | [`1`](http://hackage.haskell.org/package/type-equality-1) | [`BSD-3-Clause`](./licences/haskell/type-equality-1/LICENSE) | Data.Type.Equality compat package | `constraints` | +| `typed-process` | [`0.2.11.1`](http://hackage.haskell.org/package/typed-process-0.2.11.1) | [`MIT`](./licences/haskell/typed-process-0.2.11.1/LICENSE) | Run external processes, with strong typing of streams | `conduit-extra` | +| **`unix`** | [`2.8.1.0`](http://hackage.haskell.org/package/unix-2.8.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/unix-2.8.1.0/src/LICENSE) | POSIX functionality | `conduit`, `directory`, `easy-file`, `entropy`, `process`, `streaming-commons`, `temporary`, `unix-compat`, `unliftio`, `zip` | +| `vector` | [`0.13.1.0`](http://hackage.haskell.org/package/vector-0.13.1.0) | [`BSD-3-Clause`](./licences/haskell/vector-0.13.1.0/LICENSE) | Efficient Arrays | `aeson`, `bitvec`, `blaze-textual`, `conduit`, `indexed-traversable-instances`, `mono-traversable`, `semialign`, `vector-algorithms`, `witherable`, `yaml` | +| `vector-algorithms` | [`0.9.0.1`](http://hackage.haskell.org/package/vector-algorithms-0.9.0.1) | [`BSD-3-Clause`](./licences/haskell/vector-algorithms-0.9.0.1/LICENSE) | Efficient algorithms for vector arrays | `mono-traversable` | +| `vector-stream` | [`0.1.0.0`](http://hackage.haskell.org/package/vector-stream-0.1.0.0) | [`BSD-3-Clause`](./licences/haskell/vector-stream-0.1.0.0/LICENSE) | Efficient Streams | `vector` | +| `witherable` | [`0.4.2`](http://hackage.haskell.org/package/witherable-0.4.2) | [`BSD-3-Clause`](./licences/haskell/witherable-0.4.2/LICENSE) | filterable traversable | `aeson` | +| `yaml` | [`0.11.11.2`](http://hackage.haskell.org/package/yaml-0.11.11.2) | [`BSD-3-Clause`](./licences/haskell/yaml-0.11.11.2/LICENSE) | Support for parsing and rendering YAML documents. | `simplexmq` | +| `zlib` | [`0.6.3.0`](http://hackage.haskell.org/package/zlib-0.6.3.0) | [`BSD-3-Clause`](./licences/haskell/zlib-0.6.3.0/LICENSE) | Compression and decompression in the gzip and zlib formats | `streaming-commons` | +| `zstd` | [`0.1.3.0`](http://hackage.haskell.org/package/zstd-0.1.3.0) | [`BSD-3-Clause`](./licences/haskell/zstd-0.1.3.0/LICENSE) | Haskell bindings to the Zstandard compression algorithm | `simplexmq` | + diff --git a/docs/dependencies/README.md b/docs/dependencies/README.md new file mode 100644 index 0000000000..02c0623988 --- /dev/null +++ b/docs/dependencies/README.md @@ -0,0 +1,9 @@ +# SimpleX Chat and SimpleX servers dependencies + +[SQLCipher](https://github.com/sqlcipher/sqlcipher): Extension of [SQLite](https://sqlite.org) with encryption ([BSD-style](./licences/build/sqlcipher/LICENSE.md)) + +[vlc](https://github.com/videolan/vlc): VLC media player library ([LGPLv2](./licences/build/vlc/COPYING.LIB)) + +[WebRTC](https://webrtc.googlesource.com/src/): RTC for calls ([BSD-3-clause](./licences/build/webrtc/LICENSE)) + +[Haskell dependencies](./HASKELL.md). diff --git a/docs/dependencies/licences/apps/sqlcipher/LICENSE.md b/docs/dependencies/licences/apps/sqlcipher/LICENSE.md new file mode 100644 index 0000000000..3f71443161 --- /dev/null +++ b/docs/dependencies/licences/apps/sqlcipher/LICENSE.md @@ -0,0 +1,24 @@ +Copyright (c) 2025, ZETETIC LLC +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the ZETETIC LLC nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY ZETETIC LLC ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/apps/vlc/COPYING.LIB b/docs/dependencies/licences/apps/vlc/COPYING.LIB new file mode 100644 index 0000000000..4362b49151 --- /dev/null +++ b/docs/dependencies/licences/apps/vlc/COPYING.LIB @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/docs/dependencies/licences/apps/webrtc/LICENSE b/docs/dependencies/licences/apps/webrtc/LICENSE new file mode 100644 index 0000000000..8868dcdf45 --- /dev/null +++ b/docs/dependencies/licences/apps/webrtc/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2011, The WebRTC project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/OneTuple-0.4.1.1/LICENSE b/docs/dependencies/licences/haskell/OneTuple-0.4.1.1/LICENSE new file mode 100644 index 0000000000..405f9161e9 --- /dev/null +++ b/docs/dependencies/licences/haskell/OneTuple-0.4.1.1/LICENSE @@ -0,0 +1,34 @@ + +Copyright (c) 2008, John A. Dorsey. +All rights reserved. + +Redistribution and use of this software in source and binary forms, +with or without modification, are permitted provided that the +following conditions are met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +* Neither the name of John Dorsey nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/Only-0.1/LICENSE b/docs/dependencies/licences/haskell/Only-0.1/LICENSE new file mode 100644 index 0000000000..98d4b1ad78 --- /dev/null +++ b/docs/dependencies/licences/haskell/Only-0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Herbert Valerio Riedel nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/QuickCheck-2.14.3/LICENSE b/docs/dependencies/licences/haskell/QuickCheck-2.14.3/LICENSE new file mode 100644 index 0000000000..8b0fdbfe4e --- /dev/null +++ b/docs/dependencies/licences/haskell/QuickCheck-2.14.3/LICENSE @@ -0,0 +1,29 @@ +(The following is the 3-clause BSD license.) + +Copyright (c) 2000-2019, Koen Claessen +Copyright (c) 2006-2008, Björn Bringert +Copyright (c) 2009-2019, Nick Smallbone + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +- Neither the names of the copyright owners nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/SHA-1.6.4.4/LICENSE b/docs/dependencies/licences/haskell/SHA-1.6.4.4/LICENSE new file mode 100644 index 0000000000..8e49100207 --- /dev/null +++ b/docs/dependencies/licences/haskell/SHA-1.6.4.4/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2008, Galois, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the Galois, Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/StateVar-1.2.2/LICENSE b/docs/dependencies/licences/haskell/StateVar-1.2.2/LICENSE new file mode 100644 index 0000000000..e71299ba0e --- /dev/null +++ b/docs/dependencies/licences/haskell/StateVar-1.2.2/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2014-2015, Edward Kmett +Copyright (c) 2009-2021, Sven Panne +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/aeson-2.2.1.0/LICENSE b/docs/dependencies/licences/haskell/aeson-2.2.1.0/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/ansi-terminal-1.0/LICENSE b/docs/dependencies/licences/haskell/ansi-terminal-1.0/LICENSE new file mode 100644 index 0000000000..814a83d4fc --- /dev/null +++ b/docs/dependencies/licences/haskell/ansi-terminal-1.0/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2008, Maximilian Bolingbroke +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted +provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + * Neither the name of Maximilian Bolingbroke nor the names of other contributors may be used to + endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/ansi-terminal-types-0.11.5/LICENSE b/docs/dependencies/licences/haskell/ansi-terminal-types-0.11.5/LICENSE new file mode 100644 index 0000000000..413047009d --- /dev/null +++ b/docs/dependencies/licences/haskell/ansi-terminal-types-0.11.5/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2008, Maximilian Bolingbroke +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Maximilian Bolingbroke nor the names of other contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/appar-0.1.8/LICENSE b/docs/dependencies/licences/haskell/appar-0.1.8/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/appar-0.1.8/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/asn1-encoding-0.9.6/LICENSE b/docs/dependencies/licences/haskell/asn1-encoding-0.9.6/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/asn1-encoding-0.9.6/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/asn1-parse-0.9.5/LICENSE b/docs/dependencies/licences/haskell/asn1-parse-0.9.5/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/asn1-parse-0.9.5/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/asn1-types-0.3.4/LICENSE b/docs/dependencies/licences/haskell/asn1-types-0.3.4/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/asn1-types-0.3.4/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/assoc-1.1/LICENSE b/docs/dependencies/licences/haskell/assoc-1.1/LICENSE new file mode 100644 index 0000000000..96445e5484 --- /dev/null +++ b/docs/dependencies/licences/haskell/assoc-1.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/async-2.2.5/LICENSE b/docs/dependencies/licences/haskell/async-2.2.5/LICENSE new file mode 100644 index 0000000000..0acbb121f4 --- /dev/null +++ b/docs/dependencies/licences/haskell/async-2.2.5/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, Simon Marlow + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Simon Marlow nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/attoparsec-0.14.4/LICENSE b/docs/dependencies/licences/haskell/attoparsec-0.14.4/LICENSE new file mode 100644 index 0000000000..97392a6264 --- /dev/null +++ b/docs/dependencies/licences/haskell/attoparsec-0.14.4/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) Lennart Kolmodin + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/auto-update-0.1.6/LICENSE b/docs/dependencies/licences/haskell/auto-update-0.1.6/LICENSE new file mode 100644 index 0000000000..d2a7d05797 --- /dev/null +++ b/docs/dependencies/licences/haskell/auto-update-0.1.6/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2014 Michael Snoyman + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/base-orphans-0.9.1/LICENSE b/docs/dependencies/licences/haskell/base-orphans-0.9.1/LICENSE new file mode 100644 index 0000000000..4751ca3f63 --- /dev/null +++ b/docs/dependencies/licences/haskell/base-orphans-0.9.1/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015-2017 Simon Hengel , João Cristóvão , Ryan Scott + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/base64-bytestring-1.2.1.0/LICENSE b/docs/dependencies/licences/haskell/base64-bytestring-1.2.1.0/LICENSE new file mode 100644 index 0000000000..89d2a1b35f --- /dev/null +++ b/docs/dependencies/licences/haskell/base64-bytestring-1.2.1.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2010 Bryan O'Sullivan + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/basement-0.0.16/LICENSE b/docs/dependencies/licences/haskell/basement-0.0.16/LICENSE new file mode 100644 index 0000000000..75e0288c26 --- /dev/null +++ b/docs/dependencies/licences/haskell/basement-0.0.16/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2015-2017 Vincent Hanquez +Copyright (c) 2017-2019 Foundation Maintainers + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/bifunctors-5.6.1/LICENSE b/docs/dependencies/licences/haskell/bifunctors-5.6.1/LICENSE new file mode 100644 index 0000000000..531684e87f --- /dev/null +++ b/docs/dependencies/licences/haskell/bifunctors-5.6.1/LICENSE @@ -0,0 +1,26 @@ +Copyright 2008-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/bitvec-1.1.5.0/LICENSE b/docs/dependencies/licences/haskell/bitvec-1.1.5.0/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/blaze-builder-0.4.2.3/LICENSE b/docs/dependencies/licences/haskell/blaze-builder-0.4.2.3/LICENSE new file mode 100644 index 0000000000..a8bcabfd42 --- /dev/null +++ b/docs/dependencies/licences/haskell/blaze-builder-0.4.2.3/LICENSE @@ -0,0 +1,30 @@ +Copyright Jasper Van der Jeugt 2010, Simon Meier 2010 & 2011 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Jasper Van der Jeugt nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/blaze-textual-0.2.3.1/LICENSE b/docs/dependencies/licences/haskell/blaze-textual-0.2.3.1/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/boring-0.2.1/LICENSE b/docs/dependencies/licences/haskell/boring-0.2.1/LICENSE new file mode 100644 index 0000000000..96445e5484 --- /dev/null +++ b/docs/dependencies/licences/haskell/boring-0.2.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/byteorder-1.0.4/LICENSE b/docs/dependencies/licences/haskell/byteorder-1.0.4/LICENSE new file mode 100644 index 0000000000..81b771a3b8 --- /dev/null +++ b/docs/dependencies/licences/haskell/byteorder-1.0.4/LICENSE @@ -0,0 +1,30 @@ +Copyright 2009, Antoine Latter + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the author nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE b/docs/dependencies/licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE new file mode 100644 index 0000000000..5106f1309a --- /dev/null +++ b/docs/dependencies/licences/haskell/bytestring-builder-0.10.8.2.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Jasper Van der Jeugt 2010, Simon Meier 2010-2013 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Jasper Van der Jeugt nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/case-insensitive-1.2.1.0/LICENSE b/docs/dependencies/licences/haskell/case-insensitive-1.2.1.0/LICENSE new file mode 100644 index 0000000000..c81fb8bf9d --- /dev/null +++ b/docs/dependencies/licences/haskell/case-insensitive-1.2.1.0/LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2011-2013 Bas van Dijk + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * The name of Bas van Dijk and the names of contributors may NOT + be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cereal-0.5.8.3/LICENSE b/docs/dependencies/licences/haskell/cereal-0.5.8.3/LICENSE new file mode 100644 index 0000000000..6dfb133efb --- /dev/null +++ b/docs/dependencies/licences/haskell/cereal-0.5.8.3/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) Lennart Kolmodin, Galois, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/clock-0.8.4/LICENSE b/docs/dependencies/licences/haskell/clock-0.8.4/LICENSE new file mode 100644 index 0000000000..c2451c8c0a --- /dev/null +++ b/docs/dependencies/licences/haskell/clock-0.8.4/LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2009-2022, Clock Contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * The names of contributors may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/colour-2.3.6/LICENSE b/docs/dependencies/licences/haskell/colour-2.3.6/LICENSE new file mode 100644 index 0000000000..61aede888a --- /dev/null +++ b/docs/dependencies/licences/haskell/colour-2.3.6/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2008, 2009 +Russell O'Connor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/comonad-5.0.8/LICENSE b/docs/dependencies/licences/haskell/comonad-5.0.8/LICENSE new file mode 100644 index 0000000000..f8e92e04df --- /dev/null +++ b/docs/dependencies/licences/haskell/comonad-5.0.8/LICENSE @@ -0,0 +1,27 @@ +Copyright 2008-2014 Edward Kmett +Copyright 2004-2008 Dave Menendez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/composition-1.0.2.2/LICENSE b/docs/dependencies/licences/haskell/composition-1.0.2.2/LICENSE new file mode 100644 index 0000000000..e191e7ab64 --- /dev/null +++ b/docs/dependencies/licences/haskell/composition-1.0.2.2/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2011-2012, Dan Burton + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Dan Burton nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/conduit-1.3.5/LICENSE b/docs/dependencies/licences/haskell/conduit-1.3.5/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/conduit-extra-1.3.6/LICENSE b/docs/dependencies/licences/haskell/conduit-extra-1.3.6/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/constraints-0.14/LICENSE b/docs/dependencies/licences/haskell/constraints-0.14/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/contravariant-1.5.5/LICENSE b/docs/dependencies/licences/haskell/contravariant-1.5.5/LICENSE new file mode 100644 index 0000000000..f357c7c429 --- /dev/null +++ b/docs/dependencies/licences/haskell/contravariant-1.5.5/LICENSE @@ -0,0 +1,30 @@ +Copyright 2007-2015 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cryptohash-md5-0.11.101.0/LICENSE b/docs/dependencies/licences/haskell/cryptohash-md5-0.11.101.0/LICENSE new file mode 100644 index 0000000000..467bf18f27 --- /dev/null +++ b/docs/dependencies/licences/haskell/cryptohash-md5-0.11.101.0/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2010-2014 Vincent Hanquez + 2016 Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE b/docs/dependencies/licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE new file mode 100644 index 0000000000..467bf18f27 --- /dev/null +++ b/docs/dependencies/licences/haskell/cryptohash-sha1-0.11.101.0/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2010-2014 Vincent Hanquez + 2016 Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-0.34/LICENSE b/docs/dependencies/licences/haskell/crypton-0.34/LICENSE new file mode 100644 index 0000000000..a61c84945b --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-0.34/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2006-2015 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-x509-1.7.6/LICENSE b/docs/dependencies/licences/haskell/crypton-x509-1.7.6/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-x509-1.7.6/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-x509-store-1.6.9/LICENSE b/docs/dependencies/licences/haskell/crypton-x509-store-1.6.9/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-x509-store-1.6.9/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/crypton-x509-validation-1.6.12/LICENSE b/docs/dependencies/licences/haskell/crypton-x509-validation-1.6.12/LICENSE new file mode 100644 index 0000000000..e68cc61f14 --- /dev/null +++ b/docs/dependencies/licences/haskell/crypton-x509-validation-1.6.12/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2013 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/cryptostore-0.3.0.1/LICENSE b/docs/dependencies/licences/haskell/cryptostore-0.3.0.1/LICENSE new file mode 100644 index 0000000000..b85926a265 --- /dev/null +++ b/docs/dependencies/licences/haskell/cryptostore-0.3.0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2018-2023, Olivier Chéron + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Olivier Chéron nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-0.7.1.1/LICENSE b/docs/dependencies/licences/haskell/data-default-0.7.1.1/LICENSE new file mode 100644 index 0000000000..cd44640be9 --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-0.7.1.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-class-0.1.2.0/LICENSE b/docs/dependencies/licences/haskell/data-default-class-0.1.2.0/LICENSE new file mode 100644 index 0000000000..cd44640be9 --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-class-0.1.2.0/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-instances-containers-0.0.1/LICENSE b/docs/dependencies/licences/haskell/data-default-instances-containers-0.0.1/LICENSE new file mode 100644 index 0000000000..4f9075d76b --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-instances-containers-0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY LUKAS MAI AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-instances-dlist-0.0.1/LICENSE b/docs/dependencies/licences/haskell/data-default-instances-dlist-0.0.1/LICENSE new file mode 100644 index 0000000000..4f9075d76b --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-instances-dlist-0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY LUKAS MAI AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE b/docs/dependencies/licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE new file mode 100644 index 0000000000..4f9075d76b --- /dev/null +++ b/docs/dependencies/licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013 Lukas Mai + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY LUKAS MAI AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/data-fix-0.3.2/LICENSE b/docs/dependencies/licences/haskell/data-fix-0.3.2/LICENSE new file mode 100644 index 0000000000..0576170924 --- /dev/null +++ b/docs/dependencies/licences/haskell/data-fix-0.3.2/LICENSE @@ -0,0 +1,30 @@ +Copyright Anton Kholomiov 2010 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Anton Kholomiov nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/digest-0.0.1.7/LICENSE b/docs/dependencies/licences/haskell/digest-0.0.1.7/LICENSE new file mode 100644 index 0000000000..48326b0815 --- /dev/null +++ b/docs/dependencies/licences/haskell/digest-0.0.1.7/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2008-2009, Eugene Kirpichov +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/distributive-0.6.2.1/LICENSE b/docs/dependencies/licences/haskell/distributive-0.6.2.1/LICENSE new file mode 100644 index 0000000000..d6c9cbe4a4 --- /dev/null +++ b/docs/dependencies/licences/haskell/distributive-0.6.2.1/LICENSE @@ -0,0 +1,26 @@ +Copyright 2011-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/dlist-1.0/license.md b/docs/dependencies/licences/haskell/dlist-1.0/license.md new file mode 100644 index 0000000000..32ac978e90 --- /dev/null +++ b/docs/dependencies/licences/haskell/dlist-1.0/license.md @@ -0,0 +1,28 @@ +Copyright © 2006-2009 Don Stewart, 2013-2020 Sean Leather, contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holders nor the names of other contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/easy-file-0.2.5/LICENSE b/docs/dependencies/licences/haskell/easy-file-0.2.5/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/easy-file-0.2.5/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/email-validate-2.3.2.19/LICENSE b/docs/dependencies/licences/haskell/email-validate-2.3.2.19/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/entropy-0.4.1.10/LICENSE b/docs/dependencies/licences/haskell/entropy-0.4.1.10/LICENSE new file mode 100644 index 0000000000..6d24e89d0b --- /dev/null +++ b/docs/dependencies/licences/haskell/entropy-0.4.1.10/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) Thomas DuBuisson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/fast-logger-3.2.2/LICENSE b/docs/dependencies/licences/haskell/fast-logger-3.2.2/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/fast-logger-3.2.2/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/file-embed-0.0.15.0/LICENSE b/docs/dependencies/licences/haskell/file-embed-0.0.15.0/LICENSE new file mode 100644 index 0000000000..11dc17a16c --- /dev/null +++ b/docs/dependencies/licences/haskell/file-embed-0.0.15.0/LICENSE @@ -0,0 +1,25 @@ +The following license covers this documentation, and the source code, except +where otherwise indicated. + +Copyright 2008, Michael Snoyman. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/generically-0.1.1/LICENSE b/docs/dependencies/licences/haskell/generically-0.1.1/LICENSE new file mode 100644 index 0000000000..58c573a1e2 --- /dev/null +++ b/docs/dependencies/licences/haskell/generically-0.1.1/LICENSE @@ -0,0 +1,28 @@ +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Herbert Valerio Riedel nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/hashable-1.4.3.0/LICENSE b/docs/dependencies/licences/haskell/hashable-1.4.3.0/LICENSE new file mode 100644 index 0000000000..7130957b4d --- /dev/null +++ b/docs/dependencies/licences/haskell/hashable-1.4.3.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Milan Straka 2010 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Milan Straka nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/hourglass-0.2.12/LICENSE b/docs/dependencies/licences/haskell/hourglass-0.2.12/LICENSE new file mode 100644 index 0000000000..36c942d4a8 --- /dev/null +++ b/docs/dependencies/licences/haskell/hourglass-0.2.12/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2014 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/http-types-0.12.4/LICENSE b/docs/dependencies/licences/haskell/http-types-0.12.4/LICENSE new file mode 100644 index 0000000000..f77a7b24dd --- /dev/null +++ b/docs/dependencies/licences/haskell/http-types-0.12.4/LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2011, Aristid Breitkreuz +Copyright (c) 2011, Michael Snoyman + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Aristid Breitkreuz nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/http2-5.0.0/LICENSE b/docs/dependencies/licences/haskell/http2-5.0.0/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/indexed-traversable-0.1.3/LICENSE b/docs/dependencies/licences/haskell/indexed-traversable-0.1.3/LICENSE new file mode 100644 index 0000000000..476166addc --- /dev/null +++ b/docs/dependencies/licences/haskell/indexed-traversable-0.1.3/LICENSE @@ -0,0 +1,26 @@ +Copyright 2012-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE b/docs/dependencies/licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE new file mode 100644 index 0000000000..476166addc --- /dev/null +++ b/docs/dependencies/licences/haskell/indexed-traversable-instances-0.1.1.2/LICENSE @@ -0,0 +1,26 @@ +Copyright 2012-2016 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/ini-0.4.2/LICENSE b/docs/dependencies/licences/haskell/ini-0.4.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/integer-conversion-0.1.0.1/LICENSE b/docs/dependencies/licences/haskell/integer-conversion-0.1.0.1/LICENSE new file mode 100644 index 0000000000..3f74492595 --- /dev/null +++ b/docs/dependencies/licences/haskell/integer-conversion-0.1.0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2023, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/integer-logarithms-1.0.3.1/LICENSE b/docs/dependencies/licences/haskell/integer-logarithms-1.0.3.1/LICENSE new file mode 100644 index 0000000000..9430f484d0 --- /dev/null +++ b/docs/dependencies/licences/haskell/integer-logarithms-1.0.3.1/LICENSE @@ -0,0 +1,16 @@ +Copyright (c) 2011 Daniel Fischer, 2017 Oleg Grenrus + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + associated documentation files (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, publish, distribute, + sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/iproute-1.7.12/LICENSE b/docs/dependencies/licences/haskell/iproute-1.7.12/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/iproute-1.7.12/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/libyaml-0.1.2/LICENSE b/docs/dependencies/licences/haskell/libyaml-0.1.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/lifted-base-0.2.3.12/LICENSE b/docs/dependencies/licences/haskell/lifted-base-0.2.3.12/LICENSE new file mode 100644 index 0000000000..b8b5ed95c0 --- /dev/null +++ b/docs/dependencies/licences/haskell/lifted-base-0.2.3.12/LICENSE @@ -0,0 +1,29 @@ +Copyright © 2010-2012, Bas van Dijk, Anders Kaseorg +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +• Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +• Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +• Neither the name of the author nor the names of other contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +“AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/memory-0.18.0/LICENSE b/docs/dependencies/licences/haskell/memory-0.18.0/LICENSE new file mode 100644 index 0000000000..8eafd52555 --- /dev/null +++ b/docs/dependencies/licences/haskell/memory-0.18.0/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2015-2018 Vincent Hanquez +Copyright (c) 2017-2018 Nicolas Di Prima + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/monad-control-1.0.3.1/LICENSE b/docs/dependencies/licences/haskell/monad-control-1.0.3.1/LICENSE new file mode 100644 index 0000000000..f4e1bfab44 --- /dev/null +++ b/docs/dependencies/licences/haskell/monad-control-1.0.3.1/LICENSE @@ -0,0 +1,29 @@ +Copyright © 2010, Bas van Dijk, Anders Kaseorg +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +• Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +• Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +• Neither the name of the author nor the names of other contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +“AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/monad-logger-0.3.40/LICENSE b/docs/dependencies/licences/haskell/monad-logger-0.3.40/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/mono-traversable-1.0.15.3/LICENSE b/docs/dependencies/licences/haskell/mono-traversable-1.0.15.3/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/network-3.1.4.0/LICENSE b/docs/dependencies/licences/haskell/network-3.1.4.0/LICENSE new file mode 100644 index 0000000000..40bba626f8 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-3.1.4.0/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2002-2010, The University Court of the University of Glasgow. +Copyright (c) 2007-2010, Johan Tibell + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-byte-order-0.1.7/LICENSE b/docs/dependencies/licences/haskell/network-byte-order-0.1.7/LICENSE new file mode 100644 index 0000000000..ed342e54a0 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-byte-order-0.1.7/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Kazu Yamamoto + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Kazu Yamamoto nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-control-0.0.2/LICENSE b/docs/dependencies/licences/haskell/network-control-0.0.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/network-info-0.2.1/LICENSE b/docs/dependencies/licences/haskell/network-info-0.2.1/LICENSE new file mode 100644 index 0000000000..be005bd412 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-info-0.2.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2010, Jacob Stanley + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Jacob Stanley nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-transport-0.5.6/LICENSE b/docs/dependencies/licences/haskell/network-transport-0.5.6/LICENSE new file mode 100644 index 0000000000..f3459e449d --- /dev/null +++ b/docs/dependencies/licences/haskell/network-transport-0.5.6/LICENSE @@ -0,0 +1,31 @@ +Copyright Well-Typed LLP, 2011-2012 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the owner nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/network-udp-0.0.0/LICENSE b/docs/dependencies/licences/haskell/network-udp-0.0.0/LICENSE new file mode 100644 index 0000000000..73957a7a32 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-udp-0.0.0/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2022, Internet Initiative Japan Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/network-uri-2.6.4.2/LICENSE b/docs/dependencies/licences/haskell/network-uri-2.6.4.2/LICENSE new file mode 100644 index 0000000000..40bba626f8 --- /dev/null +++ b/docs/dependencies/licences/haskell/network-uri-2.6.4.2/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2002-2010, The University Court of the University of Glasgow. +Copyright (c) 2007-2010, Johan Tibell + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/old-locale-1.0.0.7/LICENSE b/docs/dependencies/licences/haskell/old-locale-1.0.0.7/LICENSE new file mode 100644 index 0000000000..06bb641487 --- /dev/null +++ b/docs/dependencies/licences/haskell/old-locale-1.0.0.7/LICENSE @@ -0,0 +1,63 @@ +This library (libraries/base) is derived from code from two +sources: + + * Code from the GHC project which is largely (c) The University of + Glasgow, and distributable under a BSD-style license (see below), + + * Code from the Haskell 98 Report which is (c) Simon Peyton Jones + and freely redistributable (but see the full license for + restrictions). + +The full text of these licenses is reproduced below. Both of the +licenses are BSD-style or compatible. + +----------------------------------------------------------------------------- + +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +----------------------------------------------------------------------------- + +Code derived from the document "Report on the Programming Language +Haskell 98", is distributed under the following license: + + Copyright (c) 2002 Simon Peyton Jones + + The authors intend this Report to belong to the entire Haskell + community, and so we grant permission to copy and distribute it for + any purpose, provided that it is reproduced in its entirety, + including this Notice. Modified versions of this Report may also be + copied and distributed for any purpose, provided that the modified + version is clearly presented as such, and that it does not claim to + be a definition of the Haskell 98 Language. + +----------------------------------------------------------------------------- diff --git a/docs/dependencies/licences/haskell/old-time-1.1.0.4/LICENSE b/docs/dependencies/licences/haskell/old-time-1.1.0.4/LICENSE new file mode 100644 index 0000000000..06bb641487 --- /dev/null +++ b/docs/dependencies/licences/haskell/old-time-1.1.0.4/LICENSE @@ -0,0 +1,63 @@ +This library (libraries/base) is derived from code from two +sources: + + * Code from the GHC project which is largely (c) The University of + Glasgow, and distributable under a BSD-style license (see below), + + * Code from the Haskell 98 Report which is (c) Simon Peyton Jones + and freely redistributable (but see the full license for + restrictions). + +The full text of these licenses is reproduced below. Both of the +licenses are BSD-style or compatible. + +----------------------------------------------------------------------------- + +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +----------------------------------------------------------------------------- + +Code derived from the document "Report on the Programming Language +Haskell 98", is distributed under the following license: + + Copyright (c) 2002 Simon Peyton Jones + + The authors intend this Report to belong to the entire Haskell + community, and so we grant permission to copy and distribute it for + any purpose, provided that it is reproduced in its entirety, + including this Notice. Modified versions of this Report may also be + copied and distributed for any purpose, provided that the modified + version is clearly presented as such, and that it does not claim to + be a definition of the Haskell 98 Language. + +----------------------------------------------------------------------------- diff --git a/docs/dependencies/licences/haskell/optparse-applicative-0.18.1.0/LICENSE b/docs/dependencies/licences/haskell/optparse-applicative-0.18.1.0/LICENSE new file mode 100644 index 0000000000..0507cc2a81 --- /dev/null +++ b/docs/dependencies/licences/haskell/optparse-applicative-0.18.1.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, Paolo Capriotti + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Paolo Capriotti nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/pem-0.2.4/LICENSE b/docs/dependencies/licences/haskell/pem-0.2.4/LICENSE new file mode 100644 index 0000000000..15c41788af --- /dev/null +++ b/docs/dependencies/licences/haskell/pem-0.2.4/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2018 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/prettyprinter-1.7.1/LICENSE.md b/docs/dependencies/licences/haskell/prettyprinter-1.7.1/LICENSE.md new file mode 100644 index 0000000000..39592102dc --- /dev/null +++ b/docs/dependencies/licences/haskell/prettyprinter-1.7.1/LICENSE.md @@ -0,0 +1,23 @@ +Copyright 2008, Daan Leijen and Max Bolingbroke, 2016 David Luposchainsky. All +rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + - Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +This software is provided by the copyright holders "as is" and any express or +implied warranties, including, but not limited to, the implied warranties of +merchantability and fitness for a particular purpose are disclaimed. In no event +shall the copyright holders be liable for any direct, indirect, incidental, +special, exemplary, or consequential damages (including, but not limited to, +procurement of substitute goods or services; loss of use, data, or profits; or +business interruption) however caused and on any theory of liability, whether in +contract, strict liability, or tort (including negligence or otherwise) arising +in any way out of the use of this software, even if advised of the possibility +of such damage. diff --git a/docs/dependencies/licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md b/docs/dependencies/licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md new file mode 100644 index 0000000000..39592102dc --- /dev/null +++ b/docs/dependencies/licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md @@ -0,0 +1,23 @@ +Copyright 2008, Daan Leijen and Max Bolingbroke, 2016 David Luposchainsky. All +rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + - Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +This software is provided by the copyright holders "as is" and any express or +implied warranties, including, but not limited to, the implied warranties of +merchantability and fitness for a particular purpose are disclaimed. In no event +shall the copyright holders be liable for any direct, indirect, incidental, +special, exemplary, or consequential damages (including, but not limited to, +procurement of substitute goods or services; loss of use, data, or profits; or +business interruption) however caused and on any theory of liability, whether in +contract, strict liability, or tort (including negligence or otherwise) arising +in any way out of the use of this software, even if advised of the possibility +of such damage. diff --git a/docs/dependencies/licences/haskell/primitive-0.9.0.0/LICENSE b/docs/dependencies/licences/haskell/primitive-0.9.0.0/LICENSE new file mode 100644 index 0000000000..fc213a6ffb --- /dev/null +++ b/docs/dependencies/licences/haskell/primitive-0.9.0.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2008-2009, Roman Leshchinskiy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + diff --git a/docs/dependencies/licences/haskell/psqueues-0.2.8.0/LICENSE b/docs/dependencies/licences/haskell/psqueues-0.2.8.0/LICENSE new file mode 100644 index 0000000000..92337b951e --- /dev/null +++ b/docs/dependencies/licences/haskell/psqueues-0.2.8.0/LICENSE @@ -0,0 +1,31 @@ +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/random-1.2.1.1/LICENSE b/docs/dependencies/licences/haskell/random-1.2.1.1/LICENSE new file mode 100644 index 0000000000..06bb641487 --- /dev/null +++ b/docs/dependencies/licences/haskell/random-1.2.1.1/LICENSE @@ -0,0 +1,63 @@ +This library (libraries/base) is derived from code from two +sources: + + * Code from the GHC project which is largely (c) The University of + Glasgow, and distributable under a BSD-style license (see below), + + * Code from the Haskell 98 Report which is (c) Simon Peyton Jones + and freely redistributable (but see the full license for + restrictions). + +The full text of these licenses is reproduced below. Both of the +licenses are BSD-style or compatible. + +----------------------------------------------------------------------------- + +The Glasgow Haskell Compiler License + +Copyright 2004, The University Court of the University of Glasgow. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +----------------------------------------------------------------------------- + +Code derived from the document "Report on the Programming Language +Haskell 98", is distributed under the following license: + + Copyright (c) 2002 Simon Peyton Jones + + The authors intend this Report to belong to the entire Haskell + community, and so we grant permission to copy and distribute it for + any purpose, provided that it is reproduced in its entirety, + including this Notice. Modified versions of this Report may also be + copied and distributed for any purpose, provided that the modified + version is clearly presented as such, and that it does not claim to + be a definition of the Haskell 98 Language. + +----------------------------------------------------------------------------- diff --git a/docs/dependencies/licences/haskell/record-hasfield-1.0/LICENSE b/docs/dependencies/licences/haskell/record-hasfield-1.0/LICENSE new file mode 100644 index 0000000000..f6a9dc60f7 --- /dev/null +++ b/docs/dependencies/licences/haskell/record-hasfield-1.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Adam Gundry and Neil Mitchell 2018-2019. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Neil Mitchell nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/resourcet-1.3.0/LICENSE b/docs/dependencies/licences/haskell/resourcet-1.3.0/LICENSE new file mode 100644 index 0000000000..fe6d4e9b3f --- /dev/null +++ b/docs/dependencies/licences/haskell/resourcet-1.3.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c)2011, Michael Snoyman + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Michael Snoyman nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/safe-exceptions-0.1.7.4/LICENSE b/docs/dependencies/licences/haskell/safe-exceptions-0.1.7.4/LICENSE new file mode 100644 index 0000000000..9a69c0901b --- /dev/null +++ b/docs/dependencies/licences/haskell/safe-exceptions-0.1.7.4/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/scientific-0.3.7.0/LICENSE b/docs/dependencies/licences/haskell/scientific-0.3.7.0/LICENSE new file mode 100644 index 0000000000..c1015843aa --- /dev/null +++ b/docs/dependencies/licences/haskell/scientific-0.3.7.0/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2013, Bas van Dijk + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Bas van Dijk nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/semialign-1.3/LICENSE b/docs/dependencies/licences/haskell/semialign-1.3/LICENSE new file mode 100644 index 0000000000..9d6d650165 --- /dev/null +++ b/docs/dependencies/licences/haskell/semialign-1.3/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, C. McCann, 2015-2019 Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of C. McCann nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/semigroupoids-6.0.0.1/LICENSE b/docs/dependencies/licences/haskell/semigroupoids-6.0.0.1/LICENSE new file mode 100644 index 0000000000..1812ac961c --- /dev/null +++ b/docs/dependencies/licences/haskell/semigroupoids-6.0.0.1/LICENSE @@ -0,0 +1,26 @@ +Copyright 2011-2015 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/simple-logger-0.1.1/LICENSE b/docs/dependencies/licences/haskell/simple-logger-0.1.1/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/socks-0.6.1/LICENSE b/docs/dependencies/licences/haskell/socks-0.6.1/LICENSE new file mode 100644 index 0000000000..ed6f548a98 --- /dev/null +++ b/docs/dependencies/licences/haskell/socks-0.6.1/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2019 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/split-0.2.4/LICENSE b/docs/dependencies/licences/haskell/split-0.2.4/LICENSE new file mode 100644 index 0000000000..648167ba61 --- /dev/null +++ b/docs/dependencies/licences/haskell/split-0.2.4/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2008 Brent Yorgey, Louis Wasserman + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of other contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/splitmix-0.1.0.5/LICENSE b/docs/dependencies/licences/haskell/splitmix-0.1.0.5/LICENSE new file mode 100644 index 0000000000..96445e5484 --- /dev/null +++ b/docs/dependencies/licences/haskell/splitmix-0.1.0.5/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/stm-chans-3.0.0.9/LICENSE b/docs/dependencies/licences/haskell/stm-chans-3.0.0.9/LICENSE new file mode 100644 index 0000000000..55157e8352 --- /dev/null +++ b/docs/dependencies/licences/haskell/stm-chans-3.0.0.9/LICENSE @@ -0,0 +1,35 @@ +=== stm-chans license === + +Copyright (c) 2011--2013, wren gayle romano. +ALL RIGHTS RESERVED. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the copyright holders nor the names of + other contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/streaming-commons-0.2.2.6/LICENSE b/docs/dependencies/licences/haskell/streaming-commons-0.2.2.6/LICENSE new file mode 100644 index 0000000000..7198785aaf --- /dev/null +++ b/docs/dependencies/licences/haskell/streaming-commons-0.2.2.6/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/docs/dependencies/licences/haskell/strict-0.5/LICENSE b/docs/dependencies/licences/haskell/strict-0.5/LICENSE new file mode 100644 index 0000000000..4782550806 --- /dev/null +++ b/docs/dependencies/licences/haskell/strict-0.5/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) Roman Leshchinskiy 2006-2007 + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/tagged-0.8.8/LICENSE b/docs/dependencies/licences/haskell/tagged-0.8.8/LICENSE new file mode 100644 index 0000000000..db046152dc --- /dev/null +++ b/docs/dependencies/licences/haskell/tagged-0.8.8/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2009-2015 Edward Kmett +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Edward Kmett nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/temporary-1.3/LICENSE b/docs/dependencies/licences/haskell/temporary-1.3/LICENSE new file mode 100644 index 0000000000..db55f34db3 --- /dev/null +++ b/docs/dependencies/licences/haskell/temporary-1.3/LICENSE @@ -0,0 +1,27 @@ +Copyright + (c) 2003-2006, Isaac Jones + (c) 2005-2009, Duncan Coutts + (c) 2008, Maximilian Bolingbroke + ... and other contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted +provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + * Neither the name of Maximilian Bolingbroke nor the names of other contributors may be used to + endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/terminal-0.2.0.0/LICENSE b/docs/dependencies/licences/haskell/terminal-0.2.0.0/LICENSE new file mode 100644 index 0000000000..d45361ccf6 --- /dev/null +++ b/docs/dependencies/licences/haskell/terminal-0.2.0.0/LICENSE @@ -0,0 +1,30 @@ +Copyright Lars Petersen (c) 2018 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Lars Petersen nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/text-iso8601-0.1/LICENSE b/docs/dependencies/licences/haskell/text-iso8601-0.1/LICENSE new file mode 100644 index 0000000000..fc90c466c7 --- /dev/null +++ b/docs/dependencies/licences/haskell/text-iso8601-0.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2023 Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE CONTRIBUTORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/text-short-0.1.5/LICENSE b/docs/dependencies/licences/haskell/text-short-0.1.5/LICENSE new file mode 100644 index 0000000000..98d4b1ad78 --- /dev/null +++ b/docs/dependencies/licences/haskell/text-short-0.1.5/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2017, Herbert Valerio Riedel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Herbert Valerio Riedel nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/th-abstraction-0.6.0.0/LICENSE b/docs/dependencies/licences/haskell/th-abstraction-0.6.0.0/LICENSE new file mode 100644 index 0000000000..70f6077c10 --- /dev/null +++ b/docs/dependencies/licences/haskell/th-abstraction-0.6.0.0/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2017-2020 Eric Mertens + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/docs/dependencies/licences/haskell/th-compat-0.1.4/LICENSE b/docs/dependencies/licences/haskell/th-compat-0.1.4/LICENSE new file mode 100644 index 0000000000..879f215afc --- /dev/null +++ b/docs/dependencies/licences/haskell/th-compat-0.1.4/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2020, Ryan Scott + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Ryan Scott nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/these-1.2/LICENSE b/docs/dependencies/licences/haskell/these-1.2/LICENSE new file mode 100644 index 0000000000..9d6d650165 --- /dev/null +++ b/docs/dependencies/licences/haskell/these-1.2/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, C. McCann, 2015-2019 Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of C. McCann nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/time-compat-1.9.6.1/LICENSE b/docs/dependencies/licences/haskell/time-compat-1.9.6.1/LICENSE new file mode 100644 index 0000000000..910ef51bb4 --- /dev/null +++ b/docs/dependencies/licences/haskell/time-compat-1.9.6.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2019 time contibutors, Oleg Grenrus + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Oleg Grenrus nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/time-manager-0.0.1/LICENSE b/docs/dependencies/licences/haskell/time-manager-0.0.1/LICENSE new file mode 100644 index 0000000000..d9f041796e --- /dev/null +++ b/docs/dependencies/licences/haskell/time-manager-0.0.1/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2012 Michael Snoyman, http://www.yesodweb.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/tls-1.9.0/LICENSE b/docs/dependencies/licences/haskell/tls-1.9.0/LICENSE new file mode 100644 index 0000000000..96ec822eb9 --- /dev/null +++ b/docs/dependencies/licences/haskell/tls-1.9.0/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2015 Vincent Hanquez + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/transformers-base-0.4.6/LICENSE b/docs/dependencies/licences/haskell/transformers-base-0.4.6/LICENSE new file mode 100644 index 0000000000..9d51261b52 --- /dev/null +++ b/docs/dependencies/licences/haskell/transformers-base-0.4.6/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2011, Mikhail Vorozhtsov, Bas van Dijk +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +- Neither the names of the copyright owners nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/docs/dependencies/licences/haskell/transformers-compat-0.7.2/LICENSE b/docs/dependencies/licences/haskell/transformers-compat-0.7.2/LICENSE new file mode 100644 index 0000000000..50586d197c --- /dev/null +++ b/docs/dependencies/licences/haskell/transformers-compat-0.7.2/LICENSE @@ -0,0 +1,30 @@ +Copyright 2012-2015 Edward Kmett + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/type-equality-1/LICENSE b/docs/dependencies/licences/haskell/type-equality-1/LICENSE new file mode 100644 index 0000000000..c30f3e7d31 --- /dev/null +++ b/docs/dependencies/licences/haskell/type-equality-1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2009 Erik Hesselink, 2019 Oleg Grenrus, Ryan Scott + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of authors nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/typed-process-0.2.11.1/LICENSE b/docs/dependencies/licences/haskell/typed-process-0.2.11.1/LICENSE new file mode 100644 index 0000000000..50bb8ea0cf --- /dev/null +++ b/docs/dependencies/licences/haskell/typed-process-0.2.11.1/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 FP Complete, https://www.fpcomplete.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/unix-compat-0.7.1/LICENSE b/docs/dependencies/licences/haskell/unix-compat-0.7.1/LICENSE new file mode 100644 index 0000000000..cc3be3e947 --- /dev/null +++ b/docs/dependencies/licences/haskell/unix-compat-0.7.1/LICENSE @@ -0,0 +1,31 @@ +BSD 3-Clause License + +Copyright (c) 2007-2008, Björn Bringert +Copyright (c) 2007-2009, Duncan Coutts +Copyright (c) 2010-2011, Jacob Stanley +Copyright (c) 2011, Bryan O'Sullivan +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +- Neither the names of the copyright owners nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/unix-time-0.4.11/LICENSE b/docs/dependencies/licences/haskell/unix-time-0.4.11/LICENSE new file mode 100644 index 0000000000..5422193088 --- /dev/null +++ b/docs/dependencies/licences/haskell/unix-time-0.4.11/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2009, IIJ Innovation Institute Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/unliftio-0.2.25.0/LICENSE b/docs/dependencies/licences/haskell/unliftio-0.2.25.0/LICENSE new file mode 100644 index 0000000000..1349134e01 --- /dev/null +++ b/docs/dependencies/licences/haskell/unliftio-0.2.25.0/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2017 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/unliftio-core-0.2.1.0/LICENSE b/docs/dependencies/licences/haskell/unliftio-core-0.2.1.0/LICENSE new file mode 100644 index 0000000000..1349134e01 --- /dev/null +++ b/docs/dependencies/licences/haskell/unliftio-core-0.2.1.0/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2017 FP Complete + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/dependencies/licences/haskell/unordered-containers-0.2.19.1/LICENSE b/docs/dependencies/licences/haskell/unordered-containers-0.2.19.1/LICENSE new file mode 100644 index 0000000000..5eb7e1bdda --- /dev/null +++ b/docs/dependencies/licences/haskell/unordered-containers-0.2.19.1/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2010, Johan Tibell + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Johan Tibell nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/uuid-1.3.15/LICENSE b/docs/dependencies/licences/haskell/uuid-1.3.15/LICENSE new file mode 100644 index 0000000000..dc46bca24c --- /dev/null +++ b/docs/dependencies/licences/haskell/uuid-1.3.15/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2008, Antoine Latter + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + * The names of the authors may not be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/uuid-types-1.0.5.1/LICENSE b/docs/dependencies/licences/haskell/uuid-types-1.0.5.1/LICENSE new file mode 100644 index 0000000000..dc46bca24c --- /dev/null +++ b/docs/dependencies/licences/haskell/uuid-types-1.0.5.1/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2008, Antoine Latter + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + * The names of the authors may not be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/vector-0.13.1.0/LICENSE b/docs/dependencies/licences/haskell/vector-0.13.1.0/LICENSE new file mode 100644 index 0000000000..ef2a5268d6 --- /dev/null +++ b/docs/dependencies/licences/haskell/vector-0.13.1.0/LICENSE @@ -0,0 +1,32 @@ +Copyright (c) 2008-2012, Roman Leshchinskiy + 2020-2022, Alexey Kuleshevich + 2020-2022, Aleksey Khudyakov + 2020-2022, Andrew Lelechenko +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/vector-algorithms-0.9.0.1/LICENSE b/docs/dependencies/licences/haskell/vector-algorithms-0.9.0.1/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/vector-stream-0.1.0.0/LICENSE b/docs/dependencies/licences/haskell/vector-stream-0.1.0.0/LICENSE new file mode 100644 index 0000000000..ef2a5268d6 --- /dev/null +++ b/docs/dependencies/licences/haskell/vector-stream-0.1.0.0/LICENSE @@ -0,0 +1,32 @@ +Copyright (c) 2008-2012, Roman Leshchinskiy + 2020-2022, Alexey Kuleshevich + 2020-2022, Aleksey Khudyakov + 2020-2022, Andrew Lelechenko +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +- Neither name of the University nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY COURT OF THE UNIVERSITY OF +GLASGOW AND THE CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +UNIVERSITY COURT OF THE UNIVERSITY OF GLASGOW OR THE CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/dependencies/licences/haskell/websockets-0.12.7.3/LICENCE b/docs/dependencies/licences/haskell/websockets-0.12.7.3/LICENCE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/witherable-0.4.2/LICENSE b/docs/dependencies/licences/haskell/witherable-0.4.2/LICENSE new file mode 100644 index 0000000000..1994a4c649 --- /dev/null +++ b/docs/dependencies/licences/haskell/witherable-0.4.2/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2014, Fumiaki Kinoshita + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Fumiaki Kinoshita nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/yaml-0.11.11.2/LICENSE b/docs/dependencies/licences/haskell/yaml-0.11.11.2/LICENSE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/zip-2.0.0/LICENSE.md b/docs/dependencies/licences/haskell/zip-2.0.0/LICENSE.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/dependencies/licences/haskell/zlib-0.6.3.0/LICENSE b/docs/dependencies/licences/haskell/zlib-0.6.3.0/LICENSE new file mode 100644 index 0000000000..e4d25e0e0a --- /dev/null +++ b/docs/dependencies/licences/haskell/zlib-0.6.3.0/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2006-2016, Duncan Coutts +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. This clause is intentionally left blank. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/zstd-0.1.3.0/LICENSE b/docs/dependencies/licences/haskell/zstd-0.1.3.0/LICENSE new file mode 100644 index 0000000000..a793a80289 --- /dev/null +++ b/docs/dependencies/licences/haskell/zstd-0.1.3.0/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For Zstandard software + +Copyright (c) 2016-present, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 7b11d8514a6e96e01810b135f73710072bf18d69 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 25 Apr 2025 11:17:27 +0100 Subject: [PATCH 195/289] core, ui: option to use web port by default for preset servers only (#5847) * core: option to use web port by default for preset servers only * ui * refactor * simplexmq --- .../AdvancedNetworkSettings.swift | 9 +- apps/ios/SimpleXChat/APITypes.swift | 16 ++- apps/ios/SimpleXChat/AppGroup.swift | 16 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 126 ++++++++++-------- .../AdvancedNetworkSettings.kt | 30 +++-- .../commonMain/resources/MR/base/strings.xml | 4 + .../commonMain/resources/MR/ru/strings.xml | 4 + cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 5 +- src/Simplex/Chat/Controller.hs | 8 +- src/Simplex/Chat/Library/Commands.hs | 10 +- src/Simplex/Chat/Operators/Presets.hs | 4 +- src/Simplex/Chat/Options.hs | 14 +- 14 files changed, 156 insertions(+), 94 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 55f2e837b8..fa698f8b7c 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -209,11 +209,16 @@ struct AdvancedNetworkSettings: View { } Section { - Toggle("Use web port", isOn: $netCfg.smpWebPort) + Picker("Use web port", selection: $netCfg.smpWebPortServers) { + ForEach(SMPWebPortServers.allCases, id: \.self) { Text($0.text) } + } + .frame(height: 36) } header: { Text("TCP port for messaging") } footer: { - Text("Use TCP port \(netCfg.smpWebPort ? "443" : "5223") when no port is specified.") + netCfg.smpWebPortServers == .preset + ? Text("Use TCP port 443 for preset servers only.") + : Text("Use TCP port \(netCfg.smpWebPortServers == .all ? "443" : "5223") when no port is specified.") } Section("TCP connection") { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index a9de0df01b..05fc4ff87b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1796,7 +1796,7 @@ public struct NetCfg: Codable, Equatable { public var sessionMode = TransportSessionMode.user public var smpProxyMode: SMPProxyMode = .always public var smpProxyFallback: SMPProxyFallback = .allowProtected - public var smpWebPort = false + public var smpWebPortServers: SMPWebPortServers = .preset public var tcpConnectTimeout: Int // microseconds public var tcpTimeout: Int // microseconds public var tcpTimeoutPerKb: Int // microseconds @@ -1892,6 +1892,20 @@ public enum SMPProxyFallback: String, Codable, SelectableItem { public static let values: [SMPProxyFallback] = [.allow, .allowProtected, .prohibit] } +public enum SMPWebPortServers: String, Codable, CaseIterable { + case all = "all" + case preset = "preset" + case off = "off" + + public var text: LocalizedStringKey { + switch self { + case .all: "All servers" + case .preset: "Preset servers" + case .off: "Off" + } + } +} + public enum OnionHosts: String, Identifiable { case no case prefer diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 45a05a1cd6..75bb537b94 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -40,7 +40,7 @@ let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE = "networkSMPProxyMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK = "networkSMPProxyFallback" -let GROUP_DEFAULT_NETWORK_SMP_WEB_PORT = "networkSMPWebPort" +let GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS = "networkSMPWebPortServers" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -72,7 +72,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue, - GROUP_DEFAULT_NETWORK_SMP_WEB_PORT: false, + GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb, @@ -251,6 +251,12 @@ public let networkSMPProxyFallbackGroupDefault = EnumDefault( withDefault: .allowProtected ) +public let networkSMPWebPortServersDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS, + withDefault: .preset +) + public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE) public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE) @@ -338,7 +344,7 @@ public func getNetCfg() -> NetCfg { let sessionMode = networkSessionModeGroupDefault.get() let smpProxyMode = networkSMPProxyModeGroupDefault.get() let smpProxyFallback = networkSMPProxyFallbackGroupDefault.get() - let smpWebPort = groupDefaults.bool(forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT) + let smpWebPortServers = networkSMPWebPortServersDefault.get() let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let tcpTimeoutPerKb = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) @@ -362,7 +368,7 @@ public func getNetCfg() -> NetCfg { sessionMode: sessionMode, smpProxyMode: smpProxyMode, smpProxyFallback: smpProxyFallback, - smpWebPort: smpWebPort, + smpWebPortServers: smpWebPortServers, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpTimeoutPerKb: tcpTimeoutPerKb, @@ -381,7 +387,7 @@ public func setNetCfg(_ cfg: NetCfg, networkProxy: NetworkProxy?) { networkSMPProxyFallbackGroupDefault.set(cfg.smpProxyFallback) let socksProxy = networkProxy?.toProxyString() groupDefaults.set(socksProxy, forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) - groupDefaults.set(cfg.smpWebPort, forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT) + networkSMPWebPortServersDefault.set(cfg.smpWebPortServers) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.tcpTimeoutPerKb, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) 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..bfb587820a 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 @@ -20,6 +20,8 @@ import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.MsgContent.MCUnknown +import chat.simplex.common.model.SMPProxyFallback.AllowProtected +import chat.simplex.common.model.SMPProxyMode.Always import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -86,18 +88,7 @@ class AppPreferences { val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true) - private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name) - val callOnLockScreen: SharedPreference = SharedPreference( - get = fun(): CallOnLockScreen { - val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default - return try { - CallOnLockScreen.valueOf(value) - } catch (e: Throwable) { - CallOnLockScreen.default - } - }, - set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) } - ) + val callOnLockScreen: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default) val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false) val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.default) { LAMode.values().firstOrNull { it.name == this } } val laLockDelay = mkIntPreference(SHARED_PREFS_LA_LOCK_DELAY, 30) @@ -107,18 +98,7 @@ class AppPreferences { val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } } - private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name) - val simplexLinkMode: SharedPreference = SharedPreference( - get = fun(): SimplexLinkMode { - val value = _simplexLinkMode.get() ?: return SimplexLinkMode.default - return try { - SimplexLinkMode.valueOf(value) - } catch (e: Throwable) { - SimplexLinkMode.default - } - }, - set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) } - ) + val simplexLinkMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default) val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true) val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true) val privacyShortLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SHORT_LINKS, false) @@ -158,23 +138,12 @@ class AppPreferences { }, set = fun(proxy: NetworkProxy) { _networkProxy.set(json.encodeToString(proxy)) } ) - private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) - val networkSessionMode: SharedPreference = SharedPreference( - get = fun(): TransportSessionMode { - val value = _networkSessionMode.get() ?: return TransportSessionMode.default - return try { - TransportSessionMode.valueOf(value) - } catch (e: Throwable) { - TransportSessionMode.default - } - }, - set = fun(mode: TransportSessionMode) { _networkSessionMode.set(mode.name) } - ) - val networkSMPProxyMode = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_MODE, NetCfg.defaults.smpProxyMode.name) - val networkSMPProxyFallback = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, NetCfg.defaults.smpProxyFallback.name) - val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) + val networkSessionMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default) + val networkSMPProxyMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SMP_PROXY_MODE, SMPProxyMode.default) + val networkSMPProxyFallback: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, SMPProxyFallback.default) + val networkHostMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.default) val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) - val networkSMPWebPort = mkBoolPreference(SHARED_PREFS_NETWORK_SMP_WEB_PORT, false) + val networkSMPWebPortServers: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_NETWORK_SMP_WEB_PORT_SERVERS, SMPWebPortServers.default) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout) val networkTCPTimeoutPerKb = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB, NetCfg.defaults.tcpTimeoutPerKb, NetCfg.proxyDefaults.tcpTimeoutPerKb) @@ -329,7 +298,19 @@ class AppPreferences { set = fun(value) = settings.putString(prefName, value.toString()) ) - // LALAL + private inline fun > mkSafeEnumPreference(key: String, default: T): SharedPreference = SharedPreference( + get = { + val value = settings.getString(key, "") + if (value == "") return@SharedPreference default + try { + enumValueOf(value) + } catch (e: IllegalArgumentException) { + default + } + }, + set = { value -> settings.putString(key, value.name) } + ) + private fun mkDatePreference(prefName: String, default: Instant?): SharedPreference = SharedPreference( get = { @@ -414,7 +395,7 @@ class AppPreferences { private const val SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK = "NetworkSMPProxyFallback" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" - private const val SHARED_PREFS_NETWORK_SMP_WEB_PORT = "NetworkSMPWebPort" + private const val SHARED_PREFS_NETWORK_SMP_WEB_PORT_SERVERS = "NetworkSMPWebPortServers" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -3309,12 +3290,12 @@ object ChatController { } else { null } - val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!) + val hostMode = appPrefs.networkHostMode.get() val requiredHostMode = appPrefs.networkRequiredHostMode.get() val sessionMode = appPrefs.networkSessionMode.get() - val smpProxyMode = SMPProxyMode.valueOf(appPrefs.networkSMPProxyMode.get()!!) - val smpProxyFallback = SMPProxyFallback.valueOf(appPrefs.networkSMPProxyFallback.get()!!) - val smpWebPort = appPrefs.networkSMPWebPort.get() + val smpProxyMode = appPrefs.networkSMPProxyMode.get() + val smpProxyFallback = appPrefs.networkSMPProxyFallback.get() + val smpWebPortServers = appPrefs.networkSMPWebPortServers.get() val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val tcpTimeoutPerKb = appPrefs.networkTCPTimeoutPerKb.get() @@ -3337,7 +3318,7 @@ object ChatController { sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, - smpWebPort = smpWebPort, + smpWebPortServers = smpWebPortServers, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpTimeoutPerKb = tcpTimeoutPerKb, @@ -3353,12 +3334,12 @@ object ChatController { * */ fun setNetCfg(cfg: NetCfg) { appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy) - appPrefs.networkHostMode.set(cfg.hostMode.name) + appPrefs.networkHostMode.set(cfg.hostMode) appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode) appPrefs.networkSessionMode.set(cfg.sessionMode) - appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode.name) - appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback.name) - appPrefs.networkSMPWebPort.set(cfg.smpWebPort) + appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode) + appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback) + appPrefs.networkSMPWebPortServers.set(cfg.smpWebPortServers) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkTCPTimeoutPerKb.set(cfg.tcpTimeoutPerKb) @@ -4460,13 +4441,13 @@ data class ParsedServerAddress ( @Serializable data class NetCfg( val socksProxy: String?, - val socksMode: SocksMode = SocksMode.Always, - val hostMode: HostMode = HostMode.OnionViaSocks, + val socksMode: SocksMode = SocksMode.default, + val hostMode: HostMode = HostMode.default, val requiredHostMode: Boolean = false, val sessionMode: TransportSessionMode = TransportSessionMode.default, - val smpProxyMode: SMPProxyMode = SMPProxyMode.Always, - val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected, - val smpWebPort: Boolean = false, + val smpProxyMode: SMPProxyMode = SMPProxyMode.default, + val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.default, + val smpWebPortServers: SMPWebPortServers = SMPWebPortServers.default, val tcpConnectTimeout: Long, // microseconds val tcpTimeout: Long, // microseconds val tcpTimeoutPerKb: Long, // microseconds @@ -4564,12 +4545,20 @@ enum class HostMode { @SerialName("onionViaSocks") OnionViaSocks, @SerialName("onion") Onion, @SerialName("public") Public; + + companion object { + val default = OnionViaSocks + } } @Serializable enum class SocksMode { @SerialName("always") Always, @SerialName("onion") Onion; + + companion object { + val default = Always + } } @Serializable @@ -4578,6 +4567,10 @@ enum class SMPProxyMode { @SerialName("unknown") Unknown, @SerialName("unprotected") Unprotected, @SerialName("never") Never; + + companion object { + val default = Always + } } @Serializable @@ -4585,6 +4578,27 @@ enum class SMPProxyFallback { @SerialName("allow") Allow, @SerialName("allowProtected") AllowProtected, @SerialName("prohibit") Prohibit; + + companion object { + val default = AllowProtected + } +} + +@Serializable +enum class SMPWebPortServers { + @SerialName("all") All, + @SerialName("preset") Preset, + @SerialName("off") Off; + + val text get(): StringResource = when (this) { + All -> MR.strings.network_smp_web_port_all + Preset -> MR.strings.network_smp_web_port_preset + Off -> MR.strings.network_smp_web_port_off + } + + companion object { + val default = Preset + } } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index e85d88c5f8..0c38b0c045 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -19,6 +19,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* @@ -45,7 +46,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U val sessionMode = remember { mutableStateOf(currentCfgVal.sessionMode) } val smpProxyMode = remember { mutableStateOf(currentCfgVal.smpProxyMode) } val smpProxyFallback = remember { mutableStateOf(currentCfgVal.smpProxyFallback) } - val smpWebPort = remember { mutableStateOf(currentCfgVal.smpWebPort) } + val smpWebPortServers = remember { mutableStateOf(currentCfgVal.smpWebPortServers) } val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) } val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) } @@ -84,7 +85,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode.value, smpProxyMode = smpProxyMode.value, smpProxyFallback = smpProxyFallback.value, - smpWebPort = smpWebPort.value, + smpWebPortServers = smpWebPortServers.value, tcpConnectTimeout = networkTCPConnectTimeout.value, tcpTimeout = networkTCPTimeout.value, tcpTimeoutPerKb = networkTCPTimeoutPerKb.value, @@ -99,7 +100,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode.value = cfg.sessionMode smpProxyMode.value = cfg.smpProxyMode smpProxyFallback.value = cfg.smpProxyFallback - smpWebPort.value = cfg.smpWebPort + smpWebPortServers.value = cfg.smpWebPortServers networkTCPConnectTimeout.value = cfg.tcpConnectTimeout networkTCPTimeout.value = cfg.tcpTimeout networkTCPTimeoutPerKb.value = cfg.tcpTimeoutPerKb @@ -154,7 +155,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, - smpWebPort, + smpWebPortServers, networkTCPConnectTimeout, networkTCPTimeout, networkTCPTimeoutPerKb, @@ -187,7 +188,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode: MutableState, smpProxyMode: MutableState, smpProxyFallback: MutableState, - smpWebPort: MutableState, + smpWebPortServers: MutableState, networkTCPConnectTimeout: MutableState, networkTCPTimeout: MutableState, networkTCPTimeoutPerKb: MutableState, @@ -226,11 +227,16 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U } SectionDividerSpaced() SectionView(stringResource(MR.strings.network_smp_web_port_section_title).uppercase()) { - PreferenceToggle(stringResource(MR.strings.network_smp_web_port_toggle), checked = smpWebPort.value) { - smpWebPort.value = it - } + ExposedDropDownSettingRow( + stringResource(MR.strings.network_smp_web_port_toggle), + SMPWebPortServers.entries.map { it to stringResource(it.text) }, + smpWebPortServers + ) { smpWebPortServers.value = it } } - SectionTextFooter(String.format(stringResource(MR.strings.network_smp_web_port_footer), if (smpWebPort.value) "443" else "5223")) + SectionTextFooter( + if (smpWebPortServers.value == SMPWebPortServers.Preset) stringResource(MR.strings.network_smp_web_port_preset_footer) + else String.format(stringResource(MR.strings.network_smp_web_port_footer), if (smpWebPortServers.value == SMPWebPortServers.All) "443" else "5223") + ) SectionDividerSpaced(maxTopPadding = true) SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { @@ -320,7 +326,7 @@ private fun SMPProxyModePicker( ) { val density = LocalDensity.current val values = remember { - SMPProxyMode.values().map { + SMPProxyMode.entries.map { when (it) { SMPProxyMode.Always -> ValueTitleDesc(SMPProxyMode.Always, generalGetString(MR.strings.network_smp_proxy_mode_always), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_always_description), density)) SMPProxyMode.Unknown -> ValueTitleDesc(SMPProxyMode.Unknown, generalGetString(MR.strings.network_smp_proxy_mode_unknown), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_unknown_description), density)) @@ -355,7 +361,7 @@ private fun SMPProxyFallbackPicker( ) { val density = LocalDensity.current val values = remember { - SMPProxyFallback.values().map { + SMPProxyFallback.entries.map { when (it) { SMPProxyFallback.Allow -> ValueTitleDesc(SMPProxyFallback.Allow, generalGetString(MR.strings.network_smp_proxy_fallback_allow), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_description), density)) SMPProxyFallback.AllowProtected -> ValueTitleDesc(SMPProxyFallback.AllowProtected, generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected_description), density)) @@ -548,7 +554,7 @@ fun PreviewAdvancedNetworkSettingsLayout() { sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) }, smpProxyFallback = remember { mutableStateOf(SMPProxyFallback.Allow) }, - smpWebPort = remember { mutableStateOf(false) }, + smpWebPortServers = remember { mutableStateOf(SMPWebPortServers.Preset) }, networkTCPConnectTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeoutPerKb = remember { mutableStateOf(10_000) }, 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..1bea4c18d4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -964,6 +964,10 @@ TCP port for messaging Use web port Use TCP port %1$s when no port is specified. + Use TCP port 443 for preset servers only. + All servers + Preset servers + Off Appearance Customize theme INTERFACE COLORS 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 dafa22dfa9..97742f82a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -2440,6 +2440,10 @@ Пожаловаться: увидят только модераторы группы. Выключить уведомления для всех Использовать TCP-порт %1$s, когда порт не указан. + Использовать TCP-порт 443 только для серверов по умолчанию. + Все серверы + Серверы по умолчанию + Нет Использовать веб-порт Нет Пожаловаться на сообщение: увидят только модераторы группы. diff --git a/cabal.project b/cabal.project index 28403c9fff..4a95ae3cfe 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: ec5a60430d6e2b1b4f33fa1790effbb6060bf7b8 + tag: 08b84deba458407ae97d55debd98b872cb6c4d79 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index e71a13b22a..c945d72656 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ec5a60430d6e2b1b4f33fa1790effbb6060bf7b8" = "0q238j7w976f9nx7r3gd61yhj557zwcxvrbci5lq7fib0v4ja7aw"; + "https://github.com/simplex-chat/simplexmq.git"."08b84deba458407ae97d55debd98b872cb6c4d79" = "0b4n7d81spl1r7zppr0lc40ls9m1i93g4l3hzg2996pi3bxmafrr"; "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/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 02a765bb19..6b554d29c4 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -89,6 +89,7 @@ defaultChatConfig = -- to have a different set of servers on the receiving end and on the sending end. -- To preserve backward compatibility receiving end should update before the sending. shortLinkPresetServers = allPresetServers, + presetDomains = [".simplex.im", ".simplexonflux.com"], tbqSize = 1024, fileChunkSize = 15780, -- do not change xftpDescrPartSize = 14000, @@ -240,12 +241,12 @@ newChatController randomServerCfgs name p opDomains rndSrvs = toJustOrError name $ L.nonEmpty $ agentServerCfgs p opDomains $ concatMap (pServers p) rndSrvs agentServers :: DB.Connection -> ChatConfig -> NonEmpty PresetOperator -> RandomAgentServers -> IO InitialAgentServers - agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}} presetOps as = do + agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}, presetDomains} presetOps as = do users <- getUsers db ops <- getUpdateServerOperators db presetOps (null users) let opDomains = operatorDomains $ mapMaybe snd ops (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users - pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg} + pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains} where optServers :: [(UserId, NonEmpty (ServerCfg p))] -> [ProtoServerWithAuth p] -> [(UserId, NonEmpty (ServerCfg p))] optServers srvs overrides_ = case L.nonEmpty overrides_ of diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 21d5f1041b..bd4d418158 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -50,6 +50,7 @@ import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) +import Network.Socket (HostName) import Numeric.Natural import qualified Paths_simplex_chat as SC import Simplex.Chat.AppSettings @@ -77,7 +78,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction, withTransactionPriority) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, UpMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SocksMode (..)) +import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -139,6 +140,7 @@ data ChatConfig = ChatConfig confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, + presetDomains :: [HostName], tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -1053,7 +1055,7 @@ data SimpleNetCfg = SimpleNetCfg requiredHostMode :: Bool, smpProxyMode_ :: Maybe SMPProxyMode, smpProxyFallback_ :: Maybe SMPProxyFallback, - smpWebPort :: Bool, + smpWebPortServers :: SMPWebPortServers, tcpTimeout_ :: Maybe Int, logTLSErrors :: Bool } @@ -1068,7 +1070,7 @@ defaultSimpleNetCfg = requiredHostMode = False, smpProxyMode_ = Nothing, smpProxyFallback_ = Nothing, - smpWebPort = False, + smpWebPortServers = SWPPreset, tcpTimeout_ = Nothing, logTLSErrors = False } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 5c544e3397..d10959178c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -91,7 +91,7 @@ import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) -import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) +import Simplex.Messaging.Client (NetworkConfig (..), SMPWebPortServers (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -255,11 +255,11 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, atomically $ writeTVar files M.empty updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig -updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} = +updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPortServers, tcpTimeout_, logTLSErrors} = let cfg1 = maybe cfg (\smpProxyMode -> cfg {smpProxyMode}) smpProxyMode_ cfg2 = maybe cfg1 (\smpProxyFallback -> cfg1 {smpProxyFallback}) smpProxyFallback_ cfg3 = maybe cfg2 (\tcpTimeout -> cfg2 {tcpTimeout, tcpConnectTimeout = (tcpTimeout * 3) `div` 2}) tcpTimeout_ - in cfg3 {socksProxy, socksMode, hostMode, requiredHostMode, smpWebPort, logTLSErrors} + in cfg3 {socksProxy, socksMode, hostMode, requiredHostMode, smpWebPortServers, logTLSErrors} useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)) useServers as opDomains uss = @@ -4383,11 +4383,11 @@ chatCommandP = requiredHostMode <- (" required-host-mode" $> True) <|> pure False smpProxyMode_ <- optional $ " smp-proxy=" *> strP smpProxyFallback_ <- optional $ " smp-proxy-fallback=" *> strP - smpWebPort <- (" smp-web-port" $> True) <|> pure False + smpWebPortServers <- (" smp-web-port-servers=" *> strP) <|> (" smp-web-port" $> SWPAll) <|> pure SWPPreset t_ <- optional $ " timeout=" *> A.decimal logTLSErrors <- " log=" *> onOffP <|> pure False let tcpTimeout_ = (1000000 *) <$> t_ - pure $ SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} + pure $ SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPortServers, tcpTimeout_, logTLSErrors} #if !defined(dbPostgres) dbKeyP = nonEmptyKey <$?> strP nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index 4aa0903d3c..06c2e19fab 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -46,7 +46,7 @@ operatorFlux = -- so that option used for restoring links is updated earlier, for backward/forward compatibility. allPresetServers :: NonEmpty SMPServer allPresetServers = enabledSimplexChatSMPServers <> disabledSimplexChatSMPServers <> fluxSMPServers_ - -- TODO [short links] remove, added for testing + -- added for testing, not preset in the clients <> ["smp://8Af90NX2TTkKEJAF1RCg69P_Odg2Z-6_J6DOKUqK3rQ=@smp7.simplex.im,dbxqutskmmbkbrs7ofi7pmopeyhgi5cxbjbh4ummgmep4r6bz4cbrcid.onion"] simplexChatSMPServers :: [NewUserServer 'PSMP] @@ -87,7 +87,7 @@ disabledSimplexChatSMPServers = ] fluxSMPServers :: [NewUserServer 'PSMP] -fluxSMPServers = map (presetServer' True) $ L.toList fluxSMPServers_ +fluxSMPServers = map (presetServer' True) (L.toList fluxSMPServers_) -- Please note: if any servers are removed from this list, they MUST be added to allPresetServers. -- Otherwise previously created short links won't work. diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index de657a9d74..ff2af56b85 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -29,7 +29,7 @@ import Numeric.Natural (Natural) import Options.Applicative import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), updateStr, versionNumber, versionString) import Simplex.FileTransfer.Description (mb) -import Simplex.Messaging.Client (HostMode (..), SocksMode (..), textToHostMode) +import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth) @@ -153,11 +153,17 @@ coreChatOptsP appDir defaultDbName = do <> metavar "SMP_PROXY_FALLBACK_MODE" <> help "Allow downgrade and connect directly: no, [when IP address is] protected (default), yes" ) - smpWebPort <- - switch + smpWebPortServers <- + flag' SWPAll ( long "smp-web-port" <> help "Use port 443 with SMP servers when not specified" ) + <|> option + strParse + ( long "smp-web-port-servers" + <> help "Use port 443 with SMP servers when not specified: all, preset (default), off" + <> value SWPPreset + ) t <- option auto @@ -243,7 +249,7 @@ coreChatOptsP appDir defaultDbName = do requiredHostMode, smpProxyMode_, smpProxyFallback_, - smpWebPort, + smpWebPortServers, tcpTimeout_ = Just $ useTcpTimeout socksProxy t, logTLSErrors }, From 63902633705faf595edae6ccc658de424a5b45f4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 26 Apr 2025 16:02:29 +0100 Subject: [PATCH 196/289] android, desktop: additional information about database errors in console (#5853) --- .../chat/simplex/common/model/SimpleXAPI.kt | 134 +++++++++++------- 1 file changed, 82 insertions(+), 52 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index bfb587820a..24df07a052 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 @@ -6744,64 +6744,79 @@ sealed class StoreError { val string: String get() = when (this) { is DuplicateName -> "duplicateName" - is UserNotFound -> "userNotFound" - is UserNotFoundByName -> "userNotFoundByName" - is UserNotFoundByContactId -> "userNotFoundByContactId" - is UserNotFoundByGroupId -> "userNotFoundByGroupId" - is UserNotFoundByFileId -> "userNotFoundByFileId" - is UserNotFoundByContactRequestId -> "userNotFoundByContactRequestId" - is ContactNotFound -> "contactNotFound" - is ContactNotFoundByName -> "contactNotFoundByName" - is ContactNotFoundByMemberId -> "contactNotFoundByMemberId" - is ContactNotReady -> "contactNotReady" + is UserNotFound -> "userNotFound $userId" + is UserNotFoundByName -> "userNotFoundByName $contactName" + is UserNotFoundByContactId -> "userNotFoundByContactId $contactId" + is UserNotFoundByGroupId -> "userNotFoundByGroupId $groupId" + is UserNotFoundByFileId -> "userNotFoundByFileId $fileId" + is UserNotFoundByContactRequestId -> "userNotFoundByContactRequestId $contactRequestId" + is ContactNotFound -> "contactNotFound $contactId" + is ContactNotFoundByName -> "contactNotFoundByName $contactName" + is ContactNotFoundByMemberId -> "contactNotFoundByMemberId $groupMemberId" + is ContactNotReady -> "contactNotReady $contactName" is DuplicateContactLink -> "duplicateContactLink" is UserContactLinkNotFound -> "userContactLinkNotFound" - is ContactRequestNotFound -> "contactRequestNotFound" - is ContactRequestNotFoundByName -> "contactRequestNotFoundByName" - is GroupNotFound -> "groupNotFound" - is GroupNotFoundByName -> "groupNotFoundByName" - is GroupMemberNameNotFound -> "groupMemberNameNotFound" - is GroupMemberNotFound -> "groupMemberNotFound" - is GroupMemberNotFoundByMemberId -> "groupMemberNotFoundByMemberId" - is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound" + is ContactRequestNotFound -> "contactRequestNotFound $contactRequestId" + is ContactRequestNotFoundByName -> "contactRequestNotFoundByName $contactName" + is GroupNotFound -> "groupNotFound $groupId" + is GroupNotFoundByName -> "groupNotFoundByName $groupName" + is GroupMemberNameNotFound -> "groupMemberNameNotFound $groupId $groupMemberName" + is GroupMemberNotFound -> "groupMemberNotFound $groupMemberId" + is GroupMemberNotFoundByMemberId -> "groupMemberNotFoundByMemberId $memberId" + is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound $contactId" is GroupWithoutUser -> "groupWithoutUser" is DuplicateGroupMember -> "duplicateGroupMember" is GroupAlreadyJoined -> "groupAlreadyJoined" is GroupInvitationNotFound -> "groupInvitationNotFound" - is SndFileNotFound -> "sndFileNotFound" - is SndFileInvalid -> "sndFileInvalid" - is RcvFileNotFound -> "rcvFileNotFound" - is RcvFileDescrNotFound -> "rcvFileDescrNotFound" - is FileNotFound -> "fileNotFound" - is RcvFileInvalid -> "rcvFileInvalid" + is NoteFolderAlreadyExists -> "noteFolderAlreadyExists $noteFolderId" + is NoteFolderNotFound -> "noteFolderNotFound $noteFolderId" + is UserNoteFolderNotFound -> "userNoteFolderNotFound" + is SndFileNotFound -> "sndFileNotFound $fileId" + is SndFileInvalid -> "sndFileInvalid $fileId" + is RcvFileNotFound -> "rcvFileNotFound $fileId" + is RcvFileDescrNotFound -> "rcvFileDescrNotFound $fileId" + is FileNotFound -> "fileNotFound $fileId" + is RcvFileInvalid -> "rcvFileInvalid $fileId" is RcvFileInvalidDescrPart -> "rcvFileInvalidDescrPart" - is SharedMsgIdNotFoundByFileId -> "sharedMsgIdNotFoundByFileId" - is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId" - is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP" - is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP" - is ExtraFileDescrNotFoundXFTP -> "extraFileDescrNotFoundXFTP" - is ConnectionNotFound -> "connectionNotFound" - is ConnectionNotFoundById -> "connectionNotFoundById" - is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId" - is PendingConnectionNotFound -> "pendingConnectionNotFound" + is LocalFileNoTransfer -> "localFileNoTransfer $fileId" + is SharedMsgIdNotFoundByFileId -> "sharedMsgIdNotFoundByFileId $fileId" + is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId $sharedMsgId" + is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP $agentSndFileId" + is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP $agentRcvFileId" + is ConnectionNotFound -> "connectionNotFound $agentConnId" + is ConnectionNotFoundById -> "connectionNotFoundById $connId" + is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId $groupMemberId" + is PendingConnectionNotFound -> "pendingConnectionNotFound $connId" is IntroNotFound -> "introNotFound" is UniqueID -> "uniqueID" - is InternalError -> "internalError" - is NoMsgDelivery -> "noMsgDelivery" - is BadChatItem -> "badChatItem" - is ChatItemNotFound -> "chatItemNotFound" - is ChatItemNotFoundByText -> "chatItemNotFoundByText" - is ChatItemSharedMsgIdNotFound -> "chatItemSharedMsgIdNotFound" - is ChatItemNotFoundByFileId -> "chatItemNotFoundByFileId" - is ChatItemNotFoundByGroupId -> "chatItemNotFoundByGroupId" - is ProfileNotFound -> "profileNotFound" - is DuplicateGroupLink -> "duplicateGroupLink" - is GroupLinkNotFound -> "groupLinkNotFound" - is HostMemberIdNotFound -> "hostMemberIdNotFound" - is ContactNotFoundByFileId -> "contactNotFoundByFileId" - is NoGroupSndStatus -> "noGroupSndStatus" is LargeMsg -> "largeMsg" - is DBException -> "dBException" + is InternalError -> "internalError $message" + is DBException -> "dBException $message" + is DBBusyError -> "dBBusyError $message" + is BadChatItem -> "badChatItem $itemId" + is ChatItemNotFound -> "chatItemNotFound $itemId" + is ChatItemNotFoundByText -> "chatItemNotFoundByText $text" + is ChatItemSharedMsgIdNotFound -> "chatItemSharedMsgIdNotFound $sharedMsgId" + is ChatItemNotFoundByFileId -> "chatItemNotFoundByFileId $fileId" + is ChatItemNotFoundByContactId -> "chatItemNotFoundByContactId $contactId" + is ChatItemNotFoundByGroupId -> "chatItemNotFoundByGroupId $groupId" + is ProfileNotFound -> "profileNotFound $profileId" + is DuplicateGroupLink -> "duplicateGroupLink ${groupInfo.groupId}" + is GroupLinkNotFound -> "groupLinkNotFound ${groupInfo.groupId}" + is HostMemberIdNotFound -> "hostMemberIdNotFound $groupId" + is ContactNotFoundByFileId -> "contactNotFoundByFileId $fileId" + is NoGroupSndStatus -> "noGroupSndStatus $itemId $groupMemberId" + is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" + is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" + is RemoteHostUnknown -> "remoteHostUnknown" + is RemoteHostDuplicateCA -> "remoteHostDuplicateCA" + is RemoteCtrlNotFound -> "remoteCtrlNotFound $remoteCtrlId" + is RemoteCtrlDuplicateCA -> "remoteCtrlDuplicateCA" + is ProhibitedDeleteUser -> "prohibitedDeleteUser $userId $contactId" + is OperatorNotFound -> "operatorNotFound $serverOperatorId" + is UsageConditionsNotFound -> "usageConditionsNotFound" + is InvalidQuote -> "invalidQuote" + is InvalidMention -> "invalidMention" } @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @@ -6829,6 +6844,9 @@ sealed class StoreError { @Serializable @SerialName("duplicateGroupMember") object DuplicateGroupMember: StoreError() @Serializable @SerialName("groupAlreadyJoined") object GroupAlreadyJoined: StoreError() @Serializable @SerialName("groupInvitationNotFound") object GroupInvitationNotFound: StoreError() + @Serializable @SerialName("noteFolderAlreadyExists") class NoteFolderAlreadyExists(val noteFolderId: Long): StoreError() + @Serializable @SerialName("noteFolderNotFound") class NoteFolderNotFound(val noteFolderId: Long): StoreError() + @Serializable @SerialName("userNoteFolderNotFound") object UserNoteFolderNotFound: StoreError() @Serializable @SerialName("sndFileNotFound") class SndFileNotFound(val fileId: Long): StoreError() @Serializable @SerialName("sndFileInvalid") class SndFileInvalid(val fileId: Long): StoreError() @Serializable @SerialName("rcvFileNotFound") class RcvFileNotFound(val fileId: Long): StoreError() @@ -6836,24 +6854,27 @@ sealed class StoreError { @Serializable @SerialName("fileNotFound") class FileNotFound(val fileId: Long): StoreError() @Serializable @SerialName("rcvFileInvalid") class RcvFileInvalid(val fileId: Long): StoreError() @Serializable @SerialName("rcvFileInvalidDescrPart") object RcvFileInvalidDescrPart: StoreError() + @Serializable @SerialName("localFileNoTransfer") class LocalFileNoTransfer(val fileId: Long): StoreError() @Serializable @SerialName("sharedMsgIdNotFoundByFileId") class SharedMsgIdNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("fileIdNotFoundBySharedMsgId") class FileIdNotFoundBySharedMsgId(val sharedMsgId: String): StoreError() @Serializable @SerialName("sndFileNotFoundXFTP") class SndFileNotFoundXFTP(val agentSndFileId: String): StoreError() @Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError() - @Serializable @SerialName("extraFileDescrNotFoundXFTP") class ExtraFileDescrNotFoundXFTP(val fileId: Long): StoreError() @Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError() @Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError() @Serializable @SerialName("connectionNotFoundByMemberId") class ConnectionNotFoundByMemberId(val groupMemberId: Long): StoreError() @Serializable @SerialName("pendingConnectionNotFound") class PendingConnectionNotFound(val connId: Long): StoreError() @Serializable @SerialName("introNotFound") object IntroNotFound: StoreError() @Serializable @SerialName("uniqueID") object UniqueID: StoreError() + @Serializable @SerialName("largeMsg") object LargeMsg: StoreError() @Serializable @SerialName("internalError") class InternalError(val message: String): StoreError() - @Serializable @SerialName("noMsgDelivery") class NoMsgDelivery(val connId: Long, val agentMsgId: String): StoreError() + @Serializable @SerialName("dBException") class DBException(val message: String): StoreError() + @Serializable @SerialName("dBBusyError") class DBBusyError(val message: String): StoreError() @Serializable @SerialName("badChatItem") class BadChatItem(val itemId: Long): StoreError() @Serializable @SerialName("chatItemNotFound") class ChatItemNotFound(val itemId: Long): StoreError() @Serializable @SerialName("chatItemNotFoundByText") class ChatItemNotFoundByText(val text: String): StoreError() @Serializable @SerialName("chatItemSharedMsgIdNotFound") class ChatItemSharedMsgIdNotFound(val sharedMsgId: String): StoreError() @Serializable @SerialName("chatItemNotFoundByFileId") class ChatItemNotFoundByFileId(val fileId: Long): StoreError() + @Serializable @SerialName("chatItemNotFoundByContactId") class ChatItemNotFoundByContactId(val contactId: Long): StoreError() @Serializable @SerialName("chatItemNotFoundByGroupId") class ChatItemNotFoundByGroupId(val groupId: Long): StoreError() @Serializable @SerialName("profileNotFound") class ProfileNotFound(val profileId: Long): StoreError() @Serializable @SerialName("duplicateGroupLink") class DuplicateGroupLink(val groupInfo: GroupInfo): StoreError() @@ -6861,8 +6882,17 @@ sealed class StoreError { @Serializable @SerialName("hostMemberIdNotFound") class HostMemberIdNotFound(val groupId: Long): StoreError() @Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError() - @Serializable @SerialName("largeMsg") object LargeMsg: StoreError() - @Serializable @SerialName("dBException") class DBException(val message: String): StoreError() + @Serializable @SerialName("duplicateGroupMessage") class DuplicateGroupMessage(val groupId: Long, val sharedMsgId: String, val authorGroupMemberId: Long?, val forwardedByGroupMemberId: Long?): StoreError() + @Serializable @SerialName("remoteHostNotFound") class RemoteHostNotFound(val remoteHostId: Long): StoreError() + @Serializable @SerialName("remoteHostUnknown") object RemoteHostUnknown: StoreError() + @Serializable @SerialName("remoteHostDuplicateCA") object RemoteHostDuplicateCA: StoreError() + @Serializable @SerialName("remoteCtrlNotFound") class RemoteCtrlNotFound(val remoteCtrlId: Long): StoreError() + @Serializable @SerialName("remoteCtrlDuplicateCA") class RemoteCtrlDuplicateCA: StoreError() + @Serializable @SerialName("prohibitedDeleteUser") class ProhibitedDeleteUser(val userId: Long, val contactId: Long): StoreError() + @Serializable @SerialName("operatorNotFound") class OperatorNotFound(val serverOperatorId: Long): StoreError() + @Serializable @SerialName("usageConditionsNotFound") object UsageConditionsNotFound: StoreError() + @Serializable @SerialName("invalidQuote") object InvalidQuote: StoreError() + @Serializable @SerialName("invalidMention") object InvalidMention: StoreError() } @Serializable From 7cac164b842c774a399b65f07bd5964b2e6552d5 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 27 Apr 2025 12:38:51 +0100 Subject: [PATCH 197/289] core: use /feed command in broadcast bot (#5854) --- apps/simplex-broadcast-bot/src/Broadcast/Bot.hs | 9 ++++++--- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 5 ++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 9dc927af9e..15f790e8b1 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -2,6 +2,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module Broadcast.Bot where @@ -10,6 +11,7 @@ import Control.Concurrent (forkIO) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Text as T import Broadcast.Options import Simplex.Chat.Bot @@ -47,9 +49,10 @@ broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _u then do sendChatCmd cc ListContacts >>= \case CRContactsList _ cts -> void . forkIO $ do - let cts' = filter broadcastTo cts - forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc - sendReply $ "Forwarded to " <> tshow (length cts') <> " contact(s)" + sendChatCmd cc (SendMessageBroadcast mc) >>= \case + CRBroadcastSent {successes, failures} -> + sendReply $ "Forwarded to " <> tshow successes <> " contact(s), " <> tshow failures <> " errors" + r -> putStrLn $ "Error broadcasting message: " <> show r r -> putStrLn $ "Error getting contacts list: " <> show r else sendReply "!1 Message is not supported!" | otherwise -> do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bd4d418158..043db9bfc9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -472,7 +472,7 @@ data ChatCommand | SendMemberContactMessage GroupName ContactName Text | SendLiveMessage ChatName Text | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: Text, message :: Text} - | SendMessageBroadcast Text -- UserId (not used in UI) + | SendMessageBroadcast MsgContent -- UserId (not used in UI) | DeleteMessage ChatName Text | DeleteMemberMessage GroupName ContactName Text | EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d10959178c..11ea3a1f93 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1888,7 +1888,7 @@ processChatCommand' vr = \case withSendRef chatRef $ \sendRef -> do let mc = MCText msg processChatCommand $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] - SendMessageBroadcast msg -> withUser $ \user -> do + SendMessageBroadcast mc -> withUser $ \user -> do contacts <- withFastStore' $ \db -> getUserContacts db vr user withChatLock "sendMessageBroadcast" . procCmd $ do let ctConns_ = L.nonEmpty $ foldr addContactConn [] contacts @@ -1912,7 +1912,6 @@ processChatCommand' vr = \case lift . void $ withStoreBatch' $ \db -> map (createCI db user timestamp) ctSndMsgs pure CRBroadcastSent {user, msgContent = mc, successes = length ctSndMsgs, failures = length errs, timestamp} where - mc = MCText msg addContactConn :: Contact -> [(Contact, Connection)] -> [(Contact, Connection)] addContactConn ct ctConns = case contactSendConn_ ct of Right conn | directOrUsed ct -> (ct, conn) : ctConns @@ -4150,7 +4149,7 @@ chatCommandP = ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> textP), ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> msgTextP), ReactToMessage <$> (("+" $> True) <|> ("-" $> False)) <*> reactionP <* A.space <*> chatNameP' <* A.space <*> textP, - "/feed " *> (SendMessageBroadcast <$> msgTextP), + "/feed " *> (SendMessageBroadcast . MCText <$> msgTextP), ("/chats" <|> "/cs") *> (LastChats <$> (" all" $> Nothing <|> Just <$> (A.space *> A.decimal <|> pure 20))), ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing), ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> stringP))), From ca49167ec64988aad30f0b9922e9a7bf598f05ad Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 27 Apr 2025 15:55:49 +0100 Subject: [PATCH 198/289] directory service: fix deleting group registration (#5856) --- apps/simplex-directory-service/src/Directory/Service.hs | 5 ----- apps/simplex-directory-service/src/Directory/Store.hs | 2 +- tests/Bots/DirectoryTests.hs | 6 ++++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index c5c41e39be..fc9ac24e71 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -699,11 +699,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DCListUserGroups -> getUserGroupRegs st (contactId' ct) >>= \grs -> do sendReply $ tshow (length grs) <> " registered group(s)" - -- debug how it can be that user has 0 registered groups - when (length grs == 0) $ do - total <- length <$> readTVarIO (groupRegs st) - withSuperUsers $ \ctId -> sendMessage' cc ctId $ - "0 registered groups for " <> localDisplayName' ct <> " (" <> tshow (contactId' ct) <> ") out of " <> tshow total <> " registrations" void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} -> sendGroupInfo ct gr userGroupRegId Nothing DCDeleteGroup gId gName -> diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 031d05fd49..628419ea1d 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -212,7 +212,7 @@ delGroupReg st GroupReg {dbGroupId = gId, groupRegStatus} = do logGDelete st gId atomically $ writeTVar groupRegStatus GRSRemoved atomically $ unlistGroup st gId - atomically $ modifyTVar' (groupRegs st) $ filter ((gId ==) . dbGroupId) + atomically $ modifyTVar' (groupRegs st) $ filter ((gId /=) . dbGroupId) setGroupStatus :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO () setGroupStatus st gr grStatus = do diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 92d70727e3..0877a48daa 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -299,10 +299,12 @@ testDeleteGroupAdmin ps = superUser #> "@SimpleX-Directory /delete 2:security" superUser <# "SimpleX-Directory> > /delete 2:security" superUser <## " The group security is deleted from the directory" - groupFound bob "privacy" + groupFound cath "privacy" + listUserGroup bob "privacy" "Privacy" groupNotFound bob "security" + sendListCommand cath 0 -- another user can register the group with the same name - registerGroupId superUser bob "security" "Security" 4 1 + registerGroupId superUser bob "security" "Security" 4 2 testSetRole :: HasCallStack => TestParams -> IO () testSetRole ps = 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 199/289] 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 200/289] 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 201/289] 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 38b8e0cee69ec1c3b02fb9887feebe3611237687 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:27:19 +0000 Subject: [PATCH 202/289] ios: refactor chat state (remove chatItemsChangesListener) (#5858) --- apps/ios/Shared/Model/ChatModel.swift | 14 +- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- .../Shared/Views/Chat/ChatItemsMerger.swift | 193 +++++++++--------- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/LocalAuth/LocalAuthView.swift | 2 +- 5 files changed, 104 insertions(+), 109 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 00260cc05e..c73cb32c58 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,9 +54,7 @@ class ItemsModel: ObservableObject { willSet { publisher.send() } } - // set listener here that will be notified on every add/delete of a chat item let chatState = ActiveChatState() - var chatItemsChangesListener: RecalculatePositions = RecalculatePositions() // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @@ -573,7 +571,7 @@ final class ChatModel: ObservableObject { ci.meta.itemStatus = status } im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) - im.chatItemsChangesListener.added((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) + im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) im.itemAdded = true ChatItemDummyModel.shared.sendUpdate() return true @@ -621,7 +619,7 @@ final class ChatModel: ObservableObject { if let i = getChatItemIndex(cItem) { withAnimation { let item = im.reversedChatItems.remove(at: i) - im.chatItemsChangesListener.removed([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) + im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) } } } @@ -709,7 +707,7 @@ final class ChatModel: ObservableObject { let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { im.reversedChatItems.insert(cItem, at: 0) - im.chatItemsChangesListener.added((cItem.id, cItem.isRcvNew), 0) + im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0) im.itemAdded = true } return cItem @@ -743,7 +741,7 @@ final class ChatModel: ObservableObject { markChatItemRead_(i) i += 1 } - im.chatItemsChangesListener.read(nil, im.reversedChatItems.reversed()) + im.chatState.itemsRead(nil, im.reversedChatItems.reversed()) } } func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { @@ -767,7 +765,7 @@ final class ChatModel: ObservableObject { if chatId == cInfo.id { chatItemStatuses = [:] im.reversedChatItems = [] - im.chatItemsChangesListener.cleared() + im.chatState.clear() } } @@ -785,7 +783,7 @@ final class ChatModel: ObservableObject { } i += 1 } - im.chatItemsChangesListener.read(unreadItemIds, im.reversedChatItems.reversed()) + im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed()) } self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2de818abb2..22d004f4d9 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -345,7 +345,7 @@ func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID m.chatItemStatuses = [:] if clearItems { im.reversedChatItems = [] - ItemsModel.shared.chatItemsChangesListener.cleared() + ItemsModel.shared.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 }) diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 2e9dac166a..0a55ed48cc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -321,6 +321,101 @@ class ActiveChatState { unreadAfter = 0 unreadAfterNewestLoaded = 0 } + + func itemsRead(_ itemIds: Set?, _ newItems: [ChatItem]) { + guard let itemIds else { + // special case when the whole chat became read + unreadTotal = 0 + unreadAfter = 0 + return + } + var unreadAfterItemIndex: Int = -1 + // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster + var i = newItems.count - 1 + var ids = itemIds + // intermediate variables to prevent re-setting state value a lot of times without reason + var newUnreadTotal = unreadTotal + var newUnreadAfter = unreadAfter + while i >= 0 { + let item = newItems[i] + if item.id == unreadAfterItemId { + unreadAfterItemIndex = i + } + if ids.contains(item.id) { + // was unread, now this item is read + if (unreadAfterItemIndex == -1) { + newUnreadAfter -= 1 + } + newUnreadTotal -= 1 + ids.remove(item.id) + if ids.isEmpty { + break + } + } + i -= 1 + } + unreadTotal = newUnreadTotal + unreadAfter = newUnreadAfter + } + + func itemAdded(_ item: (Int64, Bool), _ index: Int) { + if item.1 { + unreadAfter += 1 + unreadTotal += 1 + } + } + + func itemsRemoved(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) { + var newSplits: [Int64] = [] + for split in splits { + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split }) + // deleted the item that was right before the split between items, find newer item so it will act like the split + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + // it the whole section is gone and splits overlap, don't add it at all + if let newSplit, !newSplits.contains(newSplit) { + newSplits.append(newSplit) + } + } else { + newSplits.append(split) + } + } + splits = newSplits + + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == unreadAfterItemId }) + // unread after item was removed + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil + if newUnreadAfterItemId == nil { + // everything on top (including unread after item) were deleted, take top item as unread after id + newUnreadAfterItemId = newItems.first?.id + } + if let newUnreadAfterItemId { + unreadAfterItemId = newUnreadAfterItemId + totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count + unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count + unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count + if newUnreadAfterItemWasNull { + // since the unread after item was moved one item after initial position, adjust counters accordingly + if newItems.first?.isRcvNew == true { + unreadTotal += 1 + unreadAfter -= 1 + } + } + } else { + // all items were deleted, 0 items in chatItems + unreadAfterItemId = -1 + totalAfter = 0 + unreadTotal = 0 + unreadAfter = 0 + } + } else { + totalAfter -= itemIds.count + } + } } class BoxedValue: Equatable, Hashable { @@ -359,101 +454,3 @@ func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.Li // visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed() return range } - -class RecalculatePositions { - private var chatState: ActiveChatState { get { ItemsModel.shared.chatState } } - - func read(_ itemIds: Set?, _ newItems: [ChatItem]) { - guard let itemIds else { - // special case when the whole chat became read - chatState.unreadTotal = 0 - chatState.unreadAfter = 0 - return - } - var unreadAfterItemIndex: Int = -1 - // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster - var i = newItems.count - 1 - var ids = itemIds - // intermediate variables to prevent re-setting state value a lot of times without reason - var newUnreadTotal = chatState.unreadTotal - var newUnreadAfter = chatState.unreadAfter - while i >= 0 { - let item = newItems[i] - if item.id == chatState.unreadAfterItemId { - unreadAfterItemIndex = i - } - if ids.contains(item.id) { - // was unread, now this item is read - if (unreadAfterItemIndex == -1) { - newUnreadAfter -= 1 - } - newUnreadTotal -= 1 - ids.remove(item.id) - if ids.isEmpty { - break - } - } - i -= 1 - } - chatState.unreadTotal = newUnreadTotal - chatState.unreadAfter = newUnreadAfter - } - func added(_ item: (Int64, Bool), _ index: Int) { - if item.1 { - chatState.unreadAfter += 1 - chatState.unreadTotal += 1 - } - } - func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) { - var newSplits: [Int64] = [] - for split in chatState.splits { - let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split }) - // deleted the item that was right before the split between items, find newer item so it will act like the split - if let index { - let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count - let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil - // it the whole section is gone and splits overlap, don't add it at all - if let newSplit, !newSplits.contains(newSplit) { - newSplits.append(newSplit) - } - } else { - newSplits.append(split) - } - } - chatState.splits = newSplits - - let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId }) - // unread after item was removed - if let index { - let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count - var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil - let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil - if newUnreadAfterItemId == nil { - // everything on top (including unread after item) were deleted, take top item as unread after id - newUnreadAfterItemId = newItems.first?.id - } - if let newUnreadAfterItemId { - chatState.unreadAfterItemId = newUnreadAfterItemId - chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count - chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count - chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count - if newUnreadAfterItemWasNull { - // since the unread after item was moved one item after initial position, adjust counters accordingly - if newItems.first?.isRcvNew == true { - chatState.unreadTotal += 1 - chatState.unreadAfter -= 1 - } - } - } else { - // all items were deleted, 0 items in chatItems - chatState.unreadAfterItemId = -1 - chatState.totalAfter = 0 - chatState.unreadTotal = 0 - chatState.unreadAfter = 0 - } - } else { - chatState.totalAfter -= itemIds.count - } - } - func cleared() { chatState.clear() } -} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index eae28b76be..1349996683 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -281,7 +281,7 @@ struct ChatView: View { if chatModel.chatId == nil { chatModel.chatItemStatuses = [:] ItemsModel.shared.reversedChatItems = [] - ItemsModel.shared.chatItemsChangesListener.cleared() + ItemsModel.shared.chatState.clear() chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 7c16e22571..16ab26eff7 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -65,7 +65,7 @@ struct LocalAuthView: View { // Clear sensitive data on screen just in case app fails to hide its views while new database is created m.chatId = nil ItemsModel.shared.reversedChatItems = [] - ItemsModel.shared.chatItemsChangesListener.cleared() + ItemsModel.shared.chatState.clear() m.updateChats([]) m.users = [] _ = kcAppPassword.set(password) From e7a4611be9cc04988e9779e631c49d500dcb15b3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 2 May 2025 12:23:05 +0100 Subject: [PATCH 203/289] ios: deliver notifications even if iOS fails to fire expiration notice, prevent repeat delivery of stale notifications (#5861) * ios: deliver notification when iOS fails to fire expiration notice for NSE * update core api * update ui * sha256map.nix * do not enable background processes in maintenance mode * fix ios * fix parser * ios: fix command * compatible parser for connection ID * log * pass DB queue ID * simplexmq * query plans * fix broadcast bot test --- .../ios/SimpleX NSE/NotificationService.swift | 201 ++++++++++-------- apps/ios/SimpleX SE/ShareModel.swift | 2 +- apps/ios/SimpleXChat/APITypes.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 40 +++- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 12 +- src/Simplex/Chat/Core.hs | 5 +- src/Simplex/Chat/Library/Commands.hs | 35 ++- .../SQLite/Migrations/agent_query_plans.txt | 4 +- src/Simplex/Chat/View.hs | 4 +- tests/Bots/BroadcastTests.hs | 3 +- 12 files changed, 179 insertions(+), 135 deletions(-) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index ba94463d61..c82869a36a 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -143,8 +143,7 @@ class NSEThreads { } struct ExpectedMessage { - var ntfConn: UserNtfConn - var receiveConnId: String? + var ntfConn: NtfConn var expectedMsgId: String? var allowedGetNextAttempts: Int var msgBestAttemptNtf: NSENotificationData? @@ -152,6 +151,14 @@ struct ExpectedMessage { var shouldProcessNtf: Bool var startedProcessingNewMsgs: Bool var semaphore: DispatchSemaphore + + var connMsgReq: ConnMsgReq? { + if let expectedMsg_ = ntfConn.expectedMsg_ { + ConnMsgReq(msgConnId: ntfConn.agentConnId, msgDbQueueId: ntfConn.agentDbQueueId, msgTs: expectedMsg_.msgTs) + } else { + nil + } + } } // Notification service extension creates a new instance of the class and calls didReceive for each notification. @@ -172,20 +179,27 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") - let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } - setServiceBestAttemptNtf(ntf) + let receivedNtf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } + setServiceBestAttemptNtf(receivedNtf) self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() logger.debug("NotificationService: app is \(appState.rawValue)") switch appState { case .stopped: +// Use this block to debug notificaitons delivery in CLI, with "ejected" database and stopped chat +// if let nrData = ntfRequestData(request) { +// logger.debug("NotificationService get notification connections: /_ntf conns \(nrData.nonce) \(nrData.encNtfInfo)") +// contentHandler(receivedNtf) +// return; +// } setBadgeCount() - setServiceBestAttemptNtf(createAppStoppedNtf(badgeCount)) - deliverBestAttemptNtf() + contentHandler(createAppStoppedNtf(badgeCount)) case .suspended: - receiveNtfMessages(request, contentHandler) + setExpirationTimer() + receiveNtfMessages(request) case .suspending: + setExpirationTimer() Task { let state: AppState = await withCheckedContinuation { cont in appSubscriber = appStateSubscriber { s in @@ -206,42 +220,55 @@ class NotificationService: UNNotificationServiceExtension { } } logger.debug("NotificationService: app state is now \(state.rawValue)") - if state.inactive { - receiveNtfMessages(request, contentHandler) + if state.inactive && self.contentHandler != nil { + receiveNtfMessages(request) } else { - deliverBestAttemptNtf() + contentHandler(receivedNtf) } } - case .active: contentHandler(UNMutableNotificationContent()) - case .activating: contentHandler(UNMutableNotificationContent()) - case .bgRefresh: contentHandler(UNMutableNotificationContent()) + case .active: contentHandler(receivedNtf) + case .activating: contentHandler(receivedNtf) + case .bgRefresh: contentHandler(receivedNtf) } } - func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { + private func setExpirationTimer() -> Void { + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { + self.deliverBestAttemptNtf(urgent: true) + } + } + + private func ntfRequestData(_ request: UNNotificationRequest) -> (nonce: String, encNtfInfo: String)? { + if let ntfData = request.content.userInfo["notificationData"] as? [AnyHashable : Any], + let nonce = ntfData["nonce"] as? String, + let encNtfInfo = ntfData["message"] as? String { + (nonce, encNtfInfo) + } else { + nil + } + } + + func receiveNtfMessages(_ request: UNNotificationRequest) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { deliverBestAttemptNtf() return } - let userInfo = request.content.userInfo - if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], - let nonce = ntfData["nonce"] as? String, - let encNtfInfo = ntfData["message"] as? String, + if let nrData = ntfRequestData(request), // check it here again appStateGroupDefault.get().inactive { // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended if let t = threadId { NSEThreads.shared.startThread(t, self) } let dbStatus = startChat() if case .ok = dbStatus, - let ntfConns = apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo) { + let ntfConns = apiGetNtfConns(nonce: nrData.nonce, encNtfInfo: nrData.encNtfInfo) { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns count = \(ntfConns.count)") - +// logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns \(String(describing: ntfConns.map { $0.connEntity.id }))") for ntfConn in ntfConns { addExpectedMessage(ntfConn: ntfConn) } - let connIdsToGet = expectedMessages.compactMap { (id, _) in + let connMsgReqs = expectedMessages.compactMap { (id, _) in let started = NSEThreads.queue.sync { let canStart = checkCanStart(id) if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: can start: \(canStart)") } @@ -253,7 +280,7 @@ class NotificationService: UNNotificationServiceExtension { return canStart } if started { - return expectedMessages[id]?.receiveConnId + return expectedMessages[id]?.connMsgReq } else { if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") } expectedMessages[id]?.semaphore.wait() @@ -264,17 +291,17 @@ class NotificationService: UNNotificationServiceExtension { expectedMessages[id]?.startedProcessingNewMsgs = true expectedMessages[id]?.shouldProcessNtf = true } - if let connId = expectedMessages[id]?.receiveConnId { - let _ = getConnNtfMessage(connId: connId) + if let connMsgReq = expectedMessages[id]?.connMsgReq { + let _ = getConnNtfMessage(connMsgReq: connMsgReq) } } return nil } } - if !connIdsToGet.isEmpty { - if let r = apiGetConnNtfMessages(connIds: connIdsToGet) { - logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count)") + if !connMsgReqs.isEmpty { + if let r = apiGetConnNtfMessages(connMsgReqs: connMsgReqs) { + logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count), expecting messages \(r.count { $0 != nil })") } return } @@ -285,18 +312,16 @@ class NotificationService: UNNotificationServiceExtension { deliverBestAttemptNtf() } - func addExpectedMessage(ntfConn: UserNtfConn) { - if let connEntity = ntfConn.connEntity_, - let receiveEntityId = connEntity.id, ntfConn.expectedMsg_ != nil { - let expectedMsgId = ntfConn.expectedMsg_?.msgId + func addExpectedMessage(ntfConn: NtfConn) { + let expectedMsgId = ntfConn.expectedMsg_?.msgId + if let receiveEntityId = ntfConn.connEntity.id { logger.debug("NotificationService: addExpectedMessage: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private)") expectedMessages[receiveEntityId] = ExpectedMessage( ntfConn: ntfConn, - receiveConnId: connEntity.conn.agentConnId, expectedMsgId: expectedMsgId, allowedGetNextAttempts: 3, - msgBestAttemptNtf: ntfConn.defaultBestAttemptNtf, - ready: false, + msgBestAttemptNtf: defaultBestAttemptNtf(ntfConn), + ready: ntfConn.expectedMsg_ == nil, // show defaultBestAttemptNtf(ntfConn) if there is no expected message shouldProcessNtf: false, startedProcessingNewMsgs: false, semaphore: DispatchSemaphore(value: 0) @@ -350,10 +375,10 @@ class NotificationService: UNNotificationServiceExtension { NSEThreads.shared.droppedNotifications.append((id, ntf)) if signalReady { entityReady(id) } self.deliverBestAttemptNtf() - } else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let receiveConnId = expectedMessages[id]?.receiveConnId { + } else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let connMsgReq = expectedMessages[id]?.connMsgReq { logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, get next message") expectedMessages[id]?.allowedGetNextAttempts -= 1 - if let receivedMsg = getConnNtfMessage(connId: receiveConnId) { + if let receivedMsg = getConnNtfMessage(connMsgReq: connMsgReq) { logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)") } else { logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt") @@ -373,13 +398,9 @@ class NotificationService: UNNotificationServiceExtension { setBadgeCount() } let prevBestAttempt = expectedMessages[id]?.msgBestAttemptNtf - if prevBestAttempt?.callInvitation != nil { - if ntf.callInvitation != nil { // replace with newer call - expectedMessages[id]?.msgBestAttemptNtf = ntf - } // otherwise keep call as best attempt - } else { + if prevBestAttempt?.callInvitation == nil || ntf.callInvitation != nil { expectedMessages[id]?.msgBestAttemptNtf = ntf - } + } // otherwise keep call as best attempt } else { NSEThreads.shared.droppedNotifications.append((id, ntf)) if signalReady { entityReady(id) } @@ -406,7 +427,11 @@ class NotificationService: UNNotificationServiceExtension { } private func deliverBestAttemptNtf(urgent: Bool = false) { - if (urgent || !expectingMoreMessages) { + logger.debug("NotificationService.deliverBestAttemptNtf urgent: \(urgent) expectingMoreMessages: \(self.expectingMoreMessages)") + if let handler = contentHandler, urgent || !expectingMoreMessages { + if urgent { + contentHandler = nil + } logger.debug("NotificationService.deliverBestAttemptNtf") // stop processing other messages for (key, _) in expectedMessages { @@ -420,18 +445,18 @@ class NotificationService: UNNotificationServiceExtension { } else { suspend = false } - deliverCallkitOrNotification(urgent: urgent, suspend: suspend) + deliverCallkitOrNotification(urgent: urgent, suspend: suspend, handler: handler) } } - private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false) { + private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false, handler: @escaping (UNNotificationContent) -> Void) { if useCallKit() && expectedMessages.contains(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }) { logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit") if urgent { // suspending NSE even though there may be other notifications // to allow the app to process callkit call suspendChat(0) - deliverNotification() + deliverNotification(handler: handler) } else { // suspending NSE with delay and delivering after the suspension // because pushkit notification must be processed without delay @@ -439,7 +464,7 @@ class NotificationService: UNNotificationServiceExtension { DispatchQueue.global().asyncAfter(deadline: .now() + fastNSESuspendSchedule.delay) { suspendChat(fastNSESuspendSchedule.timeout) DispatchQueue.global().asyncAfter(deadline: .now() + Double(fastNSESuspendSchedule.timeout)) { - self.deliverNotification() + self.deliverNotification(handler: handler) } } } @@ -458,12 +483,12 @@ class NotificationService: UNNotificationServiceExtension { } } } - deliverNotification() + deliverNotification(handler: handler) } } - private func deliverNotification() { - if let handler = contentHandler, let ntf = prepareNotification() { + private func deliverNotification(handler: @escaping (UNNotificationContent) -> Void) { + if serviceBestAttemptNtf != nil, let ntf = prepareNotification() { contentHandler = nil serviceBestAttemptNtf = nil switch ntf { @@ -496,7 +521,9 @@ class NotificationService: UNNotificationServiceExtension { let callNtf = callNtfKV.value.msgBestAttemptNtf { return useCallKit() ? .callkit(callInv) : .nse(callNtf.notificationContent(badgeCount)) } else { + logger.debug("NotificationService prepareNotification \(String(describing: self.expectedMessages.map { $0.key }))") let ntfEvents = expectedMessages.compactMap { $0.value.msgBestAttemptNtf?.notificationEvent } + logger.debug("NotificationService prepareNotification \(ntfEvents.count)") if ntfEvents.isEmpty { return .empty } else if let ntfEvent = ntfEvents.count == 1 ? ntfEvents.first : nil { @@ -654,7 +681,7 @@ func doStartChat() -> DBMigrationResult? { let state = NSEChatState.shared.value NSEChatState.shared.set(.starting) if let user = apiGetActiveUser() { - logger.debug("NotificationService active user \(String(describing: user))") + logger.debug("NotificationService active user \(user.displayName)") do { try setNetworkConfig(networkConfig) try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path) @@ -893,7 +920,7 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { throw r } -func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? { +func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [NtfConn]? { guard apiGetActiveUser() != nil else { logger.debug("no active user") return nil @@ -901,7 +928,7 @@ func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? { let r = sendSimpleXCmd(.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) if case let .ntfConns(ntfConns) = r { logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)") - return ntfConns.compactMap { toUserNtfConn($0) } + return ntfConns } else if case let .chatCmdError(_, error) = r { logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") } else { @@ -910,30 +937,23 @@ func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? { return nil } -func toUserNtfConn(_ ntfConn: NtfConn) -> UserNtfConn? { - if let user = ntfConn.user_ { - return UserNtfConn(user: user, connEntity_: ntfConn.connEntity_, expectedMsg_: ntfConn.expectedMsg_) - } else { - return nil - } -} - -func apiGetConnNtfMessages(connIds: [String]) -> [NtfMsgInfo?]? { +func apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) -> [NtfMsgInfo?]? { guard apiGetActiveUser() != nil else { logger.debug("no active user") return nil } - let r = sendSimpleXCmd(.apiGetConnNtfMessages(connIds: connIds)) + logger.debug("apiGetConnNtfMessages command: \(ChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)") + let r = sendSimpleXCmd(.apiGetConnNtfMessages(connMsgReqs: connMsgReqs)) if case let .connNtfMessages(receivedMsgs) = r { - logger.debug("apiGetConnNtfMessages response receivedMsgs: \(receivedMsgs.count)") + logger.debug("apiGetConnNtfMessages response receivedMsgs: total \(receivedMsgs.count), expecting messages \(receivedMsgs.count { $0 != nil })") return receivedMsgs } logger.debug("apiGetConnNtfMessages error: \(responseError(r))") return nil } -func getConnNtfMessage(connId: String) -> NtfMsgInfo? { - let r_ = apiGetConnNtfMessages(connIds: [connId]) +func getConnNtfMessage(connMsgReq: ConnMsgReq) -> NtfMsgInfo? { + let r_ = apiGetConnNtfMessages(connMsgReqs: [connMsgReq]) if let r = r_, let receivedMsg = r.count == 1 ? r.first : nil { return receivedMsg } @@ -974,33 +994,28 @@ func setNetworkConfig(_ cfg: NetCfg) throws { throw r } -struct UserNtfConn { - var user: User - var connEntity_: ConnectionEntity? - var expectedMsg_: NtfMsgInfo? - - var defaultBestAttemptNtf: NSENotificationData { - return if !user.showNotifications { - .noNtf - } else if let connEntity = connEntity_ { - switch connEntity { - case let .rcvDirectMsgConnection(_, contact): - contact?.chatSettings.enableNtfs == .all - ? .connectionEvent(user, connEntity) - : .noNtf - case let .rcvGroupMsgConnection(_, groupInfo, _): - groupInfo.chatSettings.enableNtfs == .all - ? .connectionEvent(user, connEntity) - : .noNtf - case .sndFileConnection: .noNtf - case .rcvFileConnection: .noNtf - case let .userContactConnection(_, userContact): - userContact.groupId == nil - ? .connectionEvent(user, connEntity) - : .noNtf - } - } else { - .noNtf +func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData { + let user = ntfConn.user + let connEntity = ntfConn.connEntity + return if !user.showNotifications { + .noNtf + } else { + switch ntfConn.connEntity { + case let .rcvDirectMsgConnection(_, contact): + contact?.chatSettings.enableNtfs == .all + ? .connectionEvent(user, connEntity) + : .noNtf + case let .rcvGroupMsgConnection(_, groupInfo, _): + groupInfo.chatSettings.enableNtfs == .all + ? .connectionEvent(user, connEntity) + : .noNtf + case .sndFileConnection: .noNtf + case .rcvFileConnection: .noNtf + case let .userContactConnection(_, userContact): + userContact.groupId == nil + ? .connectionEvent(user, connEntity) + : .noNtf } } } + diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 21908026b2..88e174e8bc 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -179,7 +179,7 @@ class ShareModel: ObservableObject { resetChatCtrl() // Clears retained migration result registerGroupDefaults() haskell_init_se() - let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation()) + let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation(), backgroundMode: false) if let e = migrationError(result) { return e } try apiSetAppFilePaths( filesFolder: getAppFilesDirectory().path, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 05fc4ff87b..18d0cbdb3c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -67,7 +67,7 @@ public enum ChatCommand { case apiCheckToken(token: DeviceToken) case apiDeleteToken(token: DeviceToken) case apiGetNtfConns(nonce: String, encNtfInfo: String) - case apiGetConnNtfMessages(connIds: [String]) + case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) @@ -246,7 +246,7 @@ public enum ChatCommand { case let .apiCheckToken(token): return "/_ntf check \(token.cmdString)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" - case let .apiGetConnNtfMessages(connIds): return "/_ntf conn messages \(connIds.joined(separator: ","))" + case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" 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)" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 0c47442987..fb34ba390c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2399,14 +2399,14 @@ public enum ConnectionEntity: Decodable, Hashable { public var id: String? { switch self { - case let .rcvDirectMsgConnection(_, contact): - return contact?.id + case let .rcvDirectMsgConnection(conn, contact): + contact?.id ?? conn.id case let .rcvGroupMsgConnection(_, _, groupMember): - return groupMember.id + groupMember.id case let .userContactConnection(_, userContact): - return userContact.id + userContact.id default: - return nil + nil } } @@ -2422,10 +2422,11 @@ public enum ConnectionEntity: Decodable, Hashable { } public struct NtfConn: Decodable, Hashable { - public var user_: User? - public var connEntity_: ConnectionEntity? + public var user: User + public var agentConnId: String + public var agentDbQueueId: Int64 + public var connEntity: ConnectionEntity public var expectedMsg_: NtfMsgInfo? - } public struct NtfMsgInfo: Decodable, Hashable { @@ -2433,6 +2434,29 @@ public struct NtfMsgInfo: Decodable, Hashable { public var msgTs: Date } +let iso8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f +}() + +// used in apiGetConnNtfMessages +public struct ConnMsgReq { + public var msgConnId: String + public var msgDbQueueId: Int64 + public var msgTs: Date // SystemTime encodes as a number, should be taken from NtfMsgInfo + + public init(msgConnId: String, msgDbQueueId: Int64, msgTs: Date) { + self.msgConnId = msgConnId + self.msgDbQueueId = msgDbQueueId + self.msgTs = msgTs + } + + public var cmdString: String { + "\(msgConnId):\(msgDbQueueId):\(iso8601DateFormatter.string(from: msgTs))" + } +} + public struct NtfMsgAckInfo: Decodable, Hashable { public var msgId: String public var msgTs_: Date? diff --git a/cabal.project b/cabal.project index 4a95ae3cfe..b7c8832d9d 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: 08b84deba458407ae97d55debd98b872cb6c4d79 + tag: 3d10c9bf9e4d8196d39162ff8712f6b729b8c247 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index c945d72656..40aa4e7da0 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."08b84deba458407ae97d55debd98b872cb6c4d79" = "0b4n7d81spl1r7zppr0lc40ls9m1i93g4l3hzg2996pi3bxmafrr"; + "https://github.com/simplex-chat/simplexmq.git"."3d10c9bf9e4d8196d39162ff8712f6b729b8c247" = "1nnr6klv240da97qmrzlh8jywpimcnlrxnxnjrm2rd0w0w7gvra1"; "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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 043db9bfc9..4d835b41bb 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -357,7 +357,7 @@ data ChatCommand | APICheckToken DeviceToken | APIDeleteToken DeviceToken | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} - | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} + | APIGetConnNtfMessages (NonEmpty ConnMsgReq) | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIAcceptMember GroupId GroupMemberId GroupMemberRole @@ -1131,12 +1131,18 @@ instance FromJSON ChatTagData where parseJSON invalid = JT.prependFailure "bad ChatTagData, " (JT.typeMismatch "Object" invalid) data NtfConn = NtfConn - { user_ :: Maybe User, - connEntity_ :: Maybe ConnectionEntity, + { user :: User, + agentConnId :: AgentConnId, + agentDbQueueId :: Int64, + connEntity :: ConnectionEntity, + -- Decrypted ntf meta of the expected message (the one notification was sent for). + -- Nothing means it failed to decrypt or to decode, we can still show event for entity expectedMsg_ :: Maybe NtfMsgInfo } deriving (Show) +-- brokerTs is the same msgTs, it is used in ConnMsgReq / APIGetConnNtfMessages +-- to set it as last connection message in case queue is empty data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 0dbee1542e..34fc0423fb 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -34,7 +34,7 @@ import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}, maintenance} chat = case logAgent of Just level -> do setLogLevel level @@ -48,7 +48,8 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core exitFailure run db@ChatDatabase {chatStore} = do u_ <- getSelectActiveUser chatStore - cc <- newChatController db u_ cfg opts False + let backgroundMode = not maintenance + cc <- newChatController db u_ cfg opts backgroundMode u <- maybe (createActiveUser cc) pure u_ unless testView $ putStrLn $ "Current user: " <> userStr u runSimplexChat opts u cc chat diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 11ea3a1f93..eeb54c6aef 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1294,26 +1294,17 @@ processChatCommand' vr = \case ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo (errs, ntfMsgs) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (getMsgConn db) (L.toList ntfInfos)) unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRNtfConns ntfMsgs + pure $ CRNtfConns $ catMaybes ntfMsgs where - getMsgConn :: DB.Connection -> NotificationInfo -> IO NtfConn - getMsgConn db NotificationInfo {ntfConnId, ntfMsgMeta = nMsgMeta} = do + getMsgConn :: DB.Connection -> NotificationInfo -> IO (Maybe NtfConn) + getMsgConn db NotificationInfo {ntfConnId, ntfDbQueueId, ntfMsgMeta = nMsgMeta} = do let agentConnId = AgentConnId ntfConnId - user_ <- getUserByAConnId db agentConnId - connEntity_ <- - pure user_ $>>= \user -> - eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId) - pure $ - NtfConn - { user_, - connEntity_, - -- Decrypted ntf meta of the expected message (the one notification was sent for) - expectedMsg_ = expectedMsgInfo <$> nMsgMeta - } - ApiGetConnNtfMessages connIds -> withUser $ \_ -> do - let acIds = L.map (\(AgentConnId acId) -> acId) connIds - msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds - let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs + mkNtfConn user connEntity = NtfConn {user, agentConnId, agentDbQueueId = ntfDbQueueId, connEntity, expectedMsg_ = expectedMsgInfo <$> nMsgMeta} + getUserByAConnId db agentConnId + $>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId) + APIGetConnNtfMessages connMsgs -> withUser $ \_ -> do + msgs <- lift $ withAgent' (`getConnectionMessages` connMsgs) + let ntfMsgs = L.map (receivedMsgInfo <$>) msgs pure $ CRConnNtfMessages ntfMsgs GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do srvs <- withFastStore (`getUserServers` user) @@ -4005,7 +3996,7 @@ chatCommandP = "/_ntf check " *> (APICheckToken <$> strP), "/_ntf delete " *> (APIDeleteToken <$> strP), "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), - "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), + "/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP), "/_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), @@ -4320,6 +4311,12 @@ chatCommandP = cfArgs <- optional $ CFArgs <$> (" key=" *> strP <* A.space) <*> (" nonce=" *> strP) path <- filePath pure $ CryptoFile path cfArgs + connMsgsP = L.fromList <$> connMsgP `A.sepBy1'` A.char ',' + connMsgP = do + AgentConnId msgConnId <- strP <* A.char ':' + msgDbQueueId <- strP <* A.char ':' + ts <- strP + pure ConnMsgReq {msgConnId, msgDbQueueId, msgTs = Just ts} memberRole = A.choice [ " owner" $> GROwner, 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 e6a567035e..a10d3f3db7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1119,9 +1119,9 @@ 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 = ? +Query: UPDATE rcv_queues SET last_broker_ts = ? WHERE conn_id = ? AND rcv_queue_id = ? AND last_broker_ts < ? Plan: -SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) +SEARCH rcv_queues USING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) Query: UPDATE rcv_queues SET rcv_primary = ? WHERE conn_id = ? Plan: diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f145262862..6abbf6f03f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -342,8 +342,8 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv] - CRNtfConns {} -> [] - CRConnNtfMessages {} -> [] + CRNtfConns {ntfConns} -> map (\NtfConn {agentConnId, expectedMsg_} -> plain $ show agentConnId <> " " <> show expectedMsg_) ntfConns + CRConnNtfMessages ntfMsgs -> [sShow ntfMsgs] CRNtfMessage {} -> [] CRCurrentRemoteHost rhi_ -> [ maybe diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 751a7d1e05..71142c8b60 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -78,10 +78,11 @@ testBroadcastMessages ps = do bob <## "I broadcast messages to all connected users from @alice." cath `connectVia` botLink alice #> "@broadcast_bot hello all!" + alice <# "broadcast_bot> hello all!" -- we broadcast to the sender too, /feed is used by bot bob <# "broadcast_bot> hello all!" cath <# "broadcast_bot> hello all!" alice <# "broadcast_bot> > hello all!" - alice <## " Forwarded to 2 contact(s)" + alice <## " Forwarded to 3 contact(s), 0 errors" where cc `connectVia` botLink = do cc ##> ("/c " <> botLink) From f5c706f2dd206a5a15d7d4336d56afaa771ba27a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 2 May 2025 12:27:08 +0100 Subject: [PATCH 204/289] ios: remove types used only in the app from the framework (#5866) * ios: remove types used only in the app from the framework * move more types * comment --- apps/ios/Shared/Model/AppAPITypes.swift | 2327 ++++++++++++++++ apps/ios/Shared/Model/SimpleXAPI.swift | 24 +- .../Views/Migration/MigrateFromDevice.swift | 2 +- apps/ios/SimpleX NSE/NSEAPITypes.swift | 173 ++ .../ios/SimpleX NSE/NotificationService.swift | 30 +- apps/ios/SimpleX SE/ShareAPI.swift | 184 +- apps/ios/SimpleX SE/ShareModel.swift | 3 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 10 +- apps/ios/SimpleXChat/API.swift | 112 +- apps/ios/SimpleXChat/APITypes.swift | 2340 +---------------- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- apps/ios/SimpleXChat/ErrorAlert.swift | 35 +- 12 files changed, 2775 insertions(+), 2467 deletions(-) create mode 100644 apps/ios/Shared/Model/AppAPITypes.swift create mode 100644 apps/ios/SimpleX NSE/NSEAPITypes.swift diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift new file mode 100644 index 0000000000..d7f96284cf --- /dev/null +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -0,0 +1,2327 @@ +// +// APITypes.swift +// SimpleX +// +// Created by EP on 01/05/2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SimpleXChat +import SwiftUI + +// some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised +enum ChatCommand: ChatCmdProtocol { + case showActiveUser + case createActiveUser(profile: Profile?, pastTimestamp: Bool) + case listUsers + case apiSetActiveUser(userId: Int64, viewPwd: String?) + case setAllContactReceipts(enable: Bool) + case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) + case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) + case apiHideUser(userId: Int64, viewPwd: String) + case apiUnhideUser(userId: Int64, viewPwd: String) + case apiMuteUser(userId: Int64) + case apiUnmuteUser(userId: Int64) + case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) + case startChat(mainApp: Bool, enableSndFiles: Bool) + case checkChatRunning + case apiStopChat + case apiActivateChat(restoreChat: Bool) + case apiSuspendChat(timeoutMicroseconds: Int) + case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) + case apiSetEncryptLocalFiles(enable: Bool) + case apiExportArchive(config: ArchiveConfig) + case apiImportArchive(config: ArchiveConfig) + case apiDeleteStorage + case apiStorageEncryption(config: DBEncryptionConfig) + case testStorageEncryption(key: String) + case apiSaveSettings(settings: AppSettings) + 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 apiCreateChatTag(tag: ChatTagData) + case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) + case apiDeleteChatTag(tagId: Int64) + case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData) + 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 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 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 apiGetNtfToken + case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) + case apiVerifyToken(token: DeviceToken, nonce: String, code: String) + case apiCheckToken(token: DeviceToken) + case apiDeleteToken(token: DeviceToken) + case apiGetNtfConns(nonce: String, encNtfInfo: String) + case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) + case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) + case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) + case apiJoinGroup(groupId: 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) + case apiLeaveGroup(groupId: Int64) + case apiListMembers(groupId: Int64) + case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) + case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole, short: Bool) + case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) + case apiDeleteGroupLink(groupId: Int64) + case apiGetGroupLink(groupId: Int64) + case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) + case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) + case apiTestProtoServer(userId: Int64, server: String) + case apiGetServerOperators + case apiSetServerOperators(operators: [ServerOperator]) + case apiGetUserServers(userId: Int64) + case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers]) + case apiValidateServers(userId: Int64, userServers: [UserOperatorServers]) + case apiGetUsageConditions + case apiSetConditionsNotified(conditionsId: Int64) + case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) + case apiSetChatItemTTL(userId: Int64, seconds: Int64) + case apiGetChatItemTTL(userId: Int64) + case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?) + case apiSetNetworkConfig(networkConfig: NetCfg) + case apiGetNetworkConfig + case apiSetNetworkInfo(networkInfo: UserNetworkInfo) + case reconnectAllServers + case reconnectServer(userId: Int64, smpServer: String) + case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) + case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) + case apiContactInfo(contactId: Int64) + case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) + case apiContactQueueInfo(contactId: Int64) + case apiGroupMemberQueueInfo(groupId: Int64, groupMemberId: Int64) + case apiSwitchContact(contactId: Int64) + case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64) + case apiAbortSwitchContact(contactId: Int64) + case apiAbortSwitchGroupMember(groupId: Int64, groupMemberId: Int64) + case apiSyncContactRatchet(contactId: Int64, force: Bool) + case apiSyncGroupMemberRatchet(groupId: Int64, groupMemberId: Int64, force: Bool) + case apiGetContactCode(contactId: Int64) + case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) + case apiVerifyContact(contactId: Int64, connectionCode: String?) + case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) + case apiAddContact(userId: Int64, short: Bool, incognito: Bool) + case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiChangeConnectionUser(connId: Int64, userId: Int64) + case apiConnectPlan(userId: Int64, connLink: String) + case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink) + case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) + case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode) + case apiClearChat(type: ChatType, id: Int64) + case apiListContacts(userId: Int64) + case apiUpdateProfile(userId: Int64, profile: Profile) + case apiSetContactPrefs(contactId: Int64, preferences: Preferences) + case apiSetContactAlias(contactId: Int64, localAlias: String) + case apiSetGroupAlias(groupId: Int64, localAlias: String) + case apiSetConnectionAlias(connId: Int64, localAlias: String) + case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) + case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) + case apiCreateMyAddress(userId: Int64, short: Bool) + case apiDeleteMyAddress(userId: Int64) + case apiShowMyAddress(userId: Int64) + case apiSetProfileAddress(userId: Int64, on: Bool) + case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) + case apiAcceptContact(incognito: Bool, contactReqId: Int64) + case apiRejectContact(contactReqId: Int64) + // WebRTC calls + case apiSendCallInvitation(contact: Contact, callType: CallType) + case apiRejectCall(contact: Contact) + case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer) + case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) + case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) + case apiEndCall(contact: Contact) + case apiGetCallInvitations + 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 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?) + case cancelFile(fileId: Int64) + // remote desktop commands + case setLocalDeviceName(displayName: String) + case connectRemoteCtrl(xrcpInvitation: String) + case findKnownRemoteCtrl + case confirmRemoteCtrl(remoteCtrlId: Int64) + case verifyRemoteCtrlSession(sessionCode: String) + case listRemoteCtrls + case stopRemoteCtrl + case deleteRemoteCtrl(remoteCtrlId: Int64) + case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) + case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) + case apiStandaloneFileInfo(url: String) + // misc + case showVersion + case getAgentSubsTotal(userId: Int64) + case getAgentServersSummary(userId: Int64) + case resetAgentServersStats + case string(String) + + var cmdString: String { + get { + switch self { + case .showActiveUser: return "/u" + case let .createActiveUser(profile, pastTimestamp): + let user = NewUser(profile: profile, pastTimestamp: pastTimestamp) + return "/_create user \(encodeJSON(user))" + case .listUsers: return "/users" + case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" + case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))" + case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings): + let umrs = userMsgReceiptSettings + return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" + case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings): + let umrs = userMsgReceiptSettings + return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" + case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" + case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" + case let .apiMuteUser(userId): return "/_mute user \(userId)" + case let .apiUnmuteUser(userId): return "/_unmute user \(userId)" + case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" + case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" + case .checkChatRunning: return "/_check running" + case .apiStopChat: return "/_stop" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" + case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" + case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" + case .apiDeleteStorage: return "/_db delete" + case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" + case let .testStorageEncryption(key): return "/db test key \(key)" + case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))" + 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): + let msgs = encodeJSON(composedMessages) + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_send \(ref(type, id)) 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 .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: ","))" + case let .apiCreateChatItems(noteFolderId, composedMessages): + let msgs = encodeJSON(composedMessages) + 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 .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 .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): + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(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)" + case let .apiCheckToken(token): return "/_ntf check \(token.cmdString)" + case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" + case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" + case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" + 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 .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))" + case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" + case let .apiListMembers(groupId): return "/_members #\(groupId)" + case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" + case let .apiCreateGroupLink(groupId, memberRole, short): return "/_create link #\(groupId) \(memberRole) short=\(onOff(short))" + case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" + case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" + case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" + case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" + case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" + case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case .apiGetServerOperators: return "/_operators" + case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" + case let .apiGetUserServers(userId): return "/_servers \(userId)" + case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))" + case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))" + case .apiGetUsageConditions: return "/_conditions" + case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)" + 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 .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 .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" + case let .apiContactInfo(contactId): return "/_info @\(contactId)" + case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" + case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)" + case let .apiGroupMemberQueueInfo(groupId, groupMemberId): return "/_queue info #\(groupId) \(groupMemberId)" + case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" + case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)" + case let .apiAbortSwitchContact(contactId): return "/_abort switch @\(contactId)" + case let .apiAbortSwitchGroupMember(groupId, groupMemberId): return "/_abort switch #\(groupId) \(groupMemberId)" + case let .apiSyncContactRatchet(contactId, force): if force { + return "/_sync @\(contactId) force=on" + } else { + return "/_sync @\(contactId)" + } + case let .apiSyncGroupMemberRatchet(groupId, groupMemberId, force): if force { + return "/_sync #\(groupId) \(groupMemberId) force=on" + } else { + return "/_sync #\(groupId) \(groupMemberId)" + } + case let .apiGetContactCode(contactId): return "/_get code @\(contactId)" + case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)" + case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)" + case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" + case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" + case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" + case let .apiAddContact(userId, short, incognito): return "/_connect \(userId) short=\(onOff(short)) incognito=\(onOff(incognito))" + case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" + case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" + 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 .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))" + case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" + case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))" + case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" + case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")" + case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")" + case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))" + case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" + case let .apiShowMyAddress(userId): return "/_show_address \(userId)" + case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" + case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" + case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" + case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" + case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" + case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))" + case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" + case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" + case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" + case .apiGetCallInvitations: return "/_call get" + case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" + case .apiGetNetworkStatuses: return "/_network_statuses" + case let .apiChatRead(type, id): 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 .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)" + case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" + case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)" + case .findKnownRemoteCtrl: return "/find remote ctrl" + case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)" + case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)" + case .listRemoteCtrls: return "/list remote ctrls" + case .stopRemoteCtrl: return "/stop remote ctrl" + case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" + case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" + case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" + case let .apiStandaloneFileInfo(link): return "/_download info \(link)" + case .showVersion: return "/version" + case let .getAgentSubsTotal(userId): return "/get subs total \(userId)" + case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" + case .resetAgentServersStats: return "/reset servers stats" + case let .string(str): return str + } + } + } + + var cmdType: String { + get { + switch self { + case .showActiveUser: return "showActiveUser" + case .createActiveUser: return "createActiveUser" + case .listUsers: return "listUsers" + case .apiSetActiveUser: return "apiSetActiveUser" + case .setAllContactReceipts: return "setAllContactReceipts" + case .apiSetUserContactReceipts: return "apiSetUserContactReceipts" + case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts" + case .apiHideUser: return "apiHideUser" + case .apiUnhideUser: return "apiUnhideUser" + case .apiMuteUser: return "apiMuteUser" + case .apiUnmuteUser: return "apiUnmuteUser" + case .apiDeleteUser: return "apiDeleteUser" + case .startChat: return "startChat" + case .checkChatRunning: return "checkChatRunning" + case .apiStopChat: return "apiStopChat" + case .apiActivateChat: return "apiActivateChat" + case .apiSuspendChat: return "apiSuspendChat" + case .apiSetAppFilePaths: return "apiSetAppFilePaths" + case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" + case .apiExportArchive: return "apiExportArchive" + case .apiImportArchive: return "apiImportArchive" + case .apiDeleteStorage: return "apiDeleteStorage" + case .apiStorageEncryption: return "apiStorageEncryption" + case .testStorageEncryption: return "testStorageEncryption" + case .apiSaveSettings: return "apiSaveSettings" + case .apiGetSettings: return "apiGetSettings" + case .apiGetChatTags: return "apiGetChatTags" + case .apiGetChats: return "apiGetChats" + case .apiGetChat: return "apiGetChat" + case .apiGetChatItemInfo: return "apiGetChatItemInfo" + case .apiSendMessages: return "apiSendMessages" + case .apiCreateChatTag: return "apiCreateChatTag" + case .apiSetChatTags: return "apiSetChatTags" + case .apiDeleteChatTag: return "apiDeleteChatTag" + case .apiUpdateChatTag: return "apiUpdateChatTag" + case .apiReorderChatTags: return "apiReorderChatTags" + case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" + case .apiUpdateChatItem: return "apiUpdateChatItem" + case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" + case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" + case .apiArchiveReceivedReports: return "apiArchiveReceivedReports" + case .apiDeleteReceivedReports: return "apiDeleteReceivedReports" + case .apiChatItemReaction: return "apiChatItemReaction" + case .apiGetReactionMembers: return "apiGetReactionMembers" + case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" + case .apiForwardChatItems: return "apiForwardChatItems" + case .apiGetNtfToken: return "apiGetNtfToken" + case .apiRegisterToken: return "apiRegisterToken" + case .apiVerifyToken: return "apiVerifyToken" + case .apiCheckToken: return "apiCheckToken" + case .apiDeleteToken: return "apiDeleteToken" + case .apiGetNtfConns: return "apiGetNtfConns" + case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" + case .apiNewGroup: return "apiNewGroup" + case .apiAddMember: return "apiAddMember" + case .apiJoinGroup: return "apiJoinGroup" + case .apiMembersRole: return "apiMembersRole" + case .apiBlockMembersForAll: return "apiBlockMembersForAll" + case .apiRemoveMembers: return "apiRemoveMembers" + case .apiLeaveGroup: return "apiLeaveGroup" + case .apiListMembers: return "apiListMembers" + case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" + case .apiCreateGroupLink: return "apiCreateGroupLink" + case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" + case .apiDeleteGroupLink: return "apiDeleteGroupLink" + case .apiGetGroupLink: return "apiGetGroupLink" + case .apiCreateMemberContact: return "apiCreateMemberContact" + case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" + case .apiTestProtoServer: return "apiTestProtoServer" + case .apiGetServerOperators: return "apiGetServerOperators" + case .apiSetServerOperators: return "apiSetServerOperators" + case .apiGetUserServers: return "apiGetUserServers" + case .apiSetUserServers: return "apiSetUserServers" + case .apiValidateServers: return "apiValidateServers" + case .apiGetUsageConditions: return "apiGetUsageConditions" + case .apiSetConditionsNotified: return "apiSetConditionsNotified" + case .apiAcceptConditions: return "apiAcceptConditions" + case .apiSetChatItemTTL: return "apiSetChatItemTTL" + case .apiGetChatItemTTL: return "apiGetChatItemTTL" + case .apiSetChatTTL: return "apiSetChatTTL" + case .apiSetNetworkConfig: return "apiSetNetworkConfig" + case .apiGetNetworkConfig: return "apiGetNetworkConfig" + case .apiSetNetworkInfo: return "apiSetNetworkInfo" + case .reconnectAllServers: return "reconnectAllServers" + case .reconnectServer: return "reconnectServer" + case .apiSetChatSettings: return "apiSetChatSettings" + case .apiSetMemberSettings: return "apiSetMemberSettings" + case .apiContactInfo: return "apiContactInfo" + case .apiGroupMemberInfo: return "apiGroupMemberInfo" + case .apiContactQueueInfo: return "apiContactQueueInfo" + case .apiGroupMemberQueueInfo: return "apiGroupMemberQueueInfo" + case .apiSwitchContact: return "apiSwitchContact" + case .apiSwitchGroupMember: return "apiSwitchGroupMember" + case .apiAbortSwitchContact: return "apiAbortSwitchContact" + case .apiAbortSwitchGroupMember: return "apiAbortSwitchGroupMember" + case .apiSyncContactRatchet: return "apiSyncContactRatchet" + case .apiSyncGroupMemberRatchet: return "apiSyncGroupMemberRatchet" + case .apiGetContactCode: return "apiGetContactCode" + case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" + case .apiVerifyContact: return "apiVerifyContact" + case .apiVerifyGroupMember: return "apiVerifyGroupMember" + case .apiAddContact: return "apiAddContact" + case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" + case .apiChangeConnectionUser: return "apiChangeConnectionUser" + case .apiConnectPlan: return "apiConnectPlan" + case .apiConnect: return "apiConnect" + case .apiDeleteChat: return "apiDeleteChat" + case .apiClearChat: return "apiClearChat" + case .apiListContacts: return "apiListContacts" + case .apiUpdateProfile: return "apiUpdateProfile" + case .apiSetContactPrefs: return "apiSetContactPrefs" + case .apiSetContactAlias: return "apiSetContactAlias" + case .apiSetGroupAlias: return "apiSetGroupAlias" + case .apiSetConnectionAlias: return "apiSetConnectionAlias" + case .apiSetUserUIThemes: return "apiSetUserUIThemes" + case .apiSetChatUIThemes: return "apiSetChatUIThemes" + case .apiCreateMyAddress: return "apiCreateMyAddress" + case .apiDeleteMyAddress: return "apiDeleteMyAddress" + case .apiShowMyAddress: return "apiShowMyAddress" + case .apiSetProfileAddress: return "apiSetProfileAddress" + case .apiAddressAutoAccept: return "apiAddressAutoAccept" + case .apiAcceptContact: return "apiAcceptContact" + case .apiRejectContact: return "apiRejectContact" + case .apiSendCallInvitation: return "apiSendCallInvitation" + case .apiRejectCall: return "apiRejectCall" + case .apiSendCallOffer: return "apiSendCallOffer" + case .apiSendCallAnswer: return "apiSendCallAnswer" + case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" + case .apiEndCall: return "apiEndCall" + case .apiGetCallInvitations: return "apiGetCallInvitations" + case .apiCallStatus: return "apiCallStatus" + case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" + case .apiChatRead: return "apiChatRead" + case .apiChatItemsRead: return "apiChatItemsRead" + case .apiChatUnread: return "apiChatUnread" + case .receiveFile: return "receiveFile" + case .setFileToReceive: return "setFileToReceive" + case .cancelFile: return "cancelFile" + case .setLocalDeviceName: return "setLocalDeviceName" + case .connectRemoteCtrl: return "connectRemoteCtrl" + case .findKnownRemoteCtrl: return "findKnownRemoteCtrl" + case .confirmRemoteCtrl: return "confirmRemoteCtrl" + case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession" + case .listRemoteCtrls: return "listRemoteCtrls" + case .stopRemoteCtrl: return "stopRemoteCtrl" + case .deleteRemoteCtrl: return "deleteRemoteCtrl" + case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" + case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" + case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" + case .showVersion: return "showVersion" + case .getAgentSubsTotal: return "getAgentSubsTotal" + case .getAgentServersSummary: return "getAgentServersSummary" + case .resetAgentServersStats: return "resetAgentServersStats" + case .string: return "console command" + } + } + } + + func ref(_ type: ChatType, _ id: Int64) -> String { + "\(type.rawValue)\(id)" + } + + func joinedIds(_ ids: [Int64]) -> String { + ids.map { "\($0)" }.joined(separator: ",") + } + + func chatItemTTLStr(seconds: Int64?) -> String { + if let seconds = seconds { + return String(seconds) + } else { + return "default" + } + } + + var obfuscated: ChatCommand { + switch self { + case let .apiStorageEncryption(cfg): + return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey))) + case let .apiSetActiveUser(userId, viewPwd): + return .apiSetActiveUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiHideUser(userId, viewPwd): + return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiUnhideUser(userId, viewPwd): + return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiDeleteUser(userId, delSMPQueues, viewPwd): + return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) + case let .testStorageEncryption(key): + return .testStorageEncryption(key: obfuscate(key)) + default: return self + } + } + + private func obfuscate(_ s: String) -> String { + s == "" ? "" : "***" + } + + private func obfuscate(_ s: String?) -> String? { + if let s = s { + return obfuscate(s) + } + return nil + } + + private func onOffParam(_ param: String, _ b: Bool?) -> String { + if let b = b { + return " \(param)=\(onOff(b))" + } + return "" + } + + private func maybePwd(_ pwd: String?) -> String { + pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) + } +} + +enum ChatResponse: Decodable, Error, ChatRespProtocol { + case response(type: String, json: String) + case activeUser(user: User) + case usersList(users: [UserInfo]) + case chatStarted + case chatRunning + case chatStopped + case chatSuspended + case apiChats(user: UserRef, chats: [ChatData]) + case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?) + case chatTags(user: UserRef, userTags: [ChatTag]) + case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) + case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case serverOperatorConditions(conditions: ServerOperatorConditions) + case userServers(user: UserRef, userServers: [UserOperatorServers]) + case userServersValidation(user: UserRef, serverErrors: [UserServersError]) + case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) + case chatItemTTL(user: UserRef, chatItemTTL: Int64?) + case networkConfig(networkConfig: NetCfg) + case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) + case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) + case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo) + case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) + case groupMemberSwitchStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactSwitchAborted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) + case groupMemberSwitchAborted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) + case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) + case contactRatchetSyncStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) + case groupMemberRatchetSyncStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) + case contactRatchetSync(user: UserRef, contact: Contact, ratchetSyncProgress: RatchetSyncProgress) + case groupMemberRatchetSync(user: UserRef, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress) + case contactVerificationReset(user: UserRef, contact: Contact) + case groupMemberVerificationReset(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case contactCode(user: UserRef, contact: Contact, connectionCode: String) + case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) + case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) + case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64]) + case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) + case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) + case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) + case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan) + case sentConfirmation(user: UserRef, connection: PendingContactConnection) + case sentInvitation(user: UserRef, connection: PendingContactConnection) + case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) + case contactAlreadyExists(user: UserRef, contact: Contact) + case contactDeleted(user: UserRef, contact: Contact) + case contactDeletedByContact(user: UserRef, contact: Contact) + case chatCleared(user: UserRef, chatInfo: ChatInfo) + case userProfileNoChange(user: User) + case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) + case userPrivacy(user: User, updatedUser: User) + case contactAliasUpdated(user: UserRef, toContact: Contact) + case groupAliasUpdated(user: UserRef, toGroup: GroupInfo) + case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection) + case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) + case userContactLink(user: User, contactLink: UserContactLink) + case userContactLinkUpdated(user: User, contactLink: UserContactLink) + case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink) + case userContactLinkDeleted(user: User) + case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) + case contactConnecting(user: UserRef, contact: Contact) + case contactSndReady(user: UserRef, contact: Contact) + case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) + case acceptingContactRequest(user: UserRef, contact: Contact) + case contactRequestRejected(user: UserRef) + case contactUpdated(user: UserRef, toContact: Contact) + case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) + case networkStatus(networkStatus: NetworkStatus, connections: [String]) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case groupSubscribed(user: UserRef, groupInfo: GroupRef) + case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) + case groupEmpty(user: UserRef, groupInfo: GroupInfo) + case userContactLinkSubscribed + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) + case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) + case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) + case chatItemUpdated(user: UserRef, chatItem: AChatItem) + case chatItemNotChanged(user: UserRef, chatItem: AChatItem) + case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) + case reactionMembers(user: UserRef, memberReactions: [MemberReaction]) + case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) + case contactsList(user: UserRef, contacts: [Contact]) + // group events + case groupCreated(user: UserRef, groupInfo: GroupInfo) + case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) + case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) + case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) + case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) + case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) + case leftMemberUser(user: UserRef, groupInfo: GroupInfo) + case groupMembers(user: UserRef, group: SimpleXChat.Group) + 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 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) + case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) + case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool) + case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) + case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) + case groupInvitation(user: UserRef, groupInfo: GroupInfo) // unused + case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) + case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) + case groupRemoved(user: UserRef, groupInfo: GroupInfo) // unused + case groupUpdated(user: UserRef, toGroup: GroupInfo) + case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) + case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) + case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) + case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) + case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) + case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) + // receiving file events + case rcvFileAccepted(user: UserRef, chatItem: AChatItem) + case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case standaloneFileInfo(fileMeta: MigrationFileLinkData?) + case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats + case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) + case rcvFileComplete(user: UserRef, chatItem: AChatItem) + case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) + case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) + case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) + case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) + // sending file events + case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload + case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) + case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) + case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) + case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + // call events + case callInvitation(callInvitation: RcvCallInvitation) + case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(user: UserRef, contact: Contact, answer: WebRTCSession) + case callExtraInfo(user: UserRef, contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(user: UserRef, contact: Contact) + case callInvitations(callInvitations: [RcvCallInvitation]) + case ntfTokenStatus(status: NtfTknStatus) + case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) + case ntfConns(ntfConns: [NtfConn]) + case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) + case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + case contactDisabled(user: UserRef, contact: Contact) + // remote desktop responses/events + case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) + case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) + case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) + case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) + case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) + // pq + case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) + // misc + case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) + case cmdOk(user_: UserRef?) + case agentSubsTotal(user: UserRef, subsTotal: SMPServerSubs, hasSession: Bool) + case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary) + case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) + case chatCmdError(user_: UserRef?, chatError: ChatError) + case chatError(user_: UserRef?, chatError: ChatError) + case archiveExported(archiveErrors: [ArchiveError]) + case archiveImported(archiveErrors: [ArchiveError]) + case appSettings(appSettings: AppSettings) + + var responseType: String { + get { + switch self { + case let .response(type, _): return "* \(type)" + case .activeUser: return "activeUser" + case .usersList: return "usersList" + case .chatStarted: return "chatStarted" + case .chatRunning: return "chatRunning" + case .chatStopped: return "chatStopped" + case .chatSuspended: return "chatSuspended" + case .apiChats: return "apiChats" + case .apiChat: return "apiChat" + case .chatTags: return "chatTags" + case .chatItemInfo: return "chatItemInfo" + case .serverTestResult: return "serverTestResult" + case .serverOperatorConditions: return "serverOperators" + case .userServers: return "userServers" + case .userServersValidation: return "userServersValidation" + case .usageConditions: return "usageConditions" + case .chatItemTTL: return "chatItemTTL" + case .networkConfig: return "networkConfig" + case .contactInfo: return "contactInfo" + case .groupMemberInfo: return "groupMemberInfo" + case .queueInfo: return "queueInfo" + case .contactSwitchStarted: return "contactSwitchStarted" + case .groupMemberSwitchStarted: return "groupMemberSwitchStarted" + case .contactSwitchAborted: return "contactSwitchAborted" + case .groupMemberSwitchAborted: return "groupMemberSwitchAborted" + case .contactSwitch: return "contactSwitch" + case .groupMemberSwitch: return "groupMemberSwitch" + case .contactRatchetSyncStarted: return "contactRatchetSyncStarted" + case .groupMemberRatchetSyncStarted: return "groupMemberRatchetSyncStarted" + case .contactRatchetSync: return "contactRatchetSync" + case .groupMemberRatchetSync: return "groupMemberRatchetSync" + case .contactVerificationReset: return "contactVerificationReset" + case .groupMemberVerificationReset: return "groupMemberVerificationReset" + case .contactCode: return "contactCode" + case .groupMemberCode: return "groupMemberCode" + case .connectionVerified: return "connectionVerified" + case .tagsUpdated: return "tagsUpdated" + case .invitation: return "invitation" + case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" + case .connectionUserChanged: return "connectionUserChanged" + case .connectionPlan: return "connectionPlan" + case .sentConfirmation: return "sentConfirmation" + case .sentInvitation: return "sentInvitation" + case .sentInvitationToContact: return "sentInvitationToContact" + case .contactAlreadyExists: return "contactAlreadyExists" + case .contactDeleted: return "contactDeleted" + case .contactDeletedByContact: return "contactDeletedByContact" + case .chatCleared: return "chatCleared" + case .userProfileNoChange: return "userProfileNoChange" + case .userProfileUpdated: return "userProfileUpdated" + case .userPrivacy: return "userPrivacy" + case .contactAliasUpdated: return "contactAliasUpdated" + case .groupAliasUpdated: return "groupAliasUpdated" + case .connectionAliasUpdated: return "connectionAliasUpdated" + case .contactPrefsUpdated: return "contactPrefsUpdated" + case .userContactLink: return "userContactLink" + case .userContactLinkUpdated: return "userContactLinkUpdated" + case .userContactLinkCreated: return "userContactLinkCreated" + case .userContactLinkDeleted: return "userContactLinkDeleted" + case .contactConnected: return "contactConnected" + case .contactConnecting: return "contactConnecting" + case .contactSndReady: return "contactSndReady" + case .receivedContactRequest: return "receivedContactRequest" + case .acceptingContactRequest: return "acceptingContactRequest" + case .contactRequestRejected: return "contactRequestRejected" + case .contactUpdated: return "contactUpdated" + case .groupMemberUpdated: return "groupMemberUpdated" + case .networkStatus: return "networkStatus" + case .networkStatuses: return "networkStatuses" + case .groupSubscribed: return "groupSubscribed" + case .memberSubErrors: return "memberSubErrors" + case .groupEmpty: return "groupEmpty" + case .userContactLinkSubscribed: return "userContactLinkSubscribed" + case .newChatItems: return "newChatItems" + case .groupChatItemsDeleted: return "groupChatItemsDeleted" + case .forwardPlan: return "forwardPlan" + case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" + case .chatItemUpdated: return "chatItemUpdated" + case .chatItemNotChanged: return "chatItemNotChanged" + case .chatItemReaction: return "chatItemReaction" + case .reactionMembers: return "reactionMembers" + case .chatItemsDeleted: return "chatItemsDeleted" + case .contactsList: return "contactsList" + case .groupCreated: return "groupCreated" + case .sentGroupInvitation: return "sentGroupInvitation" + case .userAcceptedGroupSent: return "userAcceptedGroupSent" + case .groupLinkConnecting: return "groupLinkConnecting" + case .businessLinkConnecting: return "businessLinkConnecting" + case .userDeletedMembers: return "userDeletedMembers" + case .leftMemberUser: return "leftMemberUser" + case .groupMembers: return "groupMembers" + case .receivedGroupInvitation: return "receivedGroupInvitation" + case .groupDeletedUser: return "groupDeletedUser" + case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" + case .memberRole: return "memberRole" + case .membersRoleUser: return "membersRoleUser" + case .memberBlockedForAll: return "memberBlockedForAll" + case .membersBlockedForAllUser: return "membersBlockedForAllUser" + case .deletedMemberUser: return "deletedMemberUser" + case .deletedMember: return "deletedMember" + case .leftMember: return "leftMember" + case .groupDeleted: return "groupDeleted" + case .contactsMerged: return "contactsMerged" + case .groupInvitation: return "groupInvitation" + case .userJoinedGroup: return "userJoinedGroup" + case .joinedGroupMember: return "joinedGroupMember" + case .connectedToGroupMember: return "connectedToGroupMember" + case .groupRemoved: return "groupRemoved" + case .groupUpdated: return "groupUpdated" + case .groupLinkCreated: return "groupLinkCreated" + case .groupLink: return "groupLink" + case .groupLinkDeleted: return "groupLinkDeleted" + case .newMemberContact: return "newMemberContact" + case .newMemberContactSentInv: return "newMemberContactSentInv" + case .newMemberContactReceivedInv: return "newMemberContactReceivedInv" + case .rcvFileAccepted: return "rcvFileAccepted" + case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" + case .standaloneFileInfo: return "standaloneFileInfo" + case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated" + case .rcvFileStart: return "rcvFileStart" + case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" + case .rcvFileComplete: return "rcvFileComplete" + case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete" + case .rcvFileCancelled: return "rcvFileCancelled" + case .rcvFileSndCancelled: return "rcvFileSndCancelled" + case .rcvFileError: return "rcvFileError" + case .rcvFileWarning: return "rcvFileWarning" + case .sndFileStart: return "sndFileStart" + case .sndFileComplete: return "sndFileComplete" + case .sndFileCancelled: return "sndFileCancelled" + case .sndStandaloneFileCreated: return "sndStandaloneFileCreated" + case .sndFileStartXFTP: return "sndFileStartXFTP" + case .sndFileProgressXFTP: return "sndFileProgressXFTP" + case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP" + case .sndFileRcvCancelled: return "sndFileRcvCancelled" + case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" + case .sndStandaloneFileComplete: return "sndStandaloneFileComplete" + case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" + case .sndFileError: return "sndFileError" + case .sndFileWarning: return "sndFileWarning" + case .callInvitation: return "callInvitation" + case .callOffer: return "callOffer" + case .callAnswer: return "callAnswer" + case .callExtraInfo: return "callExtraInfo" + case .callEnded: return "callEnded" + case .callInvitations: return "callInvitations" + case .ntfTokenStatus: return "ntfTokenStatus" + case .ntfToken: return "ntfToken" + case .ntfConns: return "ntfConns" + case .connNtfMessages: return "connNtfMessages" + case .ntfMessage: return "ntfMessage" + case .contactConnectionDeleted: return "contactConnectionDeleted" + case .contactDisabled: return "contactDisabled" + case .remoteCtrlList: return "remoteCtrlList" + case .remoteCtrlFound: return "remoteCtrlFound" + case .remoteCtrlConnecting: return "remoteCtrlConnecting" + case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" + case .remoteCtrlConnected: return "remoteCtrlConnected" + case .remoteCtrlStopped: return "remoteCtrlStopped" + case .contactPQEnabled: return "contactPQEnabled" + case .versionInfo: return "versionInfo" + case .cmdOk: return "cmdOk" + case .agentSubsTotal: return "agentSubsTotal" + case .agentServersSummary: return "agentServersSummary" + case .agentSubsSummary: return "agentSubsSummary" + case .chatCmdError: return "chatCmdError" + case .chatError: return "chatError" + case .archiveExported: return "archiveExported" + case .archiveImported: return "archiveImported" + case .appSettings: return "appSettings" + } + } + } + + var details: String { + get { + switch self { + case let .response(_, json): return json + case let .activeUser(user): return String(describing: user) + case let .usersList(users): return String(describing: users) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case .chatStopped: return noDetails + case .chatSuspended: return noDetails + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") + case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") + case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") + case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" + case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") + case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" + case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) + case let .networkConfig(networkConfig): return String(describing: networkConfig) + case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") + case let .queueInfo(u, rcvMsgInfo, queueInfo): + let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } + return withUser(u, "rcvMsgInfo: \(msgInfo)\nqueueInfo: \(encodeJSON(queueInfo))") + case let .contactSwitchStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") + case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") + case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .contactVerificationReset(u, contact): return withUser(u, "contact: \(String(describing: contact))") + case let .groupMemberVerificationReset(u, groupInfo, member): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))") + case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") + case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") + case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") + case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))") + case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)") + case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))") + case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") + case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) + case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) + case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) + 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)) + case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) + case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") + case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) + case .userContactLinkDeleted: return noDetails + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) + case let .contactSndReady(u, contact): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) + case .contactRequestRejected: return noDetails + case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") + case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) + case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case .userContactLinkSubscribed: return noDetails + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): + return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") + case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") + case let .chatItemsStatusesUpdated(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") + case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))") + case let .chatItemsDeleted(u, items, byUser): + let itemsString = items.map { item in + "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") + return withUser(u, itemsString + "\nbyUser: \(byUser)") + case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) + case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") + case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") + case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") + case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupMembers(u, group): return withUser(u, String(describing: group)) + 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 .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)") + case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") + case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)") + case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") + case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") + case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") + case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") + case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") + case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) + case .rcvFileAcceptedSndCancelled: return noDetails + case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) + case .rcvStandaloneFileCreated: return noDetails + case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") + case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) + case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") + case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") + case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) + case .sndStandaloneFileCreated: return noDetails + case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) + case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) + case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .callInvitation(inv): return String(describing: inv) + case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") + case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") + case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") + case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") + case let .callInvitations(invs): return String(describing: invs) + case let .ntfTokenStatus(status): return String(describing: status) + case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" + case let .ntfConns(ntfConns): return String(describing: ntfConns) + case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") + case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) + case let .contactDisabled(u, contact): return withUser(u, String(describing: contact)) + case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) + case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" + case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" + case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" + case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" + case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") + case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" + case .cmdOk: return noDetails + case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)") + case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) + case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) + case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) + case let .archiveExported(archiveErrors): return String(describing: archiveErrors) + case let .archiveImported(archiveErrors): return String(describing: archiveErrors) + case let .appSettings(appSettings): return String(describing: appSettings) + } + } + } + + private var noDetails: String { get { "\(responseType): no details" } } + + static func chatResponse(_ s: String) -> ChatResponse { + let d = s.data(using: .utf8)! + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try callWithLargeStack { + try jsonDecoder.decode(APIResponse.self, from: d) + } + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { + type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } + if type == "apiChats" { + if let r = parseApiChats(jResp) { + return .apiChats(user: r.user, chats: r.chats) + } + } else if type == "apiChat" { + if let jApiChat = jResp["apiChat"] as? NSDictionary, + let user: UserRef = try? decodeObject(jApiChat["user"] as Any), + let jChat = jApiChat["chat"] as? NSDictionary, + let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) { + return .apiChat(user: user, chat: chat, navInfo: navInfo) + } + } else if type == "chatCmdError" { + if let jError = jResp["chatCmdError"] as? NSDictionary { + return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } else if type == "chatError" { + if let jError = jResp["chatError"] as? NSDictionary { + return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } + } + json = serializeJSON(j, options: .prettyPrinted) + } + return ChatResponse.response(type: type ?? "invalid", json: json ?? s) + } + + var chatError: ChatError? { + switch self { + case let .chatCmdError(_, error): error + case let .chatError(_, error): error + default: nil + } + } + + var chatErrorType: ChatErrorType? { + switch self { + case let .chatCmdError(_, .error(error)): error + case let .chatError(_, .error(error)): error + default: nil + } + } +} + +private let largeStackSize: Int = 2 * 1024 * 1024 + +private func callWithLargeStack(_ f: @escaping () throws -> T) throws -> T { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + let thread = Thread { + do { + result = .success(try f()) + } catch { + result = .failure(error) + } + semaphore.signal() + } + + thread.stackSize = largeStackSize + thread.qualityOfService = Thread.current.qualityOfService + thread.start() + + semaphore.wait() + + switch result! { + case let .success(r): return r + case let .failure(e): throw e + } +} + +struct NewUser: Encodable { + var profile: Profile? + var pastTimestamp: Bool +} + +enum ChatPagination { + static let INITIAL_COUNT = 75 + static let PRELOAD_COUNT = 100 + static let UNTIL_PRELOAD_COUNT = 50 + + case last(count: Int) + case after(chatItemId: Int64, count: Int) + case before(chatItemId: Int64, count: Int) + case around(chatItemId: Int64, count: Int) + case initial(count: Int) + + var cmdString: String { + switch self { + case let .last(count): return "count=\(count)" + case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" + case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" + case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" + case let .initial(count): return "initial=\(count)" + } + } +} + +enum ConnectionPlan: Decodable, Hashable { + case invitationLink(invitationLinkPlan: InvitationLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan) + case groupLink(groupLinkPlan: GroupLinkPlan) + case error(chatError: ChatError) +} + +enum InvitationLinkPlan: Decodable, Hashable { + case ok + case ownLink + case connecting(contact_: Contact?) + case known(contact: Contact) +} + +enum ContactAddressPlan: Decodable, Hashable { + case ok + case ownLink + case connectingConfirmReconnect + case connectingProhibit(contact: Contact) + case known(contact: Contact) + case contactViaAddress(contact: Contact) +} + +enum GroupLinkPlan: Decodable, Hashable { + case ok + case ownLink(groupInfo: GroupInfo) + case connectingConfirmReconnect + case connectingProhibit(groupInfo_: GroupInfo?) + case known(groupInfo: GroupInfo) +} + +struct ChatTagData: Encodable { + var emoji: String? + var text: String +} + +struct UpdatedMessage: Encodable { + var msgContent: MsgContent + var mentions: [String: Int64] + + var cmdString: String { + "json \(encodeJSON(self))" + } +} + +enum ChatDeleteMode: Codable { + case full(notify: Bool) + case entity(notify: Bool) + case messages + + var cmdString: String { + switch self { + case let .full(notify): "full notify=\(onOff(notify))" + case let .entity(notify): "entity notify=\(onOff(notify))" + case .messages: "messages" + } + } + + var isEntity: Bool { + switch self { + case .entity: return true + default: return false + } + } +} + +enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(connectionError: String) + + var statusString: LocalizedStringKey { + switch self { + case .connected: "connected" + case .error: "error" + default: "connecting" + } + } + + var statusExplanation: LocalizedStringKey { + switch self { + case .connected: "You are connected to the server used to receive messages from this contact." + case let .error(err): "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: "Trying to connect to the server used to receive messages from this contact." + } + } + + var imageName: String { + switch self { + case .unknown: "circle.dotted" + case .connected: "circle.fill" + case .disconnected: "ellipsis.circle.fill" + case .error: "exclamationmark.circle.fill" + } + } +} + +enum ForwardConfirmation: Decodable, Hashable { + case filesNotAccepted(fileIds: [Int64]) + case filesInProgress(filesCount: Int) + case filesMissing(filesCount: Int) + case filesFailed(filesCount: Int) +} + +struct ConnNetworkStatus: Decodable { + var agentConnId: String + var networkStatus: NetworkStatus +} + +struct UserMsgReceiptSettings: Codable { + var enable: Bool + var clearOverrides: Bool +} + + +struct UserContactLink: Decodable, Hashable { + var connLinkContact: CreatedConnLink + var autoAccept: AutoAccept? + + var responseDetails: String { + "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" + } +} + +struct AutoAccept: Codable, Hashable { + var businessAddress: Bool + var acceptIncognito: Bool + var autoReply: MsgContent? + + static func cmdString(_ autoAccept: AutoAccept?) -> String { + guard let autoAccept = autoAccept else { return "off" } + var s = "on" + if autoAccept.acceptIncognito { + s += " incognito=on" + } else if autoAccept.businessAddress { + s += " business" + } + guard let msg = autoAccept.autoReply else { return s } + return s + " " + msg.cmdString + } +} + +struct DeviceToken: Decodable { + var pushProvider: PushProvider + var token: String + + var cmdString: String { + "\(pushProvider) \(token)" + } +} + +enum PushEnvironment: String { + case development + case production +} + +enum PushProvider: String, Decodable { + case apns_dev + case apns_prod + + init(env: PushEnvironment) { + switch env { + case .development: self = .apns_dev + case .production: self = .apns_prod + } + } +} + +// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable, +// and .local for periodic background checks +enum NotificationsMode: String, Decodable, SelectableItem { + case off = "OFF" + case periodic = "PERIODIC" + case instant = "INSTANT" + + var label: LocalizedStringKey { + switch self { + case .off: "No push server" + case .periodic: "Periodic" + case .instant: "Instant" + } + } + + var icon: String { + switch self { + case .off: return "arrow.clockwise" + case .periodic: return "timer" + case .instant: return "bolt" + } + } + + var id: String { self.rawValue } + + static var values: [NotificationsMode] = [.instant, .periodic, .off] +} + +enum PrivacyChatListOpenLinksMode: String, CaseIterable, Codable, RawRepresentable, Identifiable { + case yes + case no + case ask + + var id: Self { self } + + var text: LocalizedStringKey { + switch self { + case .yes: return "Yes" + case .no: return "No" + case .ask: return "Ask" + } + } +} + +struct RemoteCtrlInfo: Decodable { + var remoteCtrlId: Int64 + var ctrlDeviceName: String + var sessionState: RemoteCtrlSessionState? + + var deviceViewName: String { + ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName + } +} + +enum RemoteCtrlSessionState: Decodable { + case starting + case searching + case connecting + case pendingConfirmation(sessionCode: String) + case connected(sessionCode: String) +} + +enum RemoteCtrlStopReason: Decodable { + case discoveryFailed(chatError: ChatError) + case connectionFailed(chatError: ChatError) + case setupFailed(chatError: ChatError) + case disconnected +} + +struct CtrlAppInfo: Decodable { + var appVersionRange: AppVersionRange + var deviceName: String +} + +struct AppVersionRange: Decodable { + var minVersion: String + var maxVersion: String +} + +struct CoreVersionInfo: Decodable { + var version: String + var simplexmqVersion: String + var simplexmqCommit: String +} + +struct ArchiveConfig: Encodable { + var archivePath: String + var disableCompression: Bool? +} + +struct DBEncryptionConfig: Codable { + var currentKey: String + var newKey: String +} + +enum OperatorTag: String, Codable { + case simplex = "simplex" + case flux = "flux" +} + +struct ServerOperatorInfo { + var description: [String] + var website: URL + var selfhost: (text: String, link: URL)? = nil + var logo: String + var largeLogo: String + var logoDarkMode: String + var largeLogoDarkMode: String +} + +let operatorsInfo: Dictionary = [ + .simplex: ServerOperatorInfo( + description: [ + "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or identity keys.", + "SimpleX Chat Ltd develops the communication software for SimpleX network." + ], + website: URL(string: "https://simplex.chat")!, + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ), + .flux: ServerOperatorInfo( + description: [ + "Flux is the largest decentralized cloud, based on a global network of user-operated nodes.", + "Flux offers a powerful, scalable, and affordable cutting edge technology platform for all.", + "Flux operates servers in SimpleX network to improve its privacy and decentralization." + ], + website: URL(string: "https://runonflux.com")!, + selfhost: (text: "Self-host SimpleX servers on Flux", link: URL(string: "https://home.runonflux.io/apps/marketplace?q=simplex")!), + logo: "flux_logo_symbol", + largeLogo: "flux_logo", + logoDarkMode: "flux_logo_symbol", + largeLogoDarkMode: "flux_logo-light" + ), +] + +struct UsageConditions: Decodable { + var conditionsId: Int64 + var conditionsCommit: String + var notifiedAt: Date? + var createdAt: Date + + static var sampleData = UsageConditions( + conditionsId: 1, + conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c", + notifiedAt: nil, + createdAt: Date.now + ) +} + +enum UsageConditionsAction: Decodable { + case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool) + case accepted(operators: [ServerOperator]) + + var showNotice: Bool { + switch self { + case let .review(_, _, showNotice): showNotice + case .accepted: false + } + } +} + +struct ServerOperatorConditions: Decodable { + var serverOperators: [ServerOperator] + var currentConditions: UsageConditions + var conditionsAction: UsageConditionsAction? + + static var empty = ServerOperatorConditions( + serverOperators: [], + currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now), + conditionsAction: nil + ) +} + +enum ConditionsAcceptance: Equatable, Codable, Hashable { + case accepted(acceptedAt: Date?, autoAccepted: Bool) + // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. + // No deadline indicates it's required to accept conditions for the operator to start using it. + case required(deadline: Date?) + + var conditionsAccepted: Bool { + switch self { + case .accepted: true + case .required: false + } + } + + var usageAllowed: Bool { + switch self { + case .accepted: true + case let .required(deadline): deadline != nil + } + } +} + +struct ServerOperator: Identifiable, Equatable, Codable { + var operatorId: Int64 + var operatorTag: OperatorTag? + var tradeName: String + var legalName: String? + var serverDomains: [String] + var conditionsAcceptance: ConditionsAcceptance + var enabled: Bool + var smpRoles: ServerRoles + var xftpRoles: ServerRoles + + var id: Int64 { operatorId } + + static func == (l: ServerOperator, r: ServerOperator) -> Bool { + l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName && + l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled && + l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles + } + + var legalName_: String { + legalName ?? tradeName + } + + var info: ServerOperatorInfo { + return if let operatorTag = operatorTag { + operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo + } else { + ServerOperator.dummyOperatorInfo + } + } + + static let dummyOperatorInfo = ServerOperatorInfo( + description: ["Default"], + website: URL(string: "https://simplex.chat")!, + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ) + + func logo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.logo : info.logoDarkMode + } + + func largeLogo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode + } + + static var sampleData1 = ServerOperator( + operatorId: 1, + operatorTag: .simplex, + tradeName: "SimpleX Chat", + legalName: "SimpleX Chat Ltd", + serverDomains: ["simplex.im"], + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), + enabled: true, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) +} + +struct ServerRoles: Equatable, Codable { + var storage: Bool + var proxy: Bool +} + +struct UserOperatorServers: Identifiable, Equatable, Codable { + var `operator`: ServerOperator? + var smpServers: [UserServer] + var xftpServers: [UserServer] + + var id: String { + if let op = self.operator { + "\(op.operatorId)" + } else { + "nil operator" + } + } + + var operator_: ServerOperator { + get { + self.operator ?? ServerOperator( + operatorId: 0, + operatorTag: nil, + tradeName: "", + legalName: "", + serverDomains: [], + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), + enabled: false, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) + } + set { `operator` = newValue } + } + + static var sampleData1 = UserOperatorServers( + operator: ServerOperator.sampleData1, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) + + static var sampleDataNilOperator = UserOperatorServers( + operator: nil, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) +} + +enum UserServersError: Decodable { + case noServers(protocol: ServerProtocol, user: UserRef?) + case storageMissing(protocol: ServerProtocol, user: UserRef?) + case proxyMissing(protocol: ServerProtocol, user: UserRef?) + case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + + var globalError: String? { + switch self { + case let .noServers(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .storageMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .proxyMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + default: return nil + } + } + + var globalSMPError: String? { + switch self { + case let .noServers(.smp, user): + let text = NSLocalizedString("No message servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.smp, user): + let text = NSLocalizedString("No servers to receive messages.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.smp, user): + let text = NSLocalizedString("No servers for private message routing.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + var globalXFTPError: String? { + switch self { + case let .noServers(.xftp, user): + let text = NSLocalizedString("No media & file servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.xftp, user): + let text = NSLocalizedString("No servers to send files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.xftp, user): + let text = NSLocalizedString("No servers to receive files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + private func userStr(_ user: UserRef) -> String { + String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName) + } +} + +struct UserServer: Identifiable, Equatable, Codable, Hashable { + var serverId: Int64? + var server: String + var preset: Bool + var tested: Bool? + var enabled: Bool + var deleted: Bool + var createdAt = Date() + + static func == (l: UserServer, r: UserServer) -> Bool { + l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested && + l.enabled == r.enabled && l.deleted == r.deleted + } + + var id: String { "\(server) \(createdAt)" } + + static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false) + + var isEmpty: Bool { + server.trimmingCharacters(in: .whitespaces) == "" + } + + struct SampleData { + var preset: UserServer + var custom: UserServer + var untested: UserServer + var xftpPreset: UserServer + } + + static var sampleData = SampleData( + preset: UserServer( + serverId: 1, + server: "smp://abcd@smp8.simplex.im", + preset: true, + tested: true, + enabled: true, + deleted: false + ), + custom: UserServer( + serverId: 2, + server: "smp://abcd@smp9.simplex.im", + preset: false, + tested: false, + enabled: false, + deleted: false + ), + untested: UserServer( + serverId: 3, + server: "smp://abcd@smp10.simplex.im", + preset: false, + tested: nil, + enabled: true, + deleted: false + ), + xftpPreset: UserServer( + serverId: 4, + server: "xftp://abcd@xftp8.simplex.im", + preset: true, + tested: true, + enabled: true, + deleted: false + ) + ) + + enum CodingKeys: CodingKey { + case serverId + case server + case preset + case tested + case enabled + case deleted + } +} + +enum ProtocolTestStep: String, Decodable, Equatable { + case connect + case disconnect + case createQueue + case secureQueue + case deleteQueue + case createFile + case uploadFile + case downloadFile + case compareFile + case deleteFile + + var text: String { + switch self { + case .connect: return NSLocalizedString("Connect", comment: "server test step") + case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step") + case .createQueue: return NSLocalizedString("Create queue", comment: "server test step") + case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step") + case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step") + case .createFile: return NSLocalizedString("Create file", comment: "server test step") + case .uploadFile: return NSLocalizedString("Upload file", comment: "server test step") + case .downloadFile: return NSLocalizedString("Download file", comment: "server test step") + case .compareFile: return NSLocalizedString("Compare file", comment: "server test step") + case .deleteFile: return NSLocalizedString("Delete file", comment: "server test step") + } + } +} + +struct ProtocolTestFailure: Decodable, Error, Equatable { + var testStep: ProtocolTestStep + var testError: AgentErrorType + + static func == (l: ProtocolTestFailure, r: ProtocolTestFailure) -> Bool { + l.testStep == r.testStep + } + + var localizedDescription: String { + let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "server test failure"), testStep.text) + switch testError { + case .SMP(_, .AUTH): + return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error") + case .XFTP(.AUTH): + return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error") + case .BROKER(_, .NETWORK): + return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error") + default: + return err + } + } +} + +struct MigrationFileLinkData: Codable { + let networkConfig: NetworkConfig? + + struct NetworkConfig: Codable { + let socksProxy: String? + let networkProxy: NetworkProxy? + let hostMode: HostMode? + let requiredHostMode: Bool? + + func transformToPlatformSupported() -> NetworkConfig { + return if let hostMode, let requiredHostMode { + NetworkConfig( + socksProxy: nil, + networkProxy: nil, + hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode, + requiredHostMode: requiredHostMode + ) + } else { self } + } + } + + func addToLink(link: String) -> String { + "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)" + } + + static func readFromLink(link: String) -> MigrationFileLinkData? { +// standaloneFileInfo(link) + nil + } +} + +struct AppSettings: Codable, Equatable { + var networkConfig: NetCfg? = nil + var networkProxy: NetworkProxy? = nil + var privacyEncryptLocalFiles: Bool? = nil + var privacyAskToApproveRelays: Bool? = nil + var privacyAcceptImages: Bool? = nil + var privacyLinkPreviews: Bool? = nil + var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = nil + var privacyShowChatPreviews: Bool? = nil + var privacySaveLastDraft: Bool? = nil + var privacyProtectScreen: Bool? = nil + var privacyMediaBlurRadius: Int? = nil + var notificationMode: AppSettingsNotificationMode? = nil + var notificationPreviewMode: NotificationPreviewMode? = nil + var webrtcPolicyRelay: Bool? = nil + var webrtcICEServers: [String]? = nil + var confirmRemoteSessions: Bool? = nil + var connectRemoteViaMulticast: Bool? = nil + var connectRemoteViaMulticastAuto: Bool? = nil + var developerTools: Bool? = nil + var confirmDBUpgrades: Bool? = nil + var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil + var iosCallKitEnabled: Bool? = nil + var iosCallKitCallsInRecents: Bool? = nil + var uiProfileImageCornerRadius: Double? = nil + var uiChatItemRoundness: Double? = nil + var uiChatItemTail: Bool? = nil + var uiColorScheme: String? = nil + var uiDarkColorScheme: String? = nil + var uiCurrentThemeIds: [String: String]? = nil + var uiThemes: [ThemeOverrides]? = nil + var oneHandUI: Bool? = nil + var chatBottomBar: Bool? = nil + + func prepareForExport() -> AppSettings { + var empty = AppSettings() + let def = AppSettings.defaults + if networkConfig != def.networkConfig { empty.networkConfig = networkConfig } + if networkProxy != def.networkProxy { empty.networkProxy = networkProxy } + if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } + if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } + if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } + if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacyChatListOpenLinks != def.privacyChatListOpenLinks { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } + if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } + if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } + if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } + if privacyMediaBlurRadius != def.privacyMediaBlurRadius { empty.privacyMediaBlurRadius = privacyMediaBlurRadius } + if notificationMode != def.notificationMode { empty.notificationMode = notificationMode } + if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode } + if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay } + if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers } + if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions } + if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast } + if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto } + if developerTools != def.developerTools { empty.developerTools = developerTools } + if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades } + if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen } + if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled } + if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } + if uiProfileImageCornerRadius != def.uiProfileImageCornerRadius { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius } + if uiChatItemRoundness != def.uiChatItemRoundness { empty.uiChatItemRoundness = uiChatItemRoundness } + if uiChatItemTail != def.uiChatItemTail { empty.uiChatItemTail = uiChatItemTail } + if uiColorScheme != def.uiColorScheme { empty.uiColorScheme = uiColorScheme } + if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme } + if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds } + if uiThemes != def.uiThemes { empty.uiThemes = uiThemes } + if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } + if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar } + return empty + } + + static var defaults: AppSettings { + AppSettings ( + networkConfig: NetCfg.defaults, + networkProxy: NetworkProxy.def, + privacyEncryptLocalFiles: true, + privacyAskToApproveRelays: true, + privacyAcceptImages: true, + privacyLinkPreviews: true, + privacyChatListOpenLinks: .ask, + privacyShowChatPreviews: true, + privacySaveLastDraft: true, + privacyProtectScreen: false, + privacyMediaBlurRadius: 0, + notificationMode: AppSettingsNotificationMode.instant, + notificationPreviewMode: NotificationPreviewMode.message, + webrtcPolicyRelay: true, + webrtcICEServers: [], + confirmRemoteSessions: false, + connectRemoteViaMulticast: true, + connectRemoteViaMulticastAuto: true, + developerTools: false, + confirmDBUpgrades: false, + androidCallOnLockScreen: AppSettingsLockScreenCalls.show, + iosCallKitEnabled: true, + iosCallKitCallsInRecents: false, + uiProfileImageCornerRadius: 22.5, + uiChatItemRoundness: 0.75, + uiChatItemTail: true, + uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME, + uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName, + uiCurrentThemeIds: nil as [String: String]?, + uiThemes: nil as [ThemeOverrides]?, + oneHandUI: true, + chatBottomBar: true + ) + } +} + +enum AppSettingsNotificationMode: String, Codable { + case off + case periodic + case instant + + func toNotificationsMode() -> NotificationsMode { + switch self { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } + + static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode { + switch mode { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } +} + +//enum NotificationPreviewMode: Codable { +// case hidden +// case contact +// case message +//} + +enum AppSettingsLockScreenCalls: String, Codable { + case disable + case show + case accept +} + +struct UserNetworkInfo: Codable, Equatable { + let networkType: UserNetworkType + let online: Bool +} + +enum UserNetworkType: String, Codable { + case none + case cellular + case wifi + case ethernet + case other + + var text: LocalizedStringKey { + switch self { + case .none: "No network connection" + case .cellular: "Cellular" + case .wifi: "WiFi" + case .ethernet: "Wired ethernet" + case .other: "Other" + } + } +} + +struct RcvMsgInfo: Codable { + var msgId: Int64 + var msgDeliveryId: Int64 + var msgDeliveryStatus: String + var agentMsgId: Int64 + var agentMsgMeta: String +} + +struct ServerQueueInfo: Codable { + var server: String + var rcvId: String + var sndId: String + var ntfId: String? + var status: String + var info: QueueInfo +} + +struct QueueInfo: Codable { + var qiSnd: Bool + var qiNtf: Bool + var qiSub: QSub? + var qiSize: Int + var qiMsg: MsgInfo? +} + +struct QSub: Codable { + var qSubThread: QSubThread + var qDelivered: String? +} + +enum QSubThread: String, Codable { + case noSub + case subPending + case subThread + case prohibitSub +} + +struct MsgInfo: Codable { + var msgId: String + var msgTs: Date + var msgType: MsgType +} + +enum MsgType: String, Codable { + case message + case quota +} + +struct PresentedServersSummary: Codable { + var statsStartedAt: Date + var allUsersSMP: SMPServersSummary + var allUsersXFTP: XFTPServersSummary + var currentUserSMP: SMPServersSummary + var currentUserXFTP: XFTPServersSummary +} + +struct SMPServersSummary: Codable { + var smpTotals: SMPTotals + var currentlyUsedSMPServers: [SMPServerSummary] + var previouslyUsedSMPServers: [SMPServerSummary] + var onlyProxiedSMPServers: [SMPServerSummary] +} + +struct SMPTotals: Codable { + var sessions: ServerSessions + var subs: SMPServerSubs + var stats: AgentSMPServerStatsData +} + +struct SMPServerSummary: Codable, Identifiable { + var smpServer: String + var known: Bool? + var sessions: ServerSessions? + var subs: SMPServerSubs? + var stats: AgentSMPServerStatsData? + + var id: String { smpServer } + + var hasSubs: Bool { subs != nil } + + var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions } + + var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs } +} + +struct ServerSessions: Codable { + var ssConnected: Int + var ssErrors: Int + var ssConnecting: Int + + static var newServerSessions = ServerSessions( + ssConnected: 0, + ssErrors: 0, + ssConnecting: 0 + ) + + var hasSess: Bool { ssConnected > 0 } +} + +struct SMPServerSubs: Codable { + var ssActive: Int + var ssPending: Int + + static var newSMPServerSubs = SMPServerSubs( + ssActive: 0, + ssPending: 0 + ) + + var total: Int { ssActive + ssPending } + + var shareOfActive: Double { + guard total != 0 else { return 0.0 } + return Double(ssActive) / Double(total) + } +} + +struct AgentSMPServerStatsData: Codable { + var _sentDirect: Int + var _sentViaProxy: Int + var _sentProxied: Int + var _sentDirectAttempts: Int + var _sentViaProxyAttempts: Int + var _sentProxiedAttempts: Int + var _sentAuthErrs: Int + var _sentQuotaErrs: Int + var _sentExpiredErrs: Int + var _sentOtherErrs: Int + var _recvMsgs: Int + var _recvDuplicates: Int + var _recvCryptoErrs: Int + var _recvErrs: Int + var _ackMsgs: Int + var _ackAttempts: Int + var _ackNoMsgErrs: Int + var _ackOtherErrs: Int + var _connCreated: Int + var _connSecured: Int + var _connCompleted: Int + var _connDeleted: Int + var _connDelAttempts: Int + var _connDelErrs: Int + var _connSubscribed: Int + var _connSubAttempts: Int + var _connSubIgnored: Int + var _connSubErrs: Int + var _ntfKey: Int + var _ntfKeyAttempts: Int + var _ntfKeyDeleted: Int + var _ntfKeyDeleteAttempts: Int +} + +struct XFTPServersSummary: Codable { + var xftpTotals: XFTPTotals + var currentlyUsedXFTPServers: [XFTPServerSummary] + var previouslyUsedXFTPServers: [XFTPServerSummary] +} + +struct XFTPTotals: Codable { + var sessions: ServerSessions + var stats: AgentXFTPServerStatsData +} + +struct XFTPServerSummary: Codable, Identifiable { + var xftpServer: String + var known: Bool? + var sessions: ServerSessions? + var stats: AgentXFTPServerStatsData? + var rcvInProgress: Bool + var sndInProgress: Bool + var delInProgress: Bool + + var id: String { xftpServer } +} + +struct AgentXFTPServerStatsData: Codable { + var _uploads: Int + var _uploadsSize: Int64 + var _uploadAttempts: Int + var _uploadErrs: Int + var _downloads: Int + var _downloadsSize: Int64 + var _downloadAttempts: Int + var _downloadAuthErrs: Int + var _downloadErrs: Int + var _deletions: Int + var _deleteAttempts: Int + var _deleteErrs: Int +} + +struct AgentNtfServerStatsData: Codable { + var _ntfCreated: Int + var _ntfCreateAttempts: Int + var _ntfChecked: Int + var _ntfCheckAttempts: Int + var _ntfDeleted: Int + var _ntfDelAttempts: Int +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 22d004f4d9..4e9c8ce7b6 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -11,7 +11,7 @@ import UIKit import Dispatch import BackgroundTasks import SwiftUI -import SimpleXChat +@preconcurrency import SimpleXChat private var chatController: chat_ctrl? @@ -91,7 +91,7 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = logger.debug("chatSendCmd \(cmd.cmdType)") } let start = Date.now - let resp = bgTask + let resp: ChatResponse = bgTask ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) } : sendSimpleXCmd(cmd, ctrl) if log { @@ -115,7 +115,7 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? { await withCheckedContinuation { cont in _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in - let resp = recvSimpleXMsg(ctrl) + let resp: ChatResponse? = recvSimpleXMsg(ctrl) cont.resume(returning: resp) return resp } @@ -123,7 +123,7 @@ func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? { } func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { - let r = chatSendCmdSync(.showActiveUser, ctrl) + let r: ChatResponse = chatSendCmdSync(.showActiveUser, ctrl) switch r { case let .activeUser(user): return user case .chatCmdError(_, .error(.noActiveUser)): return nil @@ -132,7 +132,7 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { } func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { - let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl) + let r: ChatResponse = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl) if case let .activeUser(user) = r { return user } throw r } @@ -199,19 +199,19 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User { } func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User { - let r = await chatSendCmd(cmd) + let r: ChatResponse = await chatSendCmd(cmd) if case let .userPrivacy(_, updatedUser) = r { return updatedUser } throw r } func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws { - let r = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd)) + let r: ChatResponse = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd)) if case .cmdOk = r { return } throw r } func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool { - let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl) + let r: ChatResponse = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl) switch r { case .chatStarted: return true case .chatRunning: return false @@ -890,7 +890,7 @@ func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqT logger.error("apiConnect: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink)) + let r: ChatResponse = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink)) let m = ChatModel.shared switch r { case let .sentConfirmation(_, connection): @@ -1281,7 +1281,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool case let .rcvFileAccepted(_, chatItem): await chatItemSimpleUpdate(user, chatItem) default: - if let chatError = chatError(r) { + if let chatError = r.chatErrorType { switch chatError { case let .fileNotApproved(fileId, unknownServers): fileIdsToApprove.append(fileId) @@ -1348,7 +1348,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool ) } default: - if let chatError = chatError(errorResponse) { + if let chatError = errorResponse.chatErrorType { switch chatError { case .fileCancelled, .fileAlreadyReceiving: logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") @@ -1635,7 +1635,7 @@ func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { // use ChatModel's loadGroupMembers from views func apiListMembers(_ groupId: Int64) async -> [GroupMember] { - let r = await chatSendCmd(.apiListMembers(groupId: groupId)) + let r: ChatResponse = await chatSendCmd(.apiListMembers(groupId: groupId)) if case let .groupMembers(_, group) = r { return group.members } return [] } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 3e8fd131a5..dfe9e37bd6 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -752,7 +752,7 @@ private class MigrationChatReceiver { func receiveMsgLoop() async { // TODO use function that has timeout - if let msg = await chatRecvMsg(ctrl) { + if let msg: ChatResponse = await chatRecvMsg(ctrl) { Task { await TerminalItems.shared.add(.resp(.now, msg)) } diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift new file mode 100644 index 0000000000..b1ab5e76c2 --- /dev/null +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -0,0 +1,173 @@ +// +// APITypes.swift +// SimpleX +// +// Created by EP on 01/05/2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SimpleXChat + +enum NSEChatCommand: ChatCmdProtocol { + case showActiveUser + case startChat(mainApp: Bool, enableSndFiles: Bool) + case apiActivateChat(restoreChat: Bool) + case apiSuspendChat(timeoutMicroseconds: Int) + case apiSetNetworkConfig(networkConfig: NetCfg) + case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) + case apiSetEncryptLocalFiles(enable: Bool) + case apiGetNtfConns(nonce: String, encNtfInfo: String) + case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) + case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) + case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) + + var cmdString: String { + switch self { + case .showActiveUser: return "/u" + case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" + case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" + case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): + return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" + case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" + 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))" + } + } + + private func onOffParam(_ param: String, _ b: Bool?) -> String { + if let b = b { + " \(param)=\(onOff(b))" + } else { + "" + } + } +} + +enum NSEChatResponse: Decodable, Error, ChatRespProtocol { + case response(type: String, json: String) + case activeUser(user: User) + case chatStarted + case chatRunning + case chatSuspended + case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) + case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case rcvFileAccepted(user: UserRef, chatItem: AChatItem) + case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case callInvitation(callInvitation: RcvCallInvitation) + case ntfConns(ntfConns: [NtfConn]) + case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) + case cmdOk(user_: UserRef?) + case chatCmdError(user_: UserRef?, chatError: ChatError) + case chatError(user_: UserRef?, chatError: ChatError) + + var responseType: String { + switch self { + case let .response(type, _): "* \(type)" + case .activeUser: "activeUser" + case .chatStarted: "chatStarted" + case .chatRunning: "chatRunning" + case .chatSuspended: "chatSuspended" + case .contactConnected: "contactConnected" + case .receivedContactRequest: "receivedContactRequest" + case .newChatItems: "newChatItems" + case .rcvFileAccepted: "rcvFileAccepted" + case .rcvFileSndCancelled: "rcvFileSndCancelled" + case .sndFileComplete: "sndFileComplete" + case .sndFileRcvCancelled: "sndFileRcvCancelled" + case .callInvitation: "callInvitation" + case .ntfConns: "ntfConns" + case .connNtfMessages: "connNtfMessages" + case .ntfMessage: "ntfMessage" + case .cmdOk: "cmdOk" + case .chatCmdError: "chatCmdError" + case .chatError: "chatError" + } + } + + var details: String { + switch self { + case let .response(_, json): return json + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case .chatSuspended: return noDetails + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .callInvitation(inv): return String(describing: inv) + case let .ntfConns(ntfConns): return String(describing: ntfConns) + case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") + case .cmdOk: return noDetails + case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) + } + } + + var noDetails: String { "\(responseType): no details" } + + static func chatResponse(_ s: String) -> NSEChatResponse { + let d = s.data(using: .utf8)! + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { + type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } + if type == "chatCmdError" { + if let jError = jResp["chatCmdError"] as? NSDictionary { + return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } else if type == "chatError" { + if let jError = jResp["chatError"] as? NSDictionary { + return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } + } + json = serializeJSON(j, options: .prettyPrinted) + } + return NSEChatResponse.response(type: type ?? "invalid", json: json ?? s) + } + + var chatError: ChatError? { + switch self { + case let .chatCmdError(_, error): error + case let .chatError(_, error): error + default: nil + } + } + + var chatErrorType: ChatErrorType? { + switch self { + case let .chatCmdError(_, .error(error)): error + case let .chatError(_, .error(error)): error + default: nil + } + } +} diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index c82869a36a..0bfa21781e 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -789,9 +789,9 @@ func receiveMessages() async { } } -func chatRecvMsg() async -> ChatResponse? { +func chatRecvMsg() async -> NSEChatResponse? { await withCheckedContinuation { cont in - let resp = recvSimpleXMsg() + let resp: NSEChatResponse? = recvSimpleXMsg() cont.resume(returning: resp) } } @@ -799,7 +799,7 @@ func chatRecvMsg() async -> ChatResponse? { private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } -func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? { +func receivedMsgNtf(_ res: NSEChatResponse) async -> (String, NSENotificationData)? { logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") switch res { case let .contactConnected(user, contact, _): @@ -868,7 +868,7 @@ func updateNetCfg() { } func apiGetActiveUser() -> User? { - let r = sendSimpleXCmd(.showActiveUser) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.showActiveUser) logger.debug("apiGetActiveUser sendSimpleXCmd response: \(r.responseType)") switch r { case let .activeUser(user): return user @@ -885,7 +885,7 @@ func apiGetActiveUser() -> User? { } func apiStartChat() throws -> Bool { - let r = sendSimpleXCmd(.startChat(mainApp: false, enableSndFiles: false)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.startChat(mainApp: false, enableSndFiles: false)) switch r { case .chatStarted: return true case .chatRunning: return false @@ -895,27 +895,27 @@ func apiStartChat() throws -> Bool { func apiActivateChat() -> Bool { chatReopenStore() - let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiActivateChat(restoreChat: false)) if case .cmdOk = r { return true } logger.error("NotificationService apiActivateChat error: \(String(describing: r))") return false } func apiSuspendChat(timeoutMicroseconds: Int) -> Bool { - let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) if case .cmdOk = r { return true } logger.error("NotificationService apiSuspendChat error: \(String(describing: r))") return false } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws { - let r = sendSimpleXCmd(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) if case .cmdOk = r { return } throw r } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } throw r } @@ -925,7 +925,7 @@ func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [NtfConn]? { logger.debug("no active user") return nil } - let r = sendSimpleXCmd(.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) if case let .ntfConns(ntfConns) = r { logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)") return ntfConns @@ -942,8 +942,8 @@ func apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) -> [NtfMsgInfo?]? { logger.debug("no active user") return nil } - logger.debug("apiGetConnNtfMessages command: \(ChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)") - let r = sendSimpleXCmd(.apiGetConnNtfMessages(connMsgReqs: connMsgReqs)) + logger.debug("apiGetConnNtfMessages command: \(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)") + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs)) if case let .connNtfMessages(receivedMsgs) = r { logger.debug("apiGetConnNtfMessages response receivedMsgs: total \(receivedMsgs.count), expecting messages \(receivedMsgs.count { $0 != nil })") return receivedMsgs @@ -962,7 +962,7 @@ func getConnNtfMessage(connMsgReq: ConnMsgReq) -> NtfMsgInfo? { func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? { let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get() - let r = sendSimpleXCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } logger.error("receiveFile error: \(responseError(r))") return nil @@ -970,7 +970,7 @@ func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> ACha func apiSetFileToReceive(fileId: Int64, encrypted: Bool) { let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get() - let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted)) if case .cmdOk = r { return } logger.error("setFileToReceive error: \(responseError(r))") } @@ -989,7 +989,7 @@ func autoReceiveFile(_ file: CIFile) -> ChatItem? { } func setNetworkConfig(_ cfg: NetCfg) throws { - let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg)) + let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) if case .cmdOk = r { return } throw r } diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index fcb78c64b1..56f1c2f5f3 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -13,7 +13,7 @@ import SimpleXChat let logger = Logger() func apiGetActiveUser() throws -> User? { - let r = sendSimpleXCmd(.showActiveUser) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.showActiveUser) switch r { case let .activeUser(user): return user case .chatCmdError(_, .error(.noActiveUser)): return nil @@ -22,7 +22,7 @@ func apiGetActiveUser() throws -> User? { } func apiStartChat() throws -> Bool { - let r = sendSimpleXCmd(.startChat(mainApp: false, enableSndFiles: true)) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.startChat(mainApp: false, enableSndFiles: true)) switch r { case .chatStarted: return true case .chatRunning: return false @@ -31,25 +31,25 @@ func apiStartChat() throws -> Bool { } func apiSetNetworkConfig(_ cfg: NetCfg) throws { - let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg)) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) if case .cmdOk = r { return } throw r } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws { - let r = sendSimpleXCmd(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) if case .cmdOk = r { return } throw r } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } throw r } func apiGetChats(userId: User.ID) throws -> Array { - let r = sendSimpleXCmd(.apiGetChats(userId: userId)) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiGetChats(userId: userId)) if case let .apiChats(user: _, chats: chats) = r { return chats } throw r } @@ -58,13 +58,13 @@ func apiSendMessages( chatInfo: ChatInfo, composedMessages: [ComposedMessage] ) throws -> [AChatItem] { - let r = sendSimpleXCmd( + let r: SEChatResponse = sendSimpleXCmd( chatInfo.chatType == .local - ? .apiCreateChatItems( + ? SEChatCommand.apiCreateChatItems( noteFolderId: chatInfo.apiId, composedMessages: composedMessages ) - : .apiSendMessages( + : SEChatCommand.apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, live: false, @@ -84,19 +84,20 @@ func apiSendMessages( func apiActivateChat() throws { chatReopenStore() - let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiActivateChat(restoreChat: false)) if case .cmdOk = r { return } throw r } func apiSuspendChat(expired: Bool) { - let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000)) + let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000)) // Block until `chatSuspended` received or 3 seconds has passed var suspended = false if case .cmdOk = r, !expired { let startTime = CFAbsoluteTimeGetCurrent() while CFAbsoluteTimeGetCurrent() - startTime < 3 { - switch recvSimpleXMsg(messageTimeout: 3_500000) { + let msg: SEChatResponse? = recvSimpleXMsg(messageTimeout: 3_500000) + switch msg { case .chatSuspended: suspended = false break @@ -105,9 +106,166 @@ func apiSuspendChat(expired: Bool) { } } if !suspended { - _ = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: 0)) + let _r1: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: 0)) } logger.debug("close store") chatCloseStore() SEChatState.shared.set(.inactive) } + +enum SEChatCommand: ChatCmdProtocol { + case showActiveUser + case startChat(mainApp: Bool, enableSndFiles: Bool) + case apiActivateChat(restoreChat: Bool) + case apiSuspendChat(timeoutMicroseconds: Int) + case apiSetNetworkConfig(networkConfig: NetCfg) + case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) + 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]) + + var cmdString: String { + switch self { + case .showActiveUser: return "/u" + case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" + case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" + case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): + return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" + case let .apiCreateChatItems(noteFolderId, composedMessages): + let msgs = encodeJSON(composedMessages) + return "/_create *\(noteFolderId) json \(msgs)" + case let .apiSendMessages(type, id, 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)" + } + } + + func ref(_ type: ChatType, _ id: Int64) -> String { + "\(type.rawValue)\(id)" + } +} + +enum SEChatResponse: Decodable, Error, ChatRespProtocol { + case response(type: String, json: String) + case activeUser(user: User) + case chatStarted + case chatRunning + case chatSuspended + case apiChats(user: UserRef, chats: [ChatData]) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) + case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case cmdOk(user_: UserRef?) + case chatCmdError(user_: UserRef?, chatError: ChatError) + case chatError(user_: UserRef?, chatError: ChatError) + + var responseType: String { + switch self { + case let .response(type, _): "* \(type)" + case .activeUser: "activeUser" + case .chatStarted: "chatStarted" + case .chatRunning: "chatRunning" + case .chatSuspended: "chatSuspended" + case .apiChats: "apiChats" + case .newChatItems: "newChatItems" + case .sndFileProgressXFTP: "sndFileProgressXFTP" + case .sndFileCompleteXFTP: "sndFileCompleteXFTP" + case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" + case .sndFileError: "sndFileError" + case .sndFileWarning: "sndFileWarning" + case .cmdOk: "cmdOk" + case .chatCmdError: "chatCmdError" + case .chatError: "chatError" + } + } + + var details: String { + switch self { + case let .response(_, json): return json + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case .chatSuspended: return noDetails + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .chatItemsStatusesUpdated(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case .cmdOk: return noDetails + case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) + } + } + + var noDetails: String { "\(responseType): no details" } + + static func chatResponse(_ s: String) -> SEChatResponse { + let d = s.data(using: .utf8)! + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { + type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } + if type == "apiChats" { + if let r = parseApiChats(jResp) { + return .apiChats(user: r.user, chats: r.chats) + } + } else if type == "chatCmdError" { + if let jError = jResp["chatCmdError"] as? NSDictionary { + return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } else if type == "chatError" { + if let jError = jResp["chatError"] as? NSDictionary { + return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } + } + json = serializeJSON(j, options: .prettyPrinted) + } + return SEChatResponse.response(type: type ?? "invalid", json: json ?? s) + } + + var chatError: ChatError? { + switch self { + case let .chatCmdError(_, error): error + case let .chatError(_, error): error + default: nil + } + } + + var chatErrorType: ChatErrorType? { + switch self { + case let .chatCmdError(_, .error(error)): error + case let .chatError(_, .error(error)): error + default: nil + } + } +} diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 88e174e8bc..a555c14472 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -303,7 +303,8 @@ class ShareModel: ObservableObject { } } } - switch recvSimpleXMsg(messageTimeout: 1_000_000) { + let r: SEChatResponse? = recvSimpleXMsg(messageTimeout: 1_000_000) + switch r { case let .sndFileProgressXFTP(_, ci, _, sentSize, totalSize): guard isMessage(for: ci) else { continue } networkTimeout = CFAbsoluteTimeGetCurrent() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5346e706a2..c7fd19e615 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -242,6 +242,8 @@ E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; }; + E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */; }; + E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -643,6 +645,8 @@ E5DCF9A62C590731007928CC /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; E5DCF9A72C590732007928CC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = ""; }; + E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = ""; }; + E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -795,6 +799,7 @@ 5C764E87279CBC8E000C6508 /* Model */ = { isa = PBXGroup; children = ( + E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */, 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, @@ -990,6 +995,7 @@ isa = PBXGroup; children = ( 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, @@ -1006,9 +1012,9 @@ 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, 5CDCAD7428188D2900503DA2 /* APITypes.swift */, 5C5E5D3C282447AB00B0488A /* CallTypes.swift */, + 5CDCAD7D2818941F00503DA2 /* API.swift */, CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */, 5C9FD96A27A56D4D0075386C /* JSON.swift */, - 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */, @@ -1534,6 +1540,7 @@ 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */, 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */, + E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */, 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, @@ -1606,6 +1613,7 @@ buildActionMask = 2147483647; files = ( 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, + E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 869dffea31..b10b544a43 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -110,19 +110,19 @@ public func resetChatCtrl() { migrationResult = nil } -public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse { +public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil) -> CR { var c = cmd.cmdString.cString(using: .utf8)! let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)! - return chatResponse(fromCString(cjson)) + return CR.chatResponse(fromCString(cjson)) } // in microseconds public let MESSAGE_TIMEOUT: Int32 = 15_000_000 -public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> ChatResponse? { +public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> CR? { if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout) { let s = fromCString(cjson) - return s == "" ? nil : chatResponse(s) + return s == "" ? nil : CR.chatResponse(s) } return nil } @@ -177,89 +177,7 @@ public func fromCString(_ c: UnsafeMutablePointer) -> String { return s } -public func chatResponse(_ s: String) -> ChatResponse { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = try callWithLargeStack { - try jsonDecoder.decode(APIResponse.self, from: d) - } - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "apiChats" { - if let jApiChats = jResp["apiChats"] as? NSDictionary, - let user: UserRef = try? decodeObject(jApiChats["user"] as Any), - let jChats = jApiChats["chats"] as? NSArray { - let chats = jChats.map { jChat in - if let chatData = try? parseChatData(jChat) { - return chatData.0 - } - return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "") - } - return .apiChats(user: user, chats: chats) - } - } else if type == "apiChat" { - if let jApiChat = jResp["apiChat"] as? NSDictionary, - let user: UserRef = try? decodeObject(jApiChat["user"] as Any), - let jChat = jApiChat["chat"] as? NSDictionary, - let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) { - return .apiChat(user: user, chat: chat, navInfo: navInfo) - } - } else if type == "chatCmdError" { - if let jError = jResp["chatCmdError"] as? NSDictionary { - return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } else if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return ChatResponse.response(type: type ?? "invalid", json: json ?? s) -} - -private let largeStackSize: Int = 2 * 1024 * 1024 - -private func callWithLargeStack(_ f: @escaping () throws -> T) throws -> T { - let semaphore = DispatchSemaphore(value: 0) - var result: Result? - let thread = Thread { - do { - result = .success(try f()) - } catch { - result = .failure(error) - } - semaphore.signal() - } - - thread.stackSize = largeStackSize - thread.qualityOfService = Thread.current.qualityOfService - thread.start() - - semaphore.wait() - - switch result! { - case let .success(r): return r - case let .failure(e): throw e - } -} - -private func decodeUser_(_ jDict: NSDictionary) -> UserRef? { +public func decodeUser_(_ jDict: NSDictionary) -> UserRef? { if let user_ = jDict["user_"] { try? decodeObject(user_ as Any) } else { @@ -267,7 +185,7 @@ private func decodeUser_(_ jDict: NSDictionary) -> UserRef? { } } -private func errorJson(_ jDict: NSDictionary) -> String? { +public func errorJson(_ jDict: NSDictionary) -> String? { if let chatError = jDict["chatError"] { serializeJSON(chatError) } else { @@ -275,7 +193,7 @@ private func errorJson(_ jDict: NSDictionary) -> String? { } } -func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) { +public func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) { let jChatDict = jChat as! NSDictionary let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!) let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!) @@ -294,7 +212,7 @@ func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, Na return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo) } -func decodeObject(_ obj: Any) throws -> T { +public func decodeObject(_ obj: Any) throws -> T { try jsonDecoder.decode(T.self, from: JSONSerialization.data(withJSONObject: obj)) } @@ -305,7 +223,7 @@ func decodeProperty(_ obj: Any, _ prop: NSString) -> T? { return nil } -func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> String? { +public func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> String? { if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) { return String(decoding: d, as: UTF8.self) } @@ -313,14 +231,14 @@ func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) - } public func responseError(_ err: Error) -> String { - if let r = err as? ChatResponse { - switch r { - case let .chatCmdError(_, chatError): return chatErrorString(chatError) - case let .chatError(_, chatError): return chatErrorString(chatError) - default: return "\(String(describing: r.responseType)), details: \(String(describing: r.details))" + if let r = err as? ChatRespProtocol { + if let e = r.chatError { + chatErrorString(e) + } else { + "\(String(describing: r.responseType)), details: \(String(describing: r.details))" } } else { - return String(describing: err) + String(describing: err) } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 18d0cbdb3c..3cfe67e158 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -13,1164 +13,47 @@ import Network public let jsonDecoder = getJSONDecoder() public let jsonEncoder = getJSONEncoder() -public enum ChatCommand { - case showActiveUser - case createActiveUser(profile: Profile?, pastTimestamp: Bool) - case listUsers - case apiSetActiveUser(userId: Int64, viewPwd: String?) - case setAllContactReceipts(enable: Bool) - case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) - case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) - case apiHideUser(userId: Int64, viewPwd: String) - case apiUnhideUser(userId: Int64, viewPwd: String) - case apiMuteUser(userId: Int64) - case apiUnmuteUser(userId: Int64) - case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) - case startChat(mainApp: Bool, enableSndFiles: Bool) - case checkChatRunning - case apiStopChat - case apiActivateChat(restoreChat: Bool) - case apiSuspendChat(timeoutMicroseconds: Int) - case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) - case apiSetEncryptLocalFiles(enable: Bool) - case apiExportArchive(config: ArchiveConfig) - case apiImportArchive(config: ArchiveConfig) - case apiDeleteStorage - case apiStorageEncryption(config: DBEncryptionConfig) - case testStorageEncryption(key: String) - case apiSaveSettings(settings: AppSettings) - 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 apiCreateChatTag(tag: ChatTagData) - case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) - case apiDeleteChatTag(tagId: Int64) - case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData) - 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 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 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 apiGetNtfToken - case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) - case apiVerifyToken(token: DeviceToken, nonce: String, code: String) - case apiCheckToken(token: DeviceToken) - case apiDeleteToken(token: DeviceToken) - case apiGetNtfConns(nonce: String, encNtfInfo: String) - case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) - case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) - case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) - case apiJoinGroup(groupId: 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) - case apiLeaveGroup(groupId: Int64) - case apiListMembers(groupId: Int64) - case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) - case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole, short: Bool) - case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) - case apiDeleteGroupLink(groupId: Int64) - case apiGetGroupLink(groupId: Int64) - case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) - case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) - case apiTestProtoServer(userId: Int64, server: String) - case apiGetServerOperators - case apiSetServerOperators(operators: [ServerOperator]) - case apiGetUserServers(userId: Int64) - case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers]) - case apiValidateServers(userId: Int64, userServers: [UserOperatorServers]) - case apiGetUsageConditions - case apiSetConditionsNotified(conditionsId: Int64) - case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) - case apiSetChatItemTTL(userId: Int64, seconds: Int64) - case apiGetChatItemTTL(userId: Int64) - case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?) - case apiSetNetworkConfig(networkConfig: NetCfg) - case apiGetNetworkConfig - case apiSetNetworkInfo(networkInfo: UserNetworkInfo) - case reconnectAllServers - case reconnectServer(userId: Int64, smpServer: String) - case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) - case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) - case apiContactInfo(contactId: Int64) - case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) - case apiContactQueueInfo(contactId: Int64) - case apiGroupMemberQueueInfo(groupId: Int64, groupMemberId: Int64) - case apiSwitchContact(contactId: Int64) - case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64) - case apiAbortSwitchContact(contactId: Int64) - case apiAbortSwitchGroupMember(groupId: Int64, groupMemberId: Int64) - case apiSyncContactRatchet(contactId: Int64, force: Bool) - case apiSyncGroupMemberRatchet(groupId: Int64, groupMemberId: Int64, force: Bool) - case apiGetContactCode(contactId: Int64) - case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) - case apiVerifyContact(contactId: Int64, connectionCode: String?) - case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) - case apiAddContact(userId: Int64, short: Bool, incognito: Bool) - case apiSetConnectionIncognito(connId: Int64, incognito: Bool) - case apiChangeConnectionUser(connId: Int64, userId: Int64) - case apiConnectPlan(userId: Int64, connLink: String) - case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink) - case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) - case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode) - case apiClearChat(type: ChatType, id: Int64) - case apiListContacts(userId: Int64) - case apiUpdateProfile(userId: Int64, profile: Profile) - case apiSetContactPrefs(contactId: Int64, preferences: Preferences) - case apiSetContactAlias(contactId: Int64, localAlias: String) - case apiSetGroupAlias(groupId: Int64, localAlias: String) - case apiSetConnectionAlias(connId: Int64, localAlias: String) - case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) - case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) - case apiCreateMyAddress(userId: Int64, short: Bool) - case apiDeleteMyAddress(userId: Int64) - case apiShowMyAddress(userId: Int64) - case apiSetProfileAddress(userId: Int64, on: Bool) - case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) - case apiAcceptContact(incognito: Bool, contactReqId: Int64) - case apiRejectContact(contactReqId: Int64) - // WebRTC calls - case apiSendCallInvitation(contact: Contact, callType: CallType) - case apiRejectCall(contact: Contact) - case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer) - case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) - case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) - case apiEndCall(contact: Contact) - case apiGetCallInvitations - 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 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?) - case cancelFile(fileId: Int64) - // remote desktop commands - case setLocalDeviceName(displayName: String) - case connectRemoteCtrl(xrcpInvitation: String) - case findKnownRemoteCtrl - case confirmRemoteCtrl(remoteCtrlId: Int64) - case verifyRemoteCtrlSession(sessionCode: String) - case listRemoteCtrls - case stopRemoteCtrl - case deleteRemoteCtrl(remoteCtrlId: Int64) - case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) - case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) - case apiStandaloneFileInfo(url: String) - // misc - case showVersion - case getAgentSubsTotal(userId: Int64) - case getAgentServersSummary(userId: Int64) - case resetAgentServersStats - case string(String) - - public var cmdString: String { - get { - switch self { - case .showActiveUser: return "/u" - case let .createActiveUser(profile, pastTimestamp): - let user = NewUser(profile: profile, pastTimestamp: pastTimestamp) - return "/_create user \(encodeJSON(user))" - case .listUsers: return "/users" - case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" - case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))" - case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings): - let umrs = userMsgReceiptSettings - return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" - case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings): - let umrs = userMsgReceiptSettings - return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" - case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" - case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" - case let .apiMuteUser(userId): return "/_mute user \(userId)" - case let .apiUnmuteUser(userId): return "/_unmute user \(userId)" - case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" - case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))" - case .checkChatRunning: return "/_check running" - case .apiStopChat: return "/_stop" - case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" - case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" - case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder): return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))" - case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" - case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" - case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" - case .apiDeleteStorage: return "/_db delete" - case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" - case let .testStorageEncryption(key): return "/db test key \(key)" - case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))" - 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): - let msgs = encodeJSON(composedMessages) - let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id)) 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 .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: ","))" - case let .apiCreateChatItems(noteFolderId, composedMessages): - let msgs = encodeJSON(composedMessages) - 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 .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 .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): - let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(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)" - case let .apiCheckToken(token): return "/_ntf check \(token.cmdString)" - case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" - case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" - case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" - 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 .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))" - case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" - case let .apiListMembers(groupId): return "/_members #\(groupId)" - case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" - case let .apiCreateGroupLink(groupId, memberRole, short): return "/_create link #\(groupId) \(memberRole) short=\(onOff(short))" - case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" - case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" - case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" - case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" - case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" - case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" - case .apiGetServerOperators: return "/_operators" - case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" - case let .apiGetUserServers(userId): return "/_servers \(userId)" - case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))" - case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))" - case .apiGetUsageConditions: return "/_conditions" - case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)" - 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 .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 .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" - case let .apiContactInfo(contactId): return "/_info @\(contactId)" - case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" - case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)" - case let .apiGroupMemberQueueInfo(groupId, groupMemberId): return "/_queue info #\(groupId) \(groupMemberId)" - case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" - case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)" - case let .apiAbortSwitchContact(contactId): return "/_abort switch @\(contactId)" - case let .apiAbortSwitchGroupMember(groupId, groupMemberId): return "/_abort switch #\(groupId) \(groupMemberId)" - case let .apiSyncContactRatchet(contactId, force): if force { - return "/_sync @\(contactId) force=on" - } else { - return "/_sync @\(contactId)" - } - case let .apiSyncGroupMemberRatchet(groupId, groupMemberId, force): if force { - return "/_sync #\(groupId) \(groupMemberId) force=on" - } else { - return "/_sync #\(groupId) \(groupMemberId)" - } - case let .apiGetContactCode(contactId): return "/_get code @\(contactId)" - case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)" - case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)" - case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" - case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" - case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" - case let .apiAddContact(userId, short, incognito): return "/_connect \(userId) short=\(onOff(short)) incognito=\(onOff(incognito))" - case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" - case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" - 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 .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))" - case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" - case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))" - case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" - case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")" - case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")" - case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))" - case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" - case let .apiShowMyAddress(userId): return "/_show_address \(userId)" - case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" - case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" - case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" - case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" - case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" - case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" - case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))" - case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" - case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" - case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" - case .apiGetCallInvitations: return "/_call get" - case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" - case .apiGetNetworkStatuses: return "/_network_statuses" - case let .apiChatRead(type, id): 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 .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)" - case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" - case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)" - case .findKnownRemoteCtrl: return "/find remote ctrl" - case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)" - case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)" - case .listRemoteCtrls: return "/list remote ctrls" - case .stopRemoteCtrl: return "/stop remote ctrl" - case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" - case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" - case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" - case let .apiStandaloneFileInfo(link): return "/_download info \(link)" - case .showVersion: return "/version" - case let .getAgentSubsTotal(userId): return "/get subs total \(userId)" - case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" - case .resetAgentServersStats: return "/reset servers stats" - case let .string(str): return str - } - } - } - - public var cmdType: String { - get { - switch self { - case .showActiveUser: return "showActiveUser" - case .createActiveUser: return "createActiveUser" - case .listUsers: return "listUsers" - case .apiSetActiveUser: return "apiSetActiveUser" - case .setAllContactReceipts: return "setAllContactReceipts" - case .apiSetUserContactReceipts: return "apiSetUserContactReceipts" - case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts" - case .apiHideUser: return "apiHideUser" - case .apiUnhideUser: return "apiUnhideUser" - case .apiMuteUser: return "apiMuteUser" - case .apiUnmuteUser: return "apiUnmuteUser" - case .apiDeleteUser: return "apiDeleteUser" - case .startChat: return "startChat" - case .checkChatRunning: return "checkChatRunning" - case .apiStopChat: return "apiStopChat" - case .apiActivateChat: return "apiActivateChat" - case .apiSuspendChat: return "apiSuspendChat" - case .apiSetAppFilePaths: return "apiSetAppFilePaths" - case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" - case .apiExportArchive: return "apiExportArchive" - case .apiImportArchive: return "apiImportArchive" - case .apiDeleteStorage: return "apiDeleteStorage" - case .apiStorageEncryption: return "apiStorageEncryption" - case .testStorageEncryption: return "testStorageEncryption" - case .apiSaveSettings: return "apiSaveSettings" - case .apiGetSettings: return "apiGetSettings" - case .apiGetChatTags: return "apiGetChatTags" - case .apiGetChats: return "apiGetChats" - case .apiGetChat: return "apiGetChat" - case .apiGetChatItemInfo: return "apiGetChatItemInfo" - case .apiSendMessages: return "apiSendMessages" - case .apiCreateChatTag: return "apiCreateChatTag" - case .apiSetChatTags: return "apiSetChatTags" - case .apiDeleteChatTag: return "apiDeleteChatTag" - case .apiUpdateChatTag: return "apiUpdateChatTag" - case .apiReorderChatTags: return "apiReorderChatTags" - case .apiCreateChatItems: return "apiCreateChatItems" - case .apiReportMessage: return "apiReportMessage" - case .apiUpdateChatItem: return "apiUpdateChatItem" - case .apiDeleteChatItem: return "apiDeleteChatItem" - case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" - case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" - case .apiArchiveReceivedReports: return "apiArchiveReceivedReports" - case .apiDeleteReceivedReports: return "apiDeleteReceivedReports" - case .apiChatItemReaction: return "apiChatItemReaction" - case .apiGetReactionMembers: return "apiGetReactionMembers" - case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" - case .apiForwardChatItems: return "apiForwardChatItems" - case .apiGetNtfToken: return "apiGetNtfToken" - case .apiRegisterToken: return "apiRegisterToken" - case .apiVerifyToken: return "apiVerifyToken" - case .apiCheckToken: return "apiCheckToken" - case .apiDeleteToken: return "apiDeleteToken" - case .apiGetNtfConns: return "apiGetNtfConns" - case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" - case .apiNewGroup: return "apiNewGroup" - case .apiAddMember: return "apiAddMember" - case .apiJoinGroup: return "apiJoinGroup" - case .apiMembersRole: return "apiMembersRole" - case .apiBlockMembersForAll: return "apiBlockMembersForAll" - case .apiRemoveMembers: return "apiRemoveMembers" - case .apiLeaveGroup: return "apiLeaveGroup" - case .apiListMembers: return "apiListMembers" - case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" - case .apiCreateGroupLink: return "apiCreateGroupLink" - case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" - case .apiDeleteGroupLink: return "apiDeleteGroupLink" - case .apiGetGroupLink: return "apiGetGroupLink" - case .apiCreateMemberContact: return "apiCreateMemberContact" - case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" - case .apiTestProtoServer: return "apiTestProtoServer" - case .apiGetServerOperators: return "apiGetServerOperators" - case .apiSetServerOperators: return "apiSetServerOperators" - case .apiGetUserServers: return "apiGetUserServers" - case .apiSetUserServers: return "apiSetUserServers" - case .apiValidateServers: return "apiValidateServers" - case .apiGetUsageConditions: return "apiGetUsageConditions" - case .apiSetConditionsNotified: return "apiSetConditionsNotified" - case .apiAcceptConditions: return "apiAcceptConditions" - case .apiSetChatItemTTL: return "apiSetChatItemTTL" - case .apiGetChatItemTTL: return "apiGetChatItemTTL" - case .apiSetChatTTL: return "apiSetChatTTL" - case .apiSetNetworkConfig: return "apiSetNetworkConfig" - case .apiGetNetworkConfig: return "apiGetNetworkConfig" - case .apiSetNetworkInfo: return "apiSetNetworkInfo" - case .reconnectAllServers: return "reconnectAllServers" - case .reconnectServer: return "reconnectServer" - case .apiSetChatSettings: return "apiSetChatSettings" - case .apiSetMemberSettings: return "apiSetMemberSettings" - case .apiContactInfo: return "apiContactInfo" - case .apiGroupMemberInfo: return "apiGroupMemberInfo" - case .apiContactQueueInfo: return "apiContactQueueInfo" - case .apiGroupMemberQueueInfo: return "apiGroupMemberQueueInfo" - case .apiSwitchContact: return "apiSwitchContact" - case .apiSwitchGroupMember: return "apiSwitchGroupMember" - case .apiAbortSwitchContact: return "apiAbortSwitchContact" - case .apiAbortSwitchGroupMember: return "apiAbortSwitchGroupMember" - case .apiSyncContactRatchet: return "apiSyncContactRatchet" - case .apiSyncGroupMemberRatchet: return "apiSyncGroupMemberRatchet" - case .apiGetContactCode: return "apiGetContactCode" - case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" - case .apiVerifyContact: return "apiVerifyContact" - case .apiVerifyGroupMember: return "apiVerifyGroupMember" - case .apiAddContact: return "apiAddContact" - case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" - case .apiChangeConnectionUser: return "apiChangeConnectionUser" - case .apiConnectPlan: return "apiConnectPlan" - case .apiConnect: return "apiConnect" - case .apiDeleteChat: return "apiDeleteChat" - case .apiClearChat: return "apiClearChat" - case .apiListContacts: return "apiListContacts" - case .apiUpdateProfile: return "apiUpdateProfile" - case .apiSetContactPrefs: return "apiSetContactPrefs" - case .apiSetContactAlias: return "apiSetContactAlias" - case .apiSetGroupAlias: return "apiSetGroupAlias" - case .apiSetConnectionAlias: return "apiSetConnectionAlias" - case .apiSetUserUIThemes: return "apiSetUserUIThemes" - case .apiSetChatUIThemes: return "apiSetChatUIThemes" - case .apiCreateMyAddress: return "apiCreateMyAddress" - case .apiDeleteMyAddress: return "apiDeleteMyAddress" - case .apiShowMyAddress: return "apiShowMyAddress" - case .apiSetProfileAddress: return "apiSetProfileAddress" - case .apiAddressAutoAccept: return "apiAddressAutoAccept" - case .apiAcceptContact: return "apiAcceptContact" - case .apiRejectContact: return "apiRejectContact" - case .apiSendCallInvitation: return "apiSendCallInvitation" - case .apiRejectCall: return "apiRejectCall" - case .apiSendCallOffer: return "apiSendCallOffer" - case .apiSendCallAnswer: return "apiSendCallAnswer" - case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" - case .apiEndCall: return "apiEndCall" - case .apiGetCallInvitations: return "apiGetCallInvitations" - case .apiCallStatus: return "apiCallStatus" - case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" - case .apiChatRead: return "apiChatRead" - case .apiChatItemsRead: return "apiChatItemsRead" - case .apiChatUnread: return "apiChatUnread" - case .receiveFile: return "receiveFile" - case .setFileToReceive: return "setFileToReceive" - case .cancelFile: return "cancelFile" - case .setLocalDeviceName: return "setLocalDeviceName" - case .connectRemoteCtrl: return "connectRemoteCtrl" - case .findKnownRemoteCtrl: return "findKnownRemoteCtrl" - case .confirmRemoteCtrl: return "confirmRemoteCtrl" - case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession" - case .listRemoteCtrls: return "listRemoteCtrls" - case .stopRemoteCtrl: return "stopRemoteCtrl" - case .deleteRemoteCtrl: return "deleteRemoteCtrl" - case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" - case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" - case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" - case .showVersion: return "showVersion" - case .getAgentSubsTotal: return "getAgentSubsTotal" - case .getAgentServersSummary: return "getAgentServersSummary" - case .resetAgentServersStats: return "resetAgentServersStats" - case .string: return "console command" - } - } - } - - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" - } - - func joinedIds(_ ids: [Int64]) -> String { - ids.map { "\($0)" }.joined(separator: ",") - } - - func chatItemTTLStr(seconds: Int64?) -> String { - if let seconds = seconds { - return String(seconds) - } else { - return "default" - } - } - - public var obfuscated: ChatCommand { - switch self { - case let .apiStorageEncryption(cfg): - return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey))) - case let .apiSetActiveUser(userId, viewPwd): - return .apiSetActiveUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiHideUser(userId, viewPwd): - return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiUnhideUser(userId, viewPwd): - return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiDeleteUser(userId, delSMPQueues, viewPwd): - return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) - case let .testStorageEncryption(key): - return .testStorageEncryption(key: obfuscate(key)) - default: return self - } - } - - private func obfuscate(_ s: String) -> String { - s == "" ? "" : "***" - } - - private func obfuscate(_ s: String?) -> String? { - if let s = s { - return obfuscate(s) - } - return nil - } - - private func onOffParam(_ param: String, _ b: Bool?) -> String { - if let b = b { - return " \(param)=\(onOff(b))" - } - return "" - } - - private func maybePwd(_ pwd: String?) -> String { - pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) - } +public protocol ChatCmdProtocol { + var cmdString: String { get } } -private func onOff(_ b: Bool) -> String { +public func onOff(_ b: Bool) -> String { b ? "on" : "off" } -public struct APIResponse: Decodable { - var resp: ChatResponse +public struct APIResponse: Decodable { + public var resp: ChatRespProtocol } -public enum ChatResponse: Decodable, Error { - case response(type: String, json: String) - case activeUser(user: User) - case usersList(users: [UserInfo]) - case chatStarted - case chatRunning - case chatStopped - case chatSuspended - case apiChats(user: UserRef, chats: [ChatData]) - case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?) - case chatTags(user: UserRef, userTags: [ChatTag]) - case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) - case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) - case serverOperatorConditions(conditions: ServerOperatorConditions) - case userServers(user: UserRef, userServers: [UserOperatorServers]) - case userServersValidation(user: UserRef, serverErrors: [UserServersError]) - case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) - case chatItemTTL(user: UserRef, chatItemTTL: Int64?) - case networkConfig(networkConfig: NetCfg) - case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) - case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) - case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo) - case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) - case groupMemberSwitchStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactSwitchAborted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) - case groupMemberSwitchAborted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) - case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) - case contactRatchetSyncStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) - case groupMemberRatchetSyncStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactRatchetSync(user: UserRef, contact: Contact, ratchetSyncProgress: RatchetSyncProgress) - case groupMemberRatchetSync(user: UserRef, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress) - case contactVerificationReset(user: UserRef, contact: Contact) - case groupMemberVerificationReset(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case contactCode(user: UserRef, contact: Contact, connectionCode: String) - case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) - case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) - case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64]) - case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) - case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) - case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) - case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan) - case sentConfirmation(user: UserRef, connection: PendingContactConnection) - case sentInvitation(user: UserRef, connection: PendingContactConnection) - case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) - case contactAlreadyExists(user: UserRef, contact: Contact) - case contactDeleted(user: UserRef, contact: Contact) - case contactDeletedByContact(user: UserRef, contact: Contact) - case chatCleared(user: UserRef, chatInfo: ChatInfo) - case userProfileNoChange(user: User) - case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) - case userPrivacy(user: User, updatedUser: User) - case contactAliasUpdated(user: UserRef, toContact: Contact) - case groupAliasUpdated(user: UserRef, toGroup: GroupInfo) - case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection) - case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) - case userContactLink(user: User, contactLink: UserContactLink) - case userContactLinkUpdated(user: User, contactLink: UserContactLink) - case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink) - case userContactLinkDeleted(user: User) - case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) - case contactConnecting(user: UserRef, contact: Contact) - case contactSndReady(user: UserRef, contact: Contact) - case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) - case acceptingContactRequest(user: UserRef, contact: Contact) - case contactRequestRejected(user: UserRef) - case contactUpdated(user: UserRef, toContact: Contact) - case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) - case networkStatus(networkStatus: NetworkStatus, connections: [String]) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) - case groupSubscribed(user: UserRef, groupInfo: GroupRef) - case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) - case groupEmpty(user: UserRef, groupInfo: GroupInfo) - case userContactLinkSubscribed - case newChatItems(user: UserRef, chatItems: [AChatItem]) - case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) - case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) - case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) - case chatItemUpdated(user: UserRef, chatItem: AChatItem) - case chatItemNotChanged(user: UserRef, chatItem: AChatItem) - case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) - case reactionMembers(user: UserRef, memberReactions: [MemberReaction]) - case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) - case contactsList(user: UserRef, contacts: [Contact]) - // group events - case groupCreated(user: UserRef, groupInfo: GroupInfo) - case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) - case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) - case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) - case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) - case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) - case leftMemberUser(user: UserRef, groupInfo: GroupInfo) - case groupMembers(user: UserRef, group: Group) - 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 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) - case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) - case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool) - case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) - case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) - case groupInvitation(user: UserRef, groupInfo: GroupInfo) // unused - case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) - case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) - case groupRemoved(user: UserRef, groupInfo: GroupInfo) // unused - case groupUpdated(user: UserRef, toGroup: GroupInfo) - case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) - case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) - case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) - case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) - case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) - case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) - // receiving file events - case rcvFileAccepted(user: UserRef, chatItem: AChatItem) - case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) - case standaloneFileInfo(fileMeta: MigrationFileLinkData?) - case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats - case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) - case rcvFileComplete(user: UserRef, chatItem: AChatItem) - case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) - case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) - case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) - case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) - case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) - // sending file events - case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) - case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) - case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload - case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used - case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) - case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) - case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) - case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) - case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) - case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - // call events - case callInvitation(callInvitation: RcvCallInvitation) - case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) - case callAnswer(user: UserRef, contact: Contact, answer: WebRTCSession) - case callExtraInfo(user: UserRef, contact: Contact, extraInfo: WebRTCExtraInfo) - case callEnded(user: UserRef, contact: Contact) - case callInvitations(callInvitations: [RcvCallInvitation]) - case ntfTokenStatus(status: NtfTknStatus) - case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) - case ntfConns(ntfConns: [NtfConn]) - case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) - case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) - case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) - case contactDisabled(user: UserRef, contact: Contact) - // remote desktop responses/events - case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) - case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) - case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) - case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) - // pq - case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) - // misc - case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) - case cmdOk(user: UserRef?) - case agentSubsTotal(user: UserRef, subsTotal: SMPServerSubs, hasSession: Bool) - case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary) - case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) - case chatCmdError(user_: UserRef?, chatError: ChatError) - case chatError(user_: UserRef?, chatError: ChatError) - case archiveExported(archiveErrors: [ArchiveError]) - case archiveImported(archiveErrors: [ArchiveError]) - case appSettings(appSettings: AppSettings) +public protocol ChatRespProtocol: Decodable, Error { + var responseType: String { get } + var details: String { get } + static func chatResponse(_ s: String) -> Self + var chatError: ChatError? { get } + var chatErrorType: ChatErrorType? { get } +} - public var responseType: String { - get { - switch self { - case let .response(type, _): return "* \(type)" - case .activeUser: return "activeUser" - case .usersList: return "usersList" - case .chatStarted: return "chatStarted" - case .chatRunning: return "chatRunning" - case .chatStopped: return "chatStopped" - case .chatSuspended: return "chatSuspended" - case .apiChats: return "apiChats" - case .apiChat: return "apiChat" - case .chatTags: return "chatTags" - case .chatItemInfo: return "chatItemInfo" - case .serverTestResult: return "serverTestResult" - case .serverOperatorConditions: return "serverOperators" - case .userServers: return "userServers" - case .userServersValidation: return "userServersValidation" - case .usageConditions: return "usageConditions" - case .chatItemTTL: return "chatItemTTL" - case .networkConfig: return "networkConfig" - case .contactInfo: return "contactInfo" - case .groupMemberInfo: return "groupMemberInfo" - case .queueInfo: return "queueInfo" - case .contactSwitchStarted: return "contactSwitchStarted" - case .groupMemberSwitchStarted: return "groupMemberSwitchStarted" - case .contactSwitchAborted: return "contactSwitchAborted" - case .groupMemberSwitchAborted: return "groupMemberSwitchAborted" - case .contactSwitch: return "contactSwitch" - case .groupMemberSwitch: return "groupMemberSwitch" - case .contactRatchetSyncStarted: return "contactRatchetSyncStarted" - case .groupMemberRatchetSyncStarted: return "groupMemberRatchetSyncStarted" - case .contactRatchetSync: return "contactRatchetSync" - case .groupMemberRatchetSync: return "groupMemberRatchetSync" - case .contactVerificationReset: return "contactVerificationReset" - case .groupMemberVerificationReset: return "groupMemberVerificationReset" - case .contactCode: return "contactCode" - case .groupMemberCode: return "groupMemberCode" - case .connectionVerified: return "connectionVerified" - case .tagsUpdated: return "tagsUpdated" - case .invitation: return "invitation" - case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" - case .connectionUserChanged: return "connectionUserChanged" - case .connectionPlan: return "connectionPlan" - case .sentConfirmation: return "sentConfirmation" - case .sentInvitation: return "sentInvitation" - case .sentInvitationToContact: return "sentInvitationToContact" - case .contactAlreadyExists: return "contactAlreadyExists" - case .contactDeleted: return "contactDeleted" - case .contactDeletedByContact: return "contactDeletedByContact" - case .chatCleared: return "chatCleared" - case .userProfileNoChange: return "userProfileNoChange" - case .userProfileUpdated: return "userProfileUpdated" - case .userPrivacy: return "userPrivacy" - case .contactAliasUpdated: return "contactAliasUpdated" - case .groupAliasUpdated: return "groupAliasUpdated" - case .connectionAliasUpdated: return "connectionAliasUpdated" - case .contactPrefsUpdated: return "contactPrefsUpdated" - case .userContactLink: return "userContactLink" - case .userContactLinkUpdated: return "userContactLinkUpdated" - case .userContactLinkCreated: return "userContactLinkCreated" - case .userContactLinkDeleted: return "userContactLinkDeleted" - case .contactConnected: return "contactConnected" - case .contactConnecting: return "contactConnecting" - case .contactSndReady: return "contactSndReady" - case .receivedContactRequest: return "receivedContactRequest" - case .acceptingContactRequest: return "acceptingContactRequest" - case .contactRequestRejected: return "contactRequestRejected" - case .contactUpdated: return "contactUpdated" - case .groupMemberUpdated: return "groupMemberUpdated" - case .networkStatus: return "networkStatus" - case .networkStatuses: return "networkStatuses" - case .groupSubscribed: return "groupSubscribed" - case .memberSubErrors: return "memberSubErrors" - case .groupEmpty: return "groupEmpty" - case .userContactLinkSubscribed: return "userContactLinkSubscribed" - case .newChatItems: return "newChatItems" - case .groupChatItemsDeleted: return "groupChatItemsDeleted" - case .forwardPlan: return "forwardPlan" - case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" - case .chatItemUpdated: return "chatItemUpdated" - case .chatItemNotChanged: return "chatItemNotChanged" - case .chatItemReaction: return "chatItemReaction" - case .reactionMembers: return "reactionMembers" - case .chatItemsDeleted: return "chatItemsDeleted" - case .contactsList: return "contactsList" - case .groupCreated: return "groupCreated" - case .sentGroupInvitation: return "sentGroupInvitation" - case .userAcceptedGroupSent: return "userAcceptedGroupSent" - case .groupLinkConnecting: return "groupLinkConnecting" - case .businessLinkConnecting: return "businessLinkConnecting" - case .userDeletedMembers: return "userDeletedMembers" - case .leftMemberUser: return "leftMemberUser" - case .groupMembers: return "groupMembers" - case .receivedGroupInvitation: return "receivedGroupInvitation" - case .groupDeletedUser: return "groupDeletedUser" - case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" - case .memberRole: return "memberRole" - case .membersRoleUser: return "membersRoleUser" - case .memberBlockedForAll: return "memberBlockedForAll" - case .membersBlockedForAllUser: return "membersBlockedForAllUser" - case .deletedMemberUser: return "deletedMemberUser" - case .deletedMember: return "deletedMember" - case .leftMember: return "leftMember" - case .groupDeleted: return "groupDeleted" - case .contactsMerged: return "contactsMerged" - case .groupInvitation: return "groupInvitation" - case .userJoinedGroup: return "userJoinedGroup" - case .joinedGroupMember: return "joinedGroupMember" - case .connectedToGroupMember: return "connectedToGroupMember" - case .groupRemoved: return "groupRemoved" - case .groupUpdated: return "groupUpdated" - case .groupLinkCreated: return "groupLinkCreated" - case .groupLink: return "groupLink" - case .groupLinkDeleted: return "groupLinkDeleted" - case .newMemberContact: return "newMemberContact" - case .newMemberContactSentInv: return "newMemberContactSentInv" - case .newMemberContactReceivedInv: return "newMemberContactReceivedInv" - case .rcvFileAccepted: return "rcvFileAccepted" - case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" - case .standaloneFileInfo: return "standaloneFileInfo" - case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated" - case .rcvFileStart: return "rcvFileStart" - case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" - case .rcvFileComplete: return "rcvFileComplete" - case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete" - case .rcvFileCancelled: return "rcvFileCancelled" - case .rcvFileSndCancelled: return "rcvFileSndCancelled" - case .rcvFileError: return "rcvFileError" - case .rcvFileWarning: return "rcvFileWarning" - case .sndFileStart: return "sndFileStart" - case .sndFileComplete: return "sndFileComplete" - case .sndFileCancelled: return "sndFileCancelled" - case .sndStandaloneFileCreated: return "sndStandaloneFileCreated" - case .sndFileStartXFTP: return "sndFileStartXFTP" - case .sndFileProgressXFTP: return "sndFileProgressXFTP" - case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP" - case .sndFileRcvCancelled: return "sndFileRcvCancelled" - case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" - case .sndStandaloneFileComplete: return "sndStandaloneFileComplete" - case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" - case .sndFileError: return "sndFileError" - case .sndFileWarning: return "sndFileWarning" - case .callInvitation: return "callInvitation" - case .callOffer: return "callOffer" - case .callAnswer: return "callAnswer" - case .callExtraInfo: return "callExtraInfo" - case .callEnded: return "callEnded" - case .callInvitations: return "callInvitations" - case .ntfTokenStatus: return "ntfTokenStatus" - case .ntfToken: return "ntfToken" - case .ntfConns: return "ntfConns" - case .connNtfMessages: return "connNtfMessages" - case .ntfMessage: return "ntfMessage" - case .contactConnectionDeleted: return "contactConnectionDeleted" - case .contactDisabled: return "contactDisabled" - case .remoteCtrlList: return "remoteCtrlList" - case .remoteCtrlFound: return "remoteCtrlFound" - case .remoteCtrlConnecting: return "remoteCtrlConnecting" - case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" - case .remoteCtrlConnected: return "remoteCtrlConnected" - case .remoteCtrlStopped: return "remoteCtrlStopped" - case .contactPQEnabled: return "contactPQEnabled" - case .versionInfo: return "versionInfo" - case .cmdOk: return "cmdOk" - case .agentSubsTotal: return "agentSubsTotal" - case .agentServersSummary: return "agentServersSummary" - case .agentSubsSummary: return "agentSubsSummary" - case .chatCmdError: return "chatCmdError" - case .chatError: return "chatError" - case .archiveExported: return "archiveExported" - case .archiveImported: return "archiveImported" - case .appSettings: return "appSettings" +public func parseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [ChatData])? { + if let jApiChats = jResp["apiChats"] as? NSDictionary, + let user: UserRef = try? decodeObject(jApiChats["user"] as Any), + let jChats = jApiChats["chats"] as? NSArray { + let chats = jChats.map { jChat in + if let chatData = try? parseChatData(jChat) { + return chatData.0 } + return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "") } - } - - public var details: String { - get { - switch self { - case let .response(_, json): return json - case let .activeUser(user): return String(describing: user) - case let .usersList(users): return String(describing: users) - case .chatStarted: return noDetails - case .chatRunning: return noDetails - case .chatStopped: return noDetails - case .chatSuspended: return noDetails - case let .apiChats(u, chats): return withUser(u, String(describing: chats)) - case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") - case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") - case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") - case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") - case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" - case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") - case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") - case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" - case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) - case let .networkConfig(networkConfig): return String(describing: networkConfig) - case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") - case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") - case let .queueInfo(u, rcvMsgInfo, queueInfo): - let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } - return withUser(u, "rcvMsgInfo: \(msgInfo)\nqueueInfo: \(encodeJSON(queueInfo))") - case let .contactSwitchStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") - case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") - case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") - case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") - case let .contactVerificationReset(u, contact): return withUser(u, "contact: \(String(describing: contact))") - case let .groupMemberVerificationReset(u, groupInfo, member): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))") - case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") - case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") - case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") - case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))") - case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)") - case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) - case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))") - case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") - case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) - case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) - case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) - case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) - case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) - case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) - 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)) - case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) - case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) - case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) - case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") - case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) - case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) - case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) - case .userContactLinkDeleted: return noDetails - case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) - case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) - case let .contactSndReady(u, contact): return withUser(u, String(describing: contact)) - case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) - case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) - case .contactRequestRejected: return noDetails - case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) - case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") - case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) - case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) - case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case .userContactLinkSubscribed: return noDetails - case let .newChatItems(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) - case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): - return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") - case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") - case let .chatItemsStatusesUpdated(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) - case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") - case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))") - case let .chatItemsDeleted(u, items, byUser): - let itemsString = items.map { item in - "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") - return withUser(u, itemsString + "\nbyUser: \(byUser)") - case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) - case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") - case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") - case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") - case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") - case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") - case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .groupMembers(u, group): return withUser(u, String(describing: group)) - 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 .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)") - case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") - case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)") - case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") - case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") - case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") - case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") - case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") - case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") - case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") - case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") - case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) - case .rcvFileAcceptedSndCancelled: return noDetails - case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) - case .rcvStandaloneFileCreated: return noDetails - case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") - case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) - case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") - case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") - case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) - case .sndStandaloneFileCreated: return noDetails - case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") - case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) - case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) - case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .callInvitation(inv): return String(describing: inv) - case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") - case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") - case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") - case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") - case let .callInvitations(invs): return String(describing: invs) - case let .ntfTokenStatus(status): return String(describing: status) - case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" - case let .ntfConns(ntfConns): return String(describing: ntfConns) - case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" - case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") - case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) - case let .contactDisabled(u, contact): return withUser(u, String(describing: contact)) - case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" - case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" - case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" - case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) - case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" - case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") - case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" - case .cmdOk: return noDetails - case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)") - case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) - case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) - case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) - case let .archiveExported(archiveErrors): return String(describing: archiveErrors) - case let .archiveImported(archiveErrors): return String(describing: archiveErrors) - case let .appSettings(appSettings): return String(describing: appSettings) - } - } - } - - private var noDetails: String { get { "\(responseType): no details" } } - - private func withUser(_ u: (any UserLike)?, _ s: String) -> String { - if let id = u?.userId { - return "userId: \(id)\n\(s)" - } - return s + return (user, chats) + } else { + return nil } } -public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { - switch chatResponse { - case let .chatCmdError(_, .error(error)): return error - case let .chatError(_, .error(error)): return error - default: return nil - } -} - -public enum ChatDeleteMode: Codable { - case full(notify: Bool) - case entity(notify: Bool) - case messages - - var cmdString: String { - switch self { - case let .full(notify): "full notify=\(onOff(notify))" - case let .entity(notify): "entity notify=\(onOff(notify))" - case .messages: "messages" - } - } - - public var isEntity: Bool { - switch self { - case .entity: return true - default: return false - } +public func withUser(_ u: (any UserLike)?, _ s: String) -> String { + if let id = u?.userId { + return "userId: \(id)\n\(s)" } + return s } public struct CreatedConnLink: Decodable, Hashable { @@ -1193,74 +76,6 @@ public func simplexChatLink(_ uri: String) -> String { : uri } -public enum ConnectionPlan: Decodable, Hashable { - case invitationLink(invitationLinkPlan: InvitationLinkPlan) - case contactAddress(contactAddressPlan: ContactAddressPlan) - case groupLink(groupLinkPlan: GroupLinkPlan) - case error(chatError: ChatError) -} - -public enum InvitationLinkPlan: Decodable, Hashable { - case ok - case ownLink - case connecting(contact_: Contact?) - case known(contact: Contact) -} - -public enum ContactAddressPlan: Decodable, Hashable { - case ok - case ownLink - case connectingConfirmReconnect - case connectingProhibit(contact: Contact) - case known(contact: Contact) - case contactViaAddress(contact: Contact) -} - -public enum GroupLinkPlan: Decodable, Hashable { - case ok - case ownLink(groupInfo: GroupInfo) - case connectingConfirmReconnect - case connectingProhibit(groupInfo_: GroupInfo?) - case known(groupInfo: GroupInfo) -} - -struct NewUser: Encodable { - var profile: Profile? - var pastTimestamp: Bool -} - -public enum ChatPagination { - public static let INITIAL_COUNT = 75 - public static let PRELOAD_COUNT = 100 - public static let UNTIL_PRELOAD_COUNT = 50 - - case last(count: Int) - case after(chatItemId: Int64, count: Int) - case before(chatItemId: Int64, count: Int) - case around(chatItemId: Int64, count: Int) - case initial(count: Int) - - var cmdString: String { - switch self { - case let .last(count): return "count=\(count)" - case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" - case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" - case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" - case let .initial(count): return "initial=\(count)" - } - } -} - -public struct ChatTagData: Encodable { - public var emoji: String? - public var text: String - - public init(emoji: String?, text: String) { - self.emoji = emoji - self.text = text - } -} - public struct ComposedMessage: Encodable { public var fileSource: CryptoFile? var quotedItemId: Int64? @@ -1275,477 +90,11 @@ public struct ComposedMessage: Encodable { } } -public struct UpdatedMessage: Encodable { - public var msgContent: MsgContent - public var mentions: [String: Int64] - - public init(msgContent: MsgContent, mentions: [String: Int64] = [:]) { - self.msgContent = msgContent - self.mentions = mentions - } - - var cmdString: String { - "json \(encodeJSON(self))" - } -} - -public struct ArchiveConfig: Encodable { - var archivePath: String - var disableCompression: Bool? - - public init(archivePath: String, disableCompression: Bool? = nil) { - self.archivePath = archivePath - self.disableCompression = disableCompression - } -} - -public struct DBEncryptionConfig: Codable { - public init(currentKey: String, newKey: String) { - self.currentKey = currentKey - self.newKey = newKey - } - - public var currentKey: String - public var newKey: String -} - public enum ServerProtocol: String, Decodable { case smp case xftp } -public enum OperatorTag: String, Codable { - case simplex = "simplex" - case flux = "flux" -} - -public struct ServerOperatorInfo { - public var description: [String] - public var website: URL - public var selfhost: (text: String, link: URL)? = nil - public var logo: String - public var largeLogo: String - public var logoDarkMode: String - public var largeLogoDarkMode: String -} - -public let operatorsInfo: Dictionary = [ - .simplex: ServerOperatorInfo( - description: [ - "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or identity keys.", - "SimpleX Chat Ltd develops the communication software for SimpleX network." - ], - website: URL(string: "https://simplex.chat")!, - logo: "decentralized", - largeLogo: "logo", - logoDarkMode: "decentralized-light", - largeLogoDarkMode: "logo-light" - ), - .flux: ServerOperatorInfo( - description: [ - "Flux is the largest decentralized cloud, based on a global network of user-operated nodes.", - "Flux offers a powerful, scalable, and affordable cutting edge technology platform for all.", - "Flux operates servers in SimpleX network to improve its privacy and decentralization." - ], - website: URL(string: "https://runonflux.com")!, - selfhost: (text: "Self-host SimpleX servers on Flux", link: URL(string: "https://home.runonflux.io/apps/marketplace?q=simplex")!), - logo: "flux_logo_symbol", - largeLogo: "flux_logo", - logoDarkMode: "flux_logo_symbol", - largeLogoDarkMode: "flux_logo-light" - ), -] - -public struct UsageConditions: Decodable { - public var conditionsId: Int64 - public var conditionsCommit: String - public var notifiedAt: Date? - public var createdAt: Date - - public static var sampleData = UsageConditions( - conditionsId: 1, - conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c", - notifiedAt: nil, - createdAt: Date.now - ) -} - -public enum UsageConditionsAction: Decodable { - case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool) - case accepted(operators: [ServerOperator]) - - public var showNotice: Bool { - switch self { - case let .review(_, _, showNotice): showNotice - case .accepted: false - } - } -} - -public struct ServerOperatorConditions: Decodable { - public var serverOperators: [ServerOperator] - public var currentConditions: UsageConditions - public var conditionsAction: UsageConditionsAction? - - public static var empty = ServerOperatorConditions( - serverOperators: [], - currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now), - conditionsAction: nil - ) -} - -public enum ConditionsAcceptance: Equatable, Codable, Hashable { - case accepted(acceptedAt: Date?, autoAccepted: Bool) - // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. - // No deadline indicates it's required to accept conditions for the operator to start using it. - case required(deadline: Date?) - - public var conditionsAccepted: Bool { - switch self { - case .accepted: true - case .required: false - } - } - - public var usageAllowed: Bool { - switch self { - case .accepted: true - case let .required(deadline): deadline != nil - } - } -} - -public struct ServerOperator: Identifiable, Equatable, Codable { - public var operatorId: Int64 - public var operatorTag: OperatorTag? - public var tradeName: String - public var legalName: String? - public var serverDomains: [String] - public var conditionsAcceptance: ConditionsAcceptance - public var enabled: Bool - public var smpRoles: ServerRoles - public var xftpRoles: ServerRoles - - public var id: Int64 { operatorId } - - public static func == (l: ServerOperator, r: ServerOperator) -> Bool { - l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName && - l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled && - l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles - } - - public var legalName_: String { - legalName ?? tradeName - } - - public var info: ServerOperatorInfo { - return if let operatorTag = operatorTag { - operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo - } else { - ServerOperator.dummyOperatorInfo - } - } - - public static let dummyOperatorInfo = ServerOperatorInfo( - description: ["Default"], - website: URL(string: "https://simplex.chat")!, - logo: "decentralized", - largeLogo: "logo", - logoDarkMode: "decentralized-light", - largeLogoDarkMode: "logo-light" - ) - - public func logo(_ colorScheme: ColorScheme) -> String { - colorScheme == .light ? info.logo : info.logoDarkMode - } - - public func largeLogo(_ colorScheme: ColorScheme) -> String { - colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode - } - - public static var sampleData1 = ServerOperator( - operatorId: 1, - operatorTag: .simplex, - tradeName: "SimpleX Chat", - legalName: "SimpleX Chat Ltd", - serverDomains: ["simplex.im"], - conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), - enabled: true, - smpRoles: ServerRoles(storage: true, proxy: true), - xftpRoles: ServerRoles(storage: true, proxy: true) - ) -} - -public struct ServerRoles: Equatable, Codable { - public var storage: Bool - public var proxy: Bool -} - -public struct UserOperatorServers: Identifiable, Equatable, Codable { - public var `operator`: ServerOperator? - public var smpServers: [UserServer] - public var xftpServers: [UserServer] - - public var id: String { - if let op = self.operator { - "\(op.operatorId)" - } else { - "nil operator" - } - } - - public var operator_: ServerOperator { - get { - self.operator ?? ServerOperator( - operatorId: 0, - operatorTag: nil, - tradeName: "", - legalName: "", - serverDomains: [], - conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), - enabled: false, - smpRoles: ServerRoles(storage: true, proxy: true), - xftpRoles: ServerRoles(storage: true, proxy: true) - ) - } - set { `operator` = newValue } - } - - public static var sampleData1 = UserOperatorServers( - operator: ServerOperator.sampleData1, - smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] - ) - - public static var sampleDataNilOperator = UserOperatorServers( - operator: nil, - smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] - ) -} - -public enum UserServersError: Decodable { - case noServers(protocol: ServerProtocol, user: UserRef?) - case storageMissing(protocol: ServerProtocol, user: UserRef?) - case proxyMissing(protocol: ServerProtocol, user: UserRef?) - case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) - - public var globalError: String? { - switch self { - case let .noServers(`protocol`, _): - switch `protocol` { - case .smp: return globalSMPError - case .xftp: return globalXFTPError - } - case let .storageMissing(`protocol`, _): - switch `protocol` { - case .smp: return globalSMPError - case .xftp: return globalXFTPError - } - case let .proxyMissing(`protocol`, _): - switch `protocol` { - case .smp: return globalSMPError - case .xftp: return globalXFTPError - } - default: return nil - } - } - - public var globalSMPError: String? { - switch self { - case let .noServers(.smp, user): - let text = NSLocalizedString("No message servers.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .storageMissing(.smp, user): - let text = NSLocalizedString("No servers to receive messages.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .proxyMissing(.smp, user): - let text = NSLocalizedString("No servers for private message routing.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - default: - return nil - } - } - - public var globalXFTPError: String? { - switch self { - case let .noServers(.xftp, user): - let text = NSLocalizedString("No media & file servers.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .storageMissing(.xftp, user): - let text = NSLocalizedString("No servers to send files.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - case let .proxyMissing(.xftp, user): - let text = NSLocalizedString("No servers to receive files.", comment: "servers error") - if let user = user { - return userStr(user) + " " + text - } else { - return text - } - default: - return nil - } - } - - private func userStr(_ user: UserRef) -> String { - String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName) - } -} - -public struct UserServer: Identifiable, Equatable, Codable, Hashable { - public var serverId: Int64? - public var server: String - public var preset: Bool - public var tested: Bool? - public var enabled: Bool - public var deleted: Bool - var createdAt = Date() - - public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) { - self.serverId = serverId - self.server = server - self.preset = preset - self.tested = tested - self.enabled = enabled - self.deleted = deleted - } - - public static func == (l: UserServer, r: UserServer) -> Bool { - l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested && - l.enabled == r.enabled && l.deleted == r.deleted - } - - public var id: String { "\(server) \(createdAt)" } - - public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false) - - public var isEmpty: Bool { - server.trimmingCharacters(in: .whitespaces) == "" - } - - public struct SampleData { - public var preset: UserServer - public var custom: UserServer - public var untested: UserServer - public var xftpPreset: UserServer - } - - public static var sampleData = SampleData( - preset: UserServer( - serverId: 1, - server: "smp://abcd@smp8.simplex.im", - preset: true, - tested: true, - enabled: true, - deleted: false - ), - custom: UserServer( - serverId: 2, - server: "smp://abcd@smp9.simplex.im", - preset: false, - tested: false, - enabled: false, - deleted: false - ), - untested: UserServer( - serverId: 3, - server: "smp://abcd@smp10.simplex.im", - preset: false, - tested: nil, - enabled: true, - deleted: false - ), - xftpPreset: UserServer( - serverId: 4, - server: "xftp://abcd@xftp8.simplex.im", - preset: true, - tested: true, - enabled: true, - deleted: false - ) - ) - - enum CodingKeys: CodingKey { - case serverId - case server - case preset - case tested - case enabled - case deleted - } -} - -public enum ProtocolTestStep: String, Decodable, Equatable { - case connect - case disconnect - case createQueue - case secureQueue - case deleteQueue - case createFile - case uploadFile - case downloadFile - case compareFile - case deleteFile - - var text: String { - switch self { - case .connect: return NSLocalizedString("Connect", comment: "server test step") - case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step") - case .createQueue: return NSLocalizedString("Create queue", comment: "server test step") - case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step") - case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step") - case .createFile: return NSLocalizedString("Create file", comment: "server test step") - case .uploadFile: return NSLocalizedString("Upload file", comment: "server test step") - case .downloadFile: return NSLocalizedString("Download file", comment: "server test step") - case .compareFile: return NSLocalizedString("Compare file", comment: "server test step") - case .deleteFile: return NSLocalizedString("Delete file", comment: "server test step") - } - } -} - -public struct ProtocolTestFailure: Decodable, Error, Equatable { - public var testStep: ProtocolTestStep - public var testError: AgentErrorType - - public static func == (l: ProtocolTestFailure, r: ProtocolTestFailure) -> Bool { - l.testStep == r.testStep - } - - public var localizedDescription: String { - let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "server test failure"), testStep.text) - switch testError { - case .SMP(_, .AUTH): - return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error") - case .XFTP(.AUTH): - return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error") - case .BROKER(_, .NETWORK): - return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error") - default: - return err - } - } -} - public struct ServerAddress: Decodable { public var serverProtocol: ServerProtocol public var hostnames: [String] @@ -2025,56 +374,6 @@ public enum NetworkProxyAuth: String, Codable { case isolate } -public enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(connectionError: String) - - public var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } - } - - public var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - public var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } -} - -public enum ForwardConfirmation: Decodable, Hashable { - case filesNotAccepted(fileIds: [Int64]) - case filesInProgress(filesCount: Int) - case filesMissing(filesCount: Int) - case filesFailed(filesCount: Int) -} - -public struct ConnNetworkStatus: Decodable { - public var agentConnId: String - public var networkStatus: NetworkStatus -} - public struct ChatSettings: Codable, Hashable { public var enableNtfs: MsgFilter public var sendRcpts: Bool? @@ -2140,16 +439,6 @@ public enum MsgFilter: String, Codable, Hashable { } } -public struct UserMsgReceiptSettings: Codable { - public var enable: Bool - public var clearOverrides: Bool - - public init(enable: Bool, clearOverrides: Bool) { - self.enable = enable - self.clearOverrides = clearOverrides - } -} - public struct ConnectionStats: Decodable, Hashable { public var connAgentVersion: Int public var rcvQueuesInfo: [RcvQueueInfo] @@ -2217,108 +506,11 @@ public enum RatchetSyncState: String, Decodable { case agreed } -public struct UserContactLink: Decodable, Hashable { - public var connLinkContact: CreatedConnLink - public var autoAccept: AutoAccept? - - public init(connLinkContact: CreatedConnLink, autoAccept: AutoAccept? = nil) { - self.connLinkContact = connLinkContact - self.autoAccept = autoAccept - } - - var responseDetails: String { - "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" - } -} - -public struct AutoAccept: Codable, Hashable { - public var businessAddress: Bool - public var acceptIncognito: Bool - public var autoReply: MsgContent? - - public init(businessAddress: Bool, acceptIncognito: Bool, autoReply: MsgContent? = nil) { - self.businessAddress = businessAddress - self.acceptIncognito = acceptIncognito - self.autoReply = autoReply - } - - static func cmdString(_ autoAccept: AutoAccept?) -> String { - guard let autoAccept = autoAccept else { return "off" } - var s = "on" - if autoAccept.acceptIncognito { - s += " incognito=on" - } else if autoAccept.businessAddress { - s += " business" - } - guard let msg = autoAccept.autoReply else { return s } - return s + " " + msg.cmdString - } -} - public protocol SelectableItem: Identifiable, Equatable { var label: LocalizedStringKey { get } static var values: [Self] { get } } -public struct DeviceToken: Decodable { - var pushProvider: PushProvider - var token: String - - public init(pushProvider: PushProvider, token: String) { - self.pushProvider = pushProvider - self.token = token - } - - public var cmdString: String { - "\(pushProvider) \(token)" - } -} - -public enum PushEnvironment: String { - case development - case production -} - -public enum PushProvider: String, Decodable { - case apns_dev - case apns_prod - - public init(env: PushEnvironment) { - switch env { - case .development: self = .apns_dev - case .production: self = .apns_prod - } - } -} - -// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable, -// and .local for periodic background checks -public enum NotificationsMode: String, Decodable, SelectableItem { - case off = "OFF" - case periodic = "PERIODIC" - case instant = "INSTANT" - - public var label: LocalizedStringKey { - switch self { - case .off: "No push server" - case .periodic: "Periodic" - case .instant: "Instant" - } - } - - public var icon: String { - switch self { - case .off: return "arrow.clockwise" - case .periodic: return "timer" - case .instant: return "bolt" - } - } - - public var id: String { self.rawValue } - - public static var values: [NotificationsMode] = [.instant, .periodic, .off] -} - public enum NotificationPreviewMode: String, SelectableItem, Codable { case hidden case contact @@ -2337,63 +529,6 @@ public enum NotificationPreviewMode: String, SelectableItem, Codable { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } -public enum PrivacyChatListOpenLinksMode: String, CaseIterable, Codable, RawRepresentable, Identifiable { - case yes - case no - case ask - - public var id: Self { self } - - public var text: LocalizedStringKey { - switch self { - case .yes: return "Yes" - case .no: return "No" - case .ask: return "Ask" - } - } -} - -public struct RemoteCtrlInfo: Decodable { - public var remoteCtrlId: Int64 - public var ctrlDeviceName: String - public var sessionState: RemoteCtrlSessionState? - - public var deviceViewName: String { - ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName - } -} - -public enum RemoteCtrlSessionState: Decodable { - case starting - case searching - case connecting - case pendingConfirmation(sessionCode: String) - case connected(sessionCode: String) -} - -public enum RemoteCtrlStopReason: Decodable { - case discoveryFailed(chatError: ChatError) - case connectionFailed(chatError: ChatError) - case setupFailed(chatError: ChatError) - case disconnected -} - -public struct CtrlAppInfo: Decodable { - public var appVersionRange: AppVersionRange - public var deviceName: String -} - -public struct AppVersionRange: Decodable { - public var minVersion: String - public var maxVersion: String -} - -public struct CoreVersionInfo: Decodable { - public var version: String - public var simplexmqVersion: String - public var simplexmqCommit: String -} - public func decodeJSON(_ json: String) -> T? { if let data = json.data(using: .utf8) { return try? jsonDecoder.decode(T.self, from: data) @@ -2746,425 +881,14 @@ public enum RemoteCtrlError: Decodable, Hashable { case protocolError } -public struct MigrationFileLinkData: Codable { - let networkConfig: NetworkConfig? - - public init(networkConfig: NetworkConfig) { - self.networkConfig = networkConfig - } - - public struct NetworkConfig: Codable { - let socksProxy: String? - let networkProxy: NetworkProxy? - let hostMode: HostMode? - let requiredHostMode: Bool? - - public init(socksProxy: String?, networkProxy: NetworkProxy?, hostMode: HostMode?, requiredHostMode: Bool?) { - self.socksProxy = socksProxy - self.networkProxy = networkProxy - self.hostMode = hostMode - self.requiredHostMode = requiredHostMode - } - - public func transformToPlatformSupported() -> NetworkConfig { - return if let hostMode, let requiredHostMode { - NetworkConfig( - socksProxy: nil, - networkProxy: nil, - hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode, - requiredHostMode: requiredHostMode - ) - } else { self } - } - } - - public func addToLink(link: String) -> String { - "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)" - } - - public static func readFromLink(link: String) -> MigrationFileLinkData? { -// standaloneFileInfo(link) - nil - } -} - -public struct AppSettings: Codable, Equatable { - public var networkConfig: NetCfg? = nil - public var networkProxy: NetworkProxy? = nil - public var privacyEncryptLocalFiles: Bool? = nil - public var privacyAskToApproveRelays: Bool? = nil - public var privacyAcceptImages: Bool? = nil - public var privacyLinkPreviews: Bool? = nil - public var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = nil - public var privacyShowChatPreviews: Bool? = nil - public var privacySaveLastDraft: Bool? = nil - public var privacyProtectScreen: Bool? = nil - public var privacyMediaBlurRadius: Int? = nil - public var notificationMode: AppSettingsNotificationMode? = nil - public var notificationPreviewMode: NotificationPreviewMode? = nil - public var webrtcPolicyRelay: Bool? = nil - public var webrtcICEServers: [String]? = nil - public var confirmRemoteSessions: Bool? = nil - public var connectRemoteViaMulticast: Bool? = nil - public var connectRemoteViaMulticastAuto: Bool? = nil - public var developerTools: Bool? = nil - public var confirmDBUpgrades: Bool? = nil - public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil - public var iosCallKitEnabled: Bool? = nil - public var iosCallKitCallsInRecents: Bool? = nil - public var uiProfileImageCornerRadius: Double? = nil - public var uiChatItemRoundness: Double? = nil - public var uiChatItemTail: Bool? = nil - public var uiColorScheme: String? = nil - public var uiDarkColorScheme: String? = nil - public var uiCurrentThemeIds: [String: String]? = nil - public var uiThemes: [ThemeOverrides]? = nil - public var oneHandUI: Bool? = nil - public var chatBottomBar: Bool? = nil - - public func prepareForExport() -> AppSettings { - var empty = AppSettings() - let def = AppSettings.defaults - if networkConfig != def.networkConfig { empty.networkConfig = networkConfig } - if networkProxy != def.networkProxy { empty.networkProxy = networkProxy } - if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } - if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } - if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } - if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } - if privacyChatListOpenLinks != def.privacyChatListOpenLinks { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } - if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } - if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } - if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } - if privacyMediaBlurRadius != def.privacyMediaBlurRadius { empty.privacyMediaBlurRadius = privacyMediaBlurRadius } - if notificationMode != def.notificationMode { empty.notificationMode = notificationMode } - if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode } - if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay } - if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers } - if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions } - if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast } - if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto } - if developerTools != def.developerTools { empty.developerTools = developerTools } - if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades } - if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen } - if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled } - if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } - if uiProfileImageCornerRadius != def.uiProfileImageCornerRadius { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius } - if uiChatItemRoundness != def.uiChatItemRoundness { empty.uiChatItemRoundness = uiChatItemRoundness } - if uiChatItemTail != def.uiChatItemTail { empty.uiChatItemTail = uiChatItemTail } - if uiColorScheme != def.uiColorScheme { empty.uiColorScheme = uiColorScheme } - if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme } - if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds } - if uiThemes != def.uiThemes { empty.uiThemes = uiThemes } - if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } - if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar } - return empty - } - - public static var defaults: AppSettings { - AppSettings ( - networkConfig: NetCfg.defaults, - networkProxy: NetworkProxy.def, - privacyEncryptLocalFiles: true, - privacyAskToApproveRelays: true, - privacyAcceptImages: true, - privacyLinkPreviews: true, - privacyChatListOpenLinks: .ask, - privacyShowChatPreviews: true, - privacySaveLastDraft: true, - privacyProtectScreen: false, - privacyMediaBlurRadius: 0, - notificationMode: AppSettingsNotificationMode.instant, - notificationPreviewMode: NotificationPreviewMode.message, - webrtcPolicyRelay: true, - webrtcICEServers: [], - confirmRemoteSessions: false, - connectRemoteViaMulticast: true, - connectRemoteViaMulticastAuto: true, - developerTools: false, - confirmDBUpgrades: false, - androidCallOnLockScreen: AppSettingsLockScreenCalls.show, - iosCallKitEnabled: true, - iosCallKitCallsInRecents: false, - uiProfileImageCornerRadius: 22.5, - uiChatItemRoundness: 0.75, - uiChatItemTail: true, - uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME, - uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName, - uiCurrentThemeIds: nil as [String: String]?, - uiThemes: nil as [ThemeOverrides]?, - oneHandUI: true, - chatBottomBar: true - ) - } -} - -public enum AppSettingsNotificationMode: String, Codable { - case off - case periodic - case instant - - public func toNotificationsMode() -> NotificationsMode { - switch self { - case .instant: .instant - case .periodic: .periodic - case .off: .off - } - } - - public static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode { - switch mode { - case .instant: .instant - case .periodic: .periodic - case .off: .off - } - } -} - -//public enum NotificationPreviewMode: Codable { -// case hidden -// case contact -// case message -//} - -public enum AppSettingsLockScreenCalls: String, Codable { - case disable - case show - case accept -} - -public struct UserNetworkInfo: Codable, Equatable { - public let networkType: UserNetworkType - public let online: Bool - - public init(networkType: UserNetworkType, online: Bool) { - self.networkType = networkType - self.online = online - } -} - -public enum UserNetworkType: String, Codable { - case none - case cellular - case wifi - case ethernet - case other - - public var text: LocalizedStringKey { - switch self { - case .none: "No network connection" - case .cellular: "Cellular" - case .wifi: "WiFi" - case .ethernet: "Wired ethernet" - case .other: "Other" - } - } -} - -public struct RcvMsgInfo: Codable { - var msgId: Int64 - var msgDeliveryId: Int64 - var msgDeliveryStatus: String - var agentMsgId: Int64 - var agentMsgMeta: String -} - -public struct ServerQueueInfo: Codable { - var server: String - var rcvId: String - var sndId: String - var ntfId: String? - var status: String - var info: QueueInfo -} - -public struct QueueInfo: Codable { - var qiSnd: Bool - var qiNtf: Bool - var qiSub: QSub? - var qiSize: Int - var qiMsg: MsgInfo? -} - -public struct QSub: Codable { - var qSubThread: QSubThread - var qDelivered: String? -} - -public enum QSubThread: String, Codable { - case noSub - case subPending - case subThread - case prohibitSub -} - -public struct MsgInfo: Codable { - var msgId: String - var msgTs: Date - var msgType: MsgType -} - -public enum MsgType: String, Codable { - case message - case quota -} - public struct AppFilePaths: Encodable { public let appFilesFolder: String public let appTempFolder: String public let appAssetsFolder: String -} - -public struct PresentedServersSummary: Codable { - public var statsStartedAt: Date - public var allUsersSMP: SMPServersSummary - public var allUsersXFTP: XFTPServersSummary - public var currentUserSMP: SMPServersSummary - public var currentUserXFTP: XFTPServersSummary -} - -public struct SMPServersSummary: Codable { - public var smpTotals: SMPTotals - public var currentlyUsedSMPServers: [SMPServerSummary] - public var previouslyUsedSMPServers: [SMPServerSummary] - public var onlyProxiedSMPServers: [SMPServerSummary] -} - -public struct SMPTotals: Codable { - public var sessions: ServerSessions - public var subs: SMPServerSubs - public var stats: AgentSMPServerStatsData -} - -public struct SMPServerSummary: Codable, Identifiable { - public var smpServer: String - public var known: Bool? - public var sessions: ServerSessions? - public var subs: SMPServerSubs? - public var stats: AgentSMPServerStatsData? - - public var id: String { smpServer } - - public var hasSubs: Bool { subs != nil } - - public var sessionsOrNew: ServerSessions { sessions ?? ServerSessions.newServerSessions } - - public var subsOrNew: SMPServerSubs { subs ?? SMPServerSubs.newSMPServerSubs } -} - -public struct ServerSessions: Codable { - public var ssConnected: Int - public var ssErrors: Int - public var ssConnecting: Int - - static public var newServerSessions = ServerSessions( - ssConnected: 0, - ssErrors: 0, - ssConnecting: 0 - ) - - public var hasSess: Bool { ssConnected > 0 } -} - -public struct SMPServerSubs: Codable { - public var ssActive: Int - public var ssPending: Int - - public init(ssActive: Int, ssPending: Int) { - self.ssActive = ssActive - self.ssPending = ssPending - } - - static public var newSMPServerSubs = SMPServerSubs( - ssActive: 0, - ssPending: 0 - ) - - public var total: Int { ssActive + ssPending } - - public var shareOfActive: Double { - guard total != 0 else { return 0.0 } - return Double(ssActive) / Double(total) + + public init(appFilesFolder: String, appTempFolder: String, appAssetsFolder: String) { + self.appFilesFolder = appFilesFolder + self.appTempFolder = appTempFolder + self.appAssetsFolder = appAssetsFolder } } - -public struct AgentSMPServerStatsData: Codable { - public var _sentDirect: Int - public var _sentViaProxy: Int - public var _sentProxied: Int - public var _sentDirectAttempts: Int - public var _sentViaProxyAttempts: Int - public var _sentProxiedAttempts: Int - public var _sentAuthErrs: Int - public var _sentQuotaErrs: Int - public var _sentExpiredErrs: Int - public var _sentOtherErrs: Int - public var _recvMsgs: Int - public var _recvDuplicates: Int - public var _recvCryptoErrs: Int - public var _recvErrs: Int - public var _ackMsgs: Int - public var _ackAttempts: Int - public var _ackNoMsgErrs: Int - public var _ackOtherErrs: Int - public var _connCreated: Int - public var _connSecured: Int - public var _connCompleted: Int - public var _connDeleted: Int - public var _connDelAttempts: Int - public var _connDelErrs: Int - public var _connSubscribed: Int - public var _connSubAttempts: Int - public var _connSubIgnored: Int - public var _connSubErrs: Int - public var _ntfKey: Int - public var _ntfKeyAttempts: Int - public var _ntfKeyDeleted: Int - public var _ntfKeyDeleteAttempts: Int -} - -public struct XFTPServersSummary: Codable { - public var xftpTotals: XFTPTotals - public var currentlyUsedXFTPServers: [XFTPServerSummary] - public var previouslyUsedXFTPServers: [XFTPServerSummary] -} - -public struct XFTPTotals: Codable { - public var sessions: ServerSessions - public var stats: AgentXFTPServerStatsData -} - -public struct XFTPServerSummary: Codable, Identifiable { - public var xftpServer: String - public var known: Bool? - public var sessions: ServerSessions? - public var stats: AgentXFTPServerStatsData? - public var rcvInProgress: Bool - public var sndInProgress: Bool - public var delInProgress: Bool - - public var id: String { xftpServer } -} - -public struct AgentXFTPServerStatsData: Codable { - public var _uploads: Int - public var _uploadsSize: Int64 - public var _uploadAttempts: Int - public var _uploadErrs: Int - public var _downloads: Int - public var _downloadsSize: Int64 - public var _downloadAttempts: Int - public var _downloadAuthErrs: Int - public var _downloadErrs: Int - public var _deletions: Int - public var _deleteAttempts: Int - public var _deleteErrs: Int -} - -public struct AgentNtfServerStatsData: Codable { - public var _ntfCreated: Int - public var _ntfCreateAttempts: Int - public var _ntfChecked: Int - public var _ntfCheckAttempts: Int - public var _ntfDeleted: Int - public var _ntfDelAttempts: Int -} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index fb34ba390c..35c276b2f4 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3935,7 +3935,7 @@ public enum MsgContent: Equatable, Hashable { } } - var cmdString: String { + public var cmdString: String { "json \(encodeJSON(self))" } diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift index d0bf5eeb6e..c99e004d92 100644 --- a/apps/ios/SimpleXChat/ErrorAlert.swift +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -37,7 +37,7 @@ public struct ErrorAlert: Error { } public init(_ error: any Error) { - self = if let chatResponse = error as? ChatResponse { + self = if let chatResponse = error as? ChatRespProtocol { ErrorAlert(chatResponse) } else { ErrorAlert("\(error.localizedDescription)") @@ -48,7 +48,7 @@ public struct ErrorAlert: Error { self = ErrorAlert("\(chatErrorString(chatError))") } - public init(_ chatResponse: ChatResponse) { + public init(_ chatResponse: ChatRespProtocol) { self = if let networkErrorAlert = getNetworkErrorAlert(chatResponse) { networkErrorAlert } else { @@ -94,22 +94,21 @@ extension View { } } -public func getNetworkErrorAlert(_ r: ChatResponse) -> ErrorAlert? { - switch r { - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): - return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): - return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .HOST))): - return ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TRANSPORT(.version)))): - return ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).") - case let .chatCmdError(_, .errorAgent(.SMP(serverAddress, .PROXY(proxyErr)))): - return smpProxyErrorAlert(proxyErr, serverAddress) - case let .chatCmdError(_, .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr))))): - return proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer) - default: - return nil +public func getNetworkErrorAlert(_ r: ChatRespProtocol) -> ErrorAlert? { + switch r.chatError { + case let .errorAgent(.BROKER(addr, .TIMEOUT)): + ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") + case let .errorAgent(.BROKER(addr, .NETWORK)): + ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") + case let .errorAgent(.BROKER(addr, .HOST)): + ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).") + case let .errorAgent(.BROKER(addr, .TRANSPORT(.version))): + ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).") + case let .errorAgent(.SMP(serverAddress, .PROXY(proxyErr))): + smpProxyErrorAlert(proxyErr, serverAddress) + case let .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr)))): + proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer) + default: nil } } From a0d1cca3895ec1b26e532287d4d28ff31cd43e80 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 4 May 2025 22:14:36 +0100 Subject: [PATCH 205/289] core: split response to two types, to improve iOS parsing memory usage (#5867) * core: split response to two types, to improve iOS parsing memory usage * ios: split core events to separate types * comment * limit more events to CLI * fix parser * simplemq --- apps/ios/Shared/Model/AppAPITypes.swift | 497 ++++++++++-------- apps/ios/Shared/Model/SimpleXAPI.swift | 45 +- .../Views/Migration/MigrateFromDevice.swift | 12 +- .../Views/Migration/MigrateToDevice.swift | 10 +- apps/ios/SimpleX NSE/NSEAPITypes.swift | 128 +++-- .../ios/SimpleX NSE/NotificationService.swift | 10 +- apps/ios/SimpleX SE/ShareAPI.swift | 114 +++- apps/ios/SimpleX SE/ShareModel.swift | 4 +- apps/ios/SimpleXChat/API.swift | 4 +- apps/ios/SimpleXChat/APITypes.swift | 8 + .../chat/simplex/common/model/SimpleXAPI.kt | 24 - apps/simplex-bot-advanced/Main.hs | 8 +- .../src/Broadcast/Bot.hs | 41 +- apps/simplex-chat/Server.hs | 39 +- .../src/Directory/Events.hs | 37 +- .../src/Directory/Service.hs | 4 +- cabal.project | 2 +- .../typescript/src/response.ts | 6 - scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Bot.hs | 8 +- src/Simplex/Chat/Controller.hs | 316 +++++------ src/Simplex/Chat/Library/Commands.hs | 170 +++--- src/Simplex/Chat/Library/Internal.hs | 117 +++-- src/Simplex/Chat/Library/Subscriber.hs | 345 ++++++------ src/Simplex/Chat/Mobile.hs | 25 +- src/Simplex/Chat/Remote.hs | 22 +- src/Simplex/Chat/Remote/Protocol.hs | 6 +- src/Simplex/Chat/Terminal/Input.hs | 1 - src/Simplex/Chat/Terminal/Main.hs | 13 +- src/Simplex/Chat/Terminal/Output.hs | 48 +- src/Simplex/Chat/View.hs | 460 +++++++++------- 31 files changed, 1394 insertions(+), 1132 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index d7f96284cf..37d016e93d 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -587,7 +587,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case chatStarted case chatRunning case chatStopped - case chatSuspended case apiChats(user: UserRef, chats: [ChatData]) case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?) case chatTags(user: UserRef, userTags: [ChatTag]) @@ -606,14 +605,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case groupMemberSwitchStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) case contactSwitchAborted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) case groupMemberSwitchAborted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) - case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) case contactRatchetSyncStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) case groupMemberRatchetSyncStarted(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) - case contactRatchetSync(user: UserRef, contact: Contact, ratchetSyncProgress: RatchetSyncProgress) - case groupMemberRatchetSync(user: UserRef, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress) - case contactVerificationReset(user: UserRef, contact: Contact) - case groupMemberVerificationReset(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case contactCode(user: UserRef, contact: Contact, connectionCode: String) case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) @@ -627,7 +620,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) - case contactDeletedByContact(user: UserRef, contact: Contact) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -640,113 +632,57 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case userContactLinkUpdated(user: User, contactLink: UserContactLink) case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink) case userContactLinkDeleted(user: User) - case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) - case contactConnecting(user: UserRef, contact: Contact) - case contactSndReady(user: UserRef, contact: Contact) - case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef) - case contactUpdated(user: UserRef, toContact: Contact) - case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) - case networkStatus(networkStatus: NetworkStatus, connections: [String]) case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) - case groupSubscribed(user: UserRef, groupInfo: GroupRef) - case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) - case groupEmpty(user: UserRef, groupInfo: GroupInfo) - case userContactLinkSubscribed case newChatItems(user: UserRef, chatItems: [AChatItem]) case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) - case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) case chatItemUpdated(user: UserRef, chatItem: AChatItem) case chatItemNotChanged(user: UserRef, chatItem: AChatItem) case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) case reactionMembers(user: UserRef, memberReactions: [MemberReaction]) case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) case contactsList(user: UserRef, contacts: [Contact]) - // group events + // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) - case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) - case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: SimpleXChat.Group) - 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 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) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) - case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool) - case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) - case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) - case groupInvitation(user: UserRef, groupInfo: GroupInfo) // unused - case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) - case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) - case groupRemoved(user: UserRef, groupInfo: GroupInfo) // unused case groupUpdated(user: UserRef, toGroup: GroupInfo) case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) - case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) - // receiving file events + // receiving file responses case rcvFileAccepted(user: UserRef, chatItem: AChatItem) case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) case standaloneFileInfo(fileMeta: MigrationFileLinkData?) case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats - case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) - case rcvFileComplete(user: UserRef, chatItem: AChatItem) - case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) - case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) - case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) - case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) - // sending file events - case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + // sending file responses case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used - case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) - case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) - case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) - case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) - case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - // call events - case callInvitation(callInvitation: RcvCallInvitation) - case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) - case callAnswer(user: UserRef, contact: Contact, answer: WebRTCSession) - case callExtraInfo(user: UserRef, contact: Contact, extraInfo: WebRTCExtraInfo) - case callEnded(user: UserRef, contact: Contact) + // call invitations case callInvitations(callInvitations: [RcvCallInvitation]) + // notifications case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) case ntfConns(ntfConns: [NtfConn]) case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) - case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) - case contactDisabled(user: UserRef, contact: Contact) - // remote desktop responses/events + // remote desktop responses case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) - case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) - // pq - case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user_: UserRef?) @@ -754,7 +690,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary) case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) case chatCmdError(user_: UserRef?, chatError: ChatError) - case chatError(user_: UserRef?, chatError: ChatError) case archiveExported(archiveErrors: [ArchiveError]) case archiveImported(archiveErrors: [ArchiveError]) case appSettings(appSettings: AppSettings) @@ -768,7 +703,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .chatStarted: return "chatStarted" case .chatRunning: return "chatRunning" case .chatStopped: return "chatStopped" - case .chatSuspended: return "chatSuspended" case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .chatTags: return "chatTags" @@ -787,14 +721,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .groupMemberSwitchStarted: return "groupMemberSwitchStarted" case .contactSwitchAborted: return "contactSwitchAborted" case .groupMemberSwitchAborted: return "groupMemberSwitchAborted" - case .contactSwitch: return "contactSwitch" - case .groupMemberSwitch: return "groupMemberSwitch" case .contactRatchetSyncStarted: return "contactRatchetSyncStarted" case .groupMemberRatchetSyncStarted: return "groupMemberRatchetSyncStarted" - case .contactRatchetSync: return "contactRatchetSync" - case .groupMemberRatchetSync: return "groupMemberRatchetSync" - case .contactVerificationReset: return "contactVerificationReset" - case .groupMemberVerificationReset: return "groupMemberVerificationReset" case .contactCode: return "contactCode" case .groupMemberCode: return "groupMemberCode" case .connectionVerified: return "connectionVerified" @@ -808,7 +736,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .sentInvitationToContact: return "sentInvitationToContact" case .contactAlreadyExists: return "contactAlreadyExists" case .contactDeleted: return "contactDeleted" - case .contactDeletedByContact: return "contactDeletedByContact" case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" @@ -821,24 +748,12 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .userContactLinkUpdated: return "userContactLinkUpdated" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" - case .contactConnected: return "contactConnected" - case .contactConnecting: return "contactConnecting" - case .contactSndReady: return "contactSndReady" - case .receivedContactRequest: return "receivedContactRequest" case .acceptingContactRequest: return "acceptingContactRequest" case .contactRequestRejected: return "contactRequestRejected" - case .contactUpdated: return "contactUpdated" - case .groupMemberUpdated: return "groupMemberUpdated" - case .networkStatus: return "networkStatus" case .networkStatuses: return "networkStatuses" - case .groupSubscribed: return "groupSubscribed" - case .memberSubErrors: return "memberSubErrors" - case .groupEmpty: return "groupEmpty" - case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .newChatItems: return "newChatItems" case .groupChatItemsDeleted: return "groupChatItemsDeleted" case .forwardPlan: return "forwardPlan" - case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" case .chatItemUpdated: return "chatItemUpdated" case .chatItemNotChanged: return "chatItemNotChanged" case .chatItemReaction: return "chatItemReaction" @@ -848,87 +763,42 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .groupCreated: return "groupCreated" case .sentGroupInvitation: return "sentGroupInvitation" case .userAcceptedGroupSent: return "userAcceptedGroupSent" - case .groupLinkConnecting: return "groupLinkConnecting" - case .businessLinkConnecting: return "businessLinkConnecting" case .userDeletedMembers: return "userDeletedMembers" case .leftMemberUser: return "leftMemberUser" case .groupMembers: return "groupMembers" - case .receivedGroupInvitation: return "receivedGroupInvitation" case .groupDeletedUser: return "groupDeletedUser" - case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" - case .memberRole: return "memberRole" case .membersRoleUser: return "membersRoleUser" - case .memberBlockedForAll: return "memberBlockedForAll" case .membersBlockedForAllUser: return "membersBlockedForAllUser" - case .deletedMemberUser: return "deletedMemberUser" - case .deletedMember: return "deletedMember" - case .leftMember: return "leftMember" - case .groupDeleted: return "groupDeleted" - case .contactsMerged: return "contactsMerged" - case .groupInvitation: return "groupInvitation" - case .userJoinedGroup: return "userJoinedGroup" - case .joinedGroupMember: return "joinedGroupMember" - case .connectedToGroupMember: return "connectedToGroupMember" - case .groupRemoved: return "groupRemoved" case .groupUpdated: return "groupUpdated" case .groupLinkCreated: return "groupLinkCreated" case .groupLink: return "groupLink" case .groupLinkDeleted: return "groupLinkDeleted" case .newMemberContact: return "newMemberContact" case .newMemberContactSentInv: return "newMemberContactSentInv" - case .newMemberContactReceivedInv: return "newMemberContactReceivedInv" case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" case .standaloneFileInfo: return "standaloneFileInfo" case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated" - case .rcvFileStart: return "rcvFileStart" - case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" - case .rcvFileComplete: return "rcvFileComplete" - case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete" case .rcvFileCancelled: return "rcvFileCancelled" - case .rcvFileSndCancelled: return "rcvFileSndCancelled" - case .rcvFileError: return "rcvFileError" - case .rcvFileWarning: return "rcvFileWarning" - case .sndFileStart: return "sndFileStart" - case .sndFileComplete: return "sndFileComplete" case .sndFileCancelled: return "sndFileCancelled" case .sndStandaloneFileCreated: return "sndStandaloneFileCreated" case .sndFileStartXFTP: return "sndFileStartXFTP" - case .sndFileProgressXFTP: return "sndFileProgressXFTP" - case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP" - case .sndFileRcvCancelled: return "sndFileRcvCancelled" - case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" - case .sndStandaloneFileComplete: return "sndStandaloneFileComplete" case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" - case .sndFileError: return "sndFileError" - case .sndFileWarning: return "sndFileWarning" - case .callInvitation: return "callInvitation" - case .callOffer: return "callOffer" - case .callAnswer: return "callAnswer" - case .callExtraInfo: return "callExtraInfo" - case .callEnded: return "callEnded" case .callInvitations: return "callInvitations" case .ntfTokenStatus: return "ntfTokenStatus" case .ntfToken: return "ntfToken" case .ntfConns: return "ntfConns" case .connNtfMessages: return "connNtfMessages" - case .ntfMessage: return "ntfMessage" case .contactConnectionDeleted: return "contactConnectionDeleted" - case .contactDisabled: return "contactDisabled" case .remoteCtrlList: return "remoteCtrlList" - case .remoteCtrlFound: return "remoteCtrlFound" case .remoteCtrlConnecting: return "remoteCtrlConnecting" - case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" case .remoteCtrlConnected: return "remoteCtrlConnected" - case .remoteCtrlStopped: return "remoteCtrlStopped" - case .contactPQEnabled: return "contactPQEnabled" case .versionInfo: return "versionInfo" case .cmdOk: return "cmdOk" case .agentSubsTotal: return "agentSubsTotal" case .agentServersSummary: return "agentServersSummary" case .agentSubsSummary: return "agentSubsSummary" case .chatCmdError: return "chatCmdError" - case .chatError: return "chatError" case .archiveExported: return "archiveExported" case .archiveImported: return "archiveImported" case .appSettings: return "appSettings" @@ -945,7 +815,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .chatStarted: return noDetails case .chatRunning: return noDetails case .chatStopped: return noDetails - case .chatSuspended: return noDetails case let .apiChats(u, chats): return withUser(u, String(describing: chats)) case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") @@ -966,14 +835,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") - case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") - case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") - case let .contactVerificationReset(u, contact): return withUser(u, "contact: \(String(describing: contact))") - case let .groupMemberVerificationReset(u, groupInfo, member): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))") case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") @@ -987,7 +850,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) - case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) 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)) @@ -1000,29 +862,15 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) case .userContactLinkDeleted: return noDetails - case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) - case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) - case let .contactSndReady(u, contact): return withUser(u, String(describing: contact)) - case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails - case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) - case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") - case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) - case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) - case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case .userContactLinkSubscribed: return noDetails case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") - case let .chatItemsStatusesUpdated(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") @@ -1035,87 +883,42 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") - case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") - case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) - 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 .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)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") - case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)") - case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") - case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") - case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") - case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") - case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) case .rcvStandaloneFileCreated: return noDetails - case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") - case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) - case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") - case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") - case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case .sndStandaloneFileCreated: return noDetails case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") - case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) - case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .callInvitation(inv): return String(describing: inv) - case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") - case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") - case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") - case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") case let .callInvitations(invs): return String(describing: invs) case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" case let .ntfConns(ntfConns): return String(describing: ntfConns) case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" - case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) - case let .contactDisabled(u, contact): return withUser(u, String(describing: contact)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" - case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) - case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" - case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" case .cmdOk: return noDetails case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)") case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) case let .archiveExported(archiveErrors): return String(describing: archiveErrors) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) @@ -1162,10 +965,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { if let jError = jResp["chatCmdError"] as? NSDictionary { return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) } - } else if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } } } json = serializeJSON(j, options: .prettyPrinted) @@ -1176,7 +975,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { var chatError: ChatError? { switch self { case let .chatCmdError(_, error): error - case let .chatError(_, error): error default: nil } } @@ -1184,6 +982,289 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { var chatErrorType: ChatErrorType? { switch self { case let .chatCmdError(_, .error(error)): error + default: nil + } + } +} + +enum ChatEvent: Decodable, ChatEventProtocol { + case event(type: String, json: String) + case chatSuspended + case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) + case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) + case contactRatchetSync(user: UserRef, contact: Contact, ratchetSyncProgress: RatchetSyncProgress) + case groupMemberRatchetSync(user: UserRef, groupInfo: GroupInfo, member: GroupMember, ratchetSyncProgress: RatchetSyncProgress) + case contactDeletedByContact(user: UserRef, contact: Contact) + case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) + case contactConnecting(user: UserRef, contact: Contact) + case contactSndReady(user: UserRef, contact: Contact) + case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) + case contactUpdated(user: UserRef, toContact: Contact) + case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) + case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) + case networkStatus(networkStatus: NetworkStatus, connections: [String]) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) + case chatItemUpdated(user: UserRef, chatItem: AChatItem) + case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction) + case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) + // group events + case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) + case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) + case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) + case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) + case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) + case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool) + case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool) + case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) + case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) + case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) + case groupUpdated(user: UserRef, toGroup: GroupInfo) + case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) + // receiving file events + case rcvFileAccepted(user: UserRef, chatItem: AChatItem) + case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats + case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) + case rcvFileComplete(user: UserRef, chatItem: AChatItem) + case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) + case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) + case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) + // sending file events + case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) + case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) + case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + // call events + case callInvitation(callInvitation: RcvCallInvitation) + case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(user: UserRef, contact: Contact, answer: WebRTCSession) + case callExtraInfo(user: UserRef, contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(user: UserRef, contact: Contact) + case contactDisabled(user: UserRef, contact: Contact) + // notification marker + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) + // remote desktop responses + case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) + case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) + case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) + // pq + case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) + case chatError(user_: UserRef?, chatError: ChatError) + + var eventType: String { + switch self { + case let .event(type, _): "* \(type)" + case .chatSuspended: "chatSuspended" + case .contactSwitch: "contactSwitch" + case .groupMemberSwitch: "groupMemberSwitch" + case .contactRatchetSync: "contactRatchetSync" + case .groupMemberRatchetSync: "groupMemberRatchetSync" + case .contactDeletedByContact: "contactDeletedByContact" + case .contactConnected: "contactConnected" + case .contactConnecting: "contactConnecting" + case .contactSndReady: "contactSndReady" + case .receivedContactRequest: "receivedContactRequest" + case .contactUpdated: "contactUpdated" + case .groupMemberUpdated: "groupMemberUpdated" + case .contactsMerged: "contactsMerged" + case .networkStatus: "networkStatus" + case .networkStatuses: "networkStatuses" + case .newChatItems: "newChatItems" + case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" + case .chatItemUpdated: "chatItemUpdated" + case .chatItemReaction: "chatItemReaction" + case .chatItemsDeleted: "chatItemsDeleted" + case .groupChatItemsDeleted: "groupChatItemsDeleted" + case .receivedGroupInvitation: "receivedGroupInvitation" + case .userAcceptedGroupSent: "userAcceptedGroupSent" + case .groupLinkConnecting: "groupLinkConnecting" + case .businessLinkConnecting: "businessLinkConnecting" + case .joinedGroupMemberConnecting: "joinedGroupMemberConnecting" + case .memberRole: "memberRole" + case .memberBlockedForAll: "memberBlockedForAll" + case .deletedMemberUser: "deletedMemberUser" + case .deletedMember: "deletedMember" + case .leftMember: "leftMember" + case .groupDeleted: "groupDeleted" + case .userJoinedGroup: "userJoinedGroup" + case .joinedGroupMember: "joinedGroupMember" + case .connectedToGroupMember: "connectedToGroupMember" + case .groupUpdated: "groupUpdated" + case .newMemberContactReceivedInv: "newMemberContactReceivedInv" + case .rcvFileAccepted: "rcvFileAccepted" + case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" + case .rcvFileStart: "rcvFileStart" + case .rcvFileProgressXFTP: "rcvFileProgressXFTP" + case .rcvFileComplete: "rcvFileComplete" + case .rcvStandaloneFileComplete: "rcvStandaloneFileComplete" + case .rcvFileSndCancelled: "rcvFileSndCancelled" + case .rcvFileError: "rcvFileError" + case .rcvFileWarning: "rcvFileWarning" + case .sndFileStart: "sndFileStart" + case .sndFileComplete: "sndFileComplete" + case .sndFileRcvCancelled: "sndFileRcvCancelled" + case .sndFileProgressXFTP: "sndFileProgressXFTP" + case .sndFileRedirectStartXFTP: "sndFileRedirectStartXFTP" + case .sndFileCompleteXFTP: "sndFileCompleteXFTP" + case .sndStandaloneFileComplete: "sndStandaloneFileComplete" + case .sndFileError: "sndFileError" + case .sndFileWarning: "sndFileWarning" + case .callInvitation: "callInvitation" + case .callOffer: "callOffer" + case .callAnswer: "callAnswer" + case .callExtraInfo: "callExtraInfo" + case .callEnded: "callEnded" + case .contactDisabled: "contactDisabled" + case .ntfMessage: "ntfMessage" + case .remoteCtrlFound: "remoteCtrlFound" + case .remoteCtrlSessionCode: "remoteCtrlSessionCode" + case .remoteCtrlConnected: "remoteCtrlConnected" + case .remoteCtrlStopped: "remoteCtrlStopped" + case .contactPQEnabled: "contactPQEnabled" + case .chatError: "chatError" + } + } + + var details: String { + switch self { + case let .event(_, json): return json + case .chatSuspended: return noDetails + case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") + case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") + case let .contactRatchetSync(u, contact, ratchetSyncProgress): return withUser(u, "contact: \(String(describing: contact))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .groupMemberRatchetSync(u, groupInfo, member, ratchetSyncProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nratchetSyncProgress: \(String(describing: ratchetSyncProgress))") + case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) + case let .contactSndReady(u, contact): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") + case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") + case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .chatItemsStatusesUpdated(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") + case let .chatItemsDeleted(u, items, byUser): + let itemsString = items.map { item in + "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") + return withUser(u, itemsString + "\nbyUser: \(byUser)") + case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): + return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") + case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") + case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") + case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\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 .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)") + case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)") + case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") + case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") + case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) + case .rcvFileAcceptedSndCancelled: return noDetails + case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") + case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) + case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") + case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") + case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) + case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) + case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .callInvitation(inv): return String(describing: inv) + case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") + case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") + case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") + case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") + case let .contactDisabled(u, contact): return withUser(u, String(describing: contact)) + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") + case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" + case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" + case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" + case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) + } + } + + private var noDetails: String { "\(eventType): no details" } + + static func chatEvent(_ s: String) -> ChatEvent { + let d = s.data(using: .utf8)! + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = // try callWithLargeStack { + try jsonDecoder.decode(APIResponse.self, from: d) +// } + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { + type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } + if type == "chatError" { + if let jError = jResp["chatError"] as? NSDictionary { + return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } + } + json = serializeJSON(j, options: .prettyPrinted) + } + return ChatEvent.event(type: type ?? "invalid", json: json ?? s) + } + + var chatError: ChatError? { + switch self { + case let .chatError(_, error): error + default: nil + } + } + + var chatErrorType: ChatErrorType? { + switch self { case let .chatError(_, .error(error)): error default: nil } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4e9c8ce7b6..a6b9e719c7 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -20,12 +20,14 @@ private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) case resp(Date, ChatResponse) + case event(Date, ChatEvent) var id: Date { get { switch self { - case let .cmd(id, _): return id - case let .resp(id, _): return id + case let .cmd(d, _): return d + case let .resp(d, _): return d + case let .event(d, _): return d } } } @@ -35,6 +37,7 @@ enum TerminalItem: Identifiable { switch self { case let .cmd(_, cmd): return "> \(cmd.cmdString.prefix(30))" case let .resp(_, resp): return "< \(resp.responseType)" + case let .event(_, evt): return "< \(evt.eventType)" } } } @@ -44,6 +47,7 @@ enum TerminalItem: Identifiable { switch self { case let .cmd(_, cmd): return cmd.cmdString case let .resp(_, resp): return resp.details + case let .event(_, evt): return evt.details } } } @@ -112,12 +116,12 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil } } -func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? { +func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatEvent? { await withCheckedContinuation { cont in - _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in - let resp: ChatResponse? = recvSimpleXMsg(ctrl) - cont.resume(returning: resp) - return resp + _ = withBGTask(bgDelay: msgDelay) { () -> ChatEvent? in + let evt: ChatEvent? = recvSimpleXMsg(ctrl) + cont.resume(returning: evt) + return evt } } } @@ -476,8 +480,11 @@ 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) - if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } - throw r + switch r { + case let .chatItemUpdated(_, aChatItem): return aChatItem.chatItem + case let .chatItemNotChanged(_, aChatItem): return aChatItem.chatItem + default: throw r + } } func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem { @@ -1280,6 +1287,10 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool switch r { case let .rcvFileAccepted(_, chatItem): await chatItemSimpleUpdate(user, chatItem) +// TODO when aChatItem added +// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): +// await chatItemSimpleUpdate(user, aChatItem) +// Task { cleanupFile(aChatItem) } default: if let chatError = r.chatErrorType { switch chatError { @@ -1925,7 +1936,7 @@ class ChatReceiver { private var receiveMessages = true private var _lastMsgTime = Date.now - var messagesChannel: ((ChatResponse) -> Void)? = nil + var messagesChannel: ((ChatEvent) -> Void)? = nil static let shared = ChatReceiver() @@ -1960,13 +1971,13 @@ class ChatReceiver { } } -func processReceivedMsg(_ res: ChatResponse) async { +func processReceivedMsg(_ res: ChatEvent) async { Task { - await TerminalItems.shared.add(.resp(.now, res)) + await TerminalItems.shared.add(.event(.now, res)) } let m = ChatModel.shared let n = NetworkModel.shared - logger.debug("processReceivedMsg: \(res.responseType)") + logger.debug("processReceivedMsg: \(res.eventType)") switch res { case let .contactDeletedByContact(user, contact): if active(user) && contact.directOrUsed { @@ -2281,6 +2292,10 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE await chatItemSimpleUpdate(user, aChatItem) +// TODO when aChatItem added +// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): // usually rcvFileAcceptedSndCancelled is a response, but it's also an event for XFTP files auto-accepted from NSE +// await chatItemSimpleUpdate(user, aChatItem) +// Task { cleanupFile(aChatItem) } case let .rcvFileStart(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): @@ -2460,14 +2475,14 @@ func processReceivedMsg(_ res: ChatResponse) async { } } default: - logger.debug("unsupported event: \(res.responseType)") + logger.debug("unsupported event: \(res.eventType)") } func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async { if let call = m.activeCall, call.contact.apiId == contact.apiId { await perform(call) } else { - logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") + logger.debug("processReceivedMsg: ignoring \(res.eventType), not in call with the contact \(contact.id)") } } } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index dfe9e37bd6..c684ad627a 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -550,7 +550,7 @@ struct MigrateFromDevice: View { alert = .error(title: "Upload failed", error: "Check your internet connection and try again") migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) default: - logger.debug("unsupported event: \(msg.responseType)") + logger.debug("unsupported event: \(msg.eventType)") } } } @@ -733,11 +733,11 @@ func chatStoppedView() -> some View { private class MigrationChatReceiver { let ctrl: chat_ctrl let databaseUrl: URL - let processReceivedMsg: (ChatResponse) async -> Void + let processReceivedMsg: (ChatEvent) async -> Void private var receiveLoop: Task? private var receiveMessages = true - init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatEvent) async -> Void) { self.ctrl = ctrl self.databaseUrl = databaseUrl self.processReceivedMsg = processReceivedMsg @@ -752,11 +752,11 @@ private class MigrationChatReceiver { func receiveMsgLoop() async { // TODO use function that has timeout - if let msg: ChatResponse = await chatRecvMsg(ctrl) { + if let msg: ChatEvent = await chatRecvMsg(ctrl) { Task { - await TerminalItems.shared.add(.resp(.now, msg)) + await TerminalItems.shared.add(.event(.now, msg)) } - logger.debug("processReceivedMsg: \(msg.responseType)") + logger.debug("processReceivedMsg: \(msg.eventType)") await processReceivedMsg(msg) } if self.receiveMessages { diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 1a740874a6..19cefa7f4d 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -516,7 +516,7 @@ struct MigrateToDevice: View { alert = .error(title: "Download failed", error: "File was deleted or link is invalid") migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) default: - logger.debug("unsupported event: \(msg.responseType)") + logger.debug("unsupported event: \(msg.eventType)") } } } @@ -751,11 +751,11 @@ private func progressView() -> some View { private class MigrationChatReceiver { let ctrl: chat_ctrl let databaseUrl: URL - let processReceivedMsg: (ChatResponse) async -> Void + let processReceivedMsg: (ChatEvent) async -> Void private var receiveLoop: Task? private var receiveMessages = true - init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatEvent) async -> Void) { self.ctrl = ctrl self.databaseUrl = databaseUrl self.processReceivedMsg = processReceivedMsg @@ -772,9 +772,9 @@ private class MigrationChatReceiver { // TODO use function that has timeout if let msg = await chatRecvMsg(ctrl) { Task { - await TerminalItems.shared.add(.resp(.now, msg)) + await TerminalItems.shared.add(.event(.now, msg)) } - logger.debug("processReceivedMsg: \(msg.responseType)") + logger.debug("processReceivedMsg: \(msg.eventType)") await processReceivedMsg(msg) } if self.receiveMessages { diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index b1ab5e76c2..7569547e6a 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -52,21 +52,12 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { case activeUser(user: User) case chatStarted case chatRunning - case chatSuspended - case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) - case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) - case newChatItems(user: UserRef, chatItems: [AChatItem]) case rcvFileAccepted(user: UserRef, chatItem: AChatItem) - case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) - case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) - case callInvitation(callInvitation: RcvCallInvitation) case ntfConns(ntfConns: [NtfConn]) case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) case cmdOk(user_: UserRef?) case chatCmdError(user_: UserRef?, chatError: ChatError) - case chatError(user_: UserRef?, chatError: ChatError) var responseType: String { switch self { @@ -74,21 +65,12 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { case .activeUser: "activeUser" case .chatStarted: "chatStarted" case .chatRunning: "chatRunning" - case .chatSuspended: "chatSuspended" - case .contactConnected: "contactConnected" - case .receivedContactRequest: "receivedContactRequest" - case .newChatItems: "newChatItems" case .rcvFileAccepted: "rcvFileAccepted" - case .rcvFileSndCancelled: "rcvFileSndCancelled" - case .sndFileComplete: "sndFileComplete" - case .sndFileRcvCancelled: "sndFileRcvCancelled" - case .callInvitation: "callInvitation" case .ntfConns: "ntfConns" case .connNtfMessages: "connNtfMessages" case .ntfMessage: "ntfMessage" case .cmdOk: "cmdOk" case .chatCmdError: "chatCmdError" - case .chatError: "chatError" } } @@ -98,23 +80,12 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { case let .activeUser(user): return String(describing: user) case .chatStarted: return noDetails case .chatRunning: return noDetails - case .chatSuspended: return noDetails - case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) - case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) - case let .newChatItems(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .callInvitation(inv): return String(describing: inv) case let .ntfConns(ntfConns): return String(describing: ntfConns) case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case .cmdOk: return noDetails case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } } @@ -144,10 +115,6 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { if let jError = jResp["chatCmdError"] as? NSDictionary { return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) } - } else if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } } } json = serializeJSON(j, options: .prettyPrinted) @@ -158,7 +125,6 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { var chatError: ChatError? { switch self { case let .chatCmdError(_, error): error - case let .chatError(_, error): error default: nil } } @@ -166,6 +132,100 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { var chatErrorType: ChatErrorType? { switch self { case let .chatCmdError(_, .error(error)): error + default: nil + } + } +} + +enum NSEChatEvent: Decodable, Error, ChatEventProtocol { + case event(type: String, json: String) + case chatSuspended + case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) + case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) + case newChatItems(user: UserRef, chatItems: [AChatItem]) + case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case callInvitation(callInvitation: RcvCallInvitation) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) + case chatError(user_: UserRef?, chatError: ChatError) + + var eventType: String { + switch self { + case let .event(type, _): "* \(type)" + case .chatSuspended: "chatSuspended" + case .contactConnected: "contactConnected" + case .receivedContactRequest: "receivedContactRequest" + case .newChatItems: "newChatItems" + case .rcvFileSndCancelled: "rcvFileSndCancelled" + case .sndFileComplete: "sndFileComplete" + case .sndFileRcvCancelled: "sndFileRcvCancelled" + case .callInvitation: "callInvitation" + case .ntfMessage: "ntfMessage" + case .chatError: "chatError" + } + } + + var details: String { + switch self { + case let .event(_, json): return json + case .chatSuspended: return noDetails + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .callInvitation(inv): return String(describing: inv) + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) + } + } + + var noDetails: String { "\(eventType): no details" } + + static func chatEvent(_ s: String) -> NSEChatEvent { + let d = s.data(using: .utf8)! + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { + type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } + if type == "chatError" { + if let jError = jResp["chatError"] as? NSDictionary { + return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } + } + json = serializeJSON(j, options: .prettyPrinted) + } + return NSEChatEvent.event(type: type ?? "invalid", json: json ?? s) + } + + var chatError: ChatError? { + switch self { + case let .chatError(_, error): error + default: nil + } + } + + var chatErrorType: ChatErrorType? { + switch self { case let .chatError(_, .error(error)): error default: nil } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 0bfa21781e..e8dd21f23c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -789,9 +789,9 @@ func receiveMessages() async { } } -func chatRecvMsg() async -> NSEChatResponse? { +func chatRecvMsg() async -> NSEChatEvent? { await withCheckedContinuation { cont in - let resp: NSEChatResponse? = recvSimpleXMsg() + let resp: NSEChatEvent? = recvSimpleXMsg() cont.resume(returning: resp) } } @@ -799,8 +799,8 @@ func chatRecvMsg() async -> NSEChatResponse? { private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } -func receivedMsgNtf(_ res: NSEChatResponse) async -> (String, NSENotificationData)? { - logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") +func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? { + logger.debug("NotificationService receivedMsgNtf: \(res.eventType)") switch res { case let .contactConnected(user, contact, _): return (contact.id, .contactConnected(user, contact)) @@ -849,7 +849,7 @@ func receivedMsgNtf(_ res: NSEChatResponse) async -> (String, NSENotificationDat logger.error("NotificationService receivedMsgNtf error: \(String(describing: err))") return nil default: - logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)") + logger.debug("NotificationService receivedMsgNtf ignored event: \(res.eventType)") return nil } } diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 56f1c2f5f3..0f12b002f7 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -96,7 +96,7 @@ func apiSuspendChat(expired: Bool) { if case .cmdOk = r, !expired { let startTime = CFAbsoluteTimeGetCurrent() while CFAbsoluteTimeGetCurrent() - startTime < 3 { - let msg: SEChatResponse? = recvSimpleXMsg(messageTimeout: 3_500000) + let msg: SEChatEvent? = recvSimpleXMsg(messageTimeout: 3_500000) switch msg { case .chatSuspended: suspended = false @@ -156,17 +156,10 @@ enum SEChatResponse: Decodable, Error, ChatRespProtocol { case activeUser(user: User) case chatStarted case chatRunning - case chatSuspended case apiChats(user: UserRef, chats: [ChatData]) case newChatItems(user: UserRef, chatItems: [AChatItem]) - case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) - case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) - case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) - case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) case cmdOk(user_: UserRef?) case chatCmdError(user_: UserRef?, chatError: ChatError) - case chatError(user_: UserRef?, chatError: ChatError) var responseType: String { switch self { @@ -174,17 +167,10 @@ enum SEChatResponse: Decodable, Error, ChatRespProtocol { case .activeUser: "activeUser" case .chatStarted: "chatStarted" case .chatRunning: "chatRunning" - case .chatSuspended: "chatSuspended" case .apiChats: "apiChats" case .newChatItems: "newChatItems" - case .sndFileProgressXFTP: "sndFileProgressXFTP" - case .sndFileCompleteXFTP: "sndFileCompleteXFTP" - case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" - case .sndFileError: "sndFileError" - case .sndFileWarning: "sndFileWarning" case .cmdOk: "cmdOk" case .chatCmdError: "chatCmdError" - case .chatError: "chatError" } } @@ -194,21 +180,12 @@ enum SEChatResponse: Decodable, Error, ChatRespProtocol { case let .activeUser(user): return String(describing: user) case .chatStarted: return noDetails case .chatRunning: return noDetails - case .chatSuspended: return noDetails case let .apiChats(u, chats): return withUser(u, String(describing: chats)) case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) - case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") - case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .chatItemsStatusesUpdated(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) - case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") case .cmdOk: return noDetails case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } } @@ -242,10 +219,6 @@ enum SEChatResponse: Decodable, Error, ChatRespProtocol { if let jError = jResp["chatCmdError"] as? NSDictionary { return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) } - } else if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } } } json = serializeJSON(j, options: .prettyPrinted) @@ -256,7 +229,6 @@ enum SEChatResponse: Decodable, Error, ChatRespProtocol { var chatError: ChatError? { switch self { case let .chatCmdError(_, error): error - case let .chatError(_, error): error default: nil } } @@ -264,6 +236,90 @@ enum SEChatResponse: Decodable, Error, ChatRespProtocol { var chatErrorType: ChatErrorType? { switch self { case let .chatCmdError(_, .error(error)): error + default: nil + } + } +} + +enum SEChatEvent: Decodable, Error, ChatEventProtocol { + case event(type: String, json: String) + case chatSuspended + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) + case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case chatError(user_: UserRef?, chatError: ChatError) + + var eventType: String { + switch self { + case let .event(type, _): "* \(type)" + case .chatSuspended: "chatSuspended" + case .sndFileProgressXFTP: "sndFileProgressXFTP" + case .sndFileCompleteXFTP: "sndFileCompleteXFTP" + case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" + case .sndFileError: "sndFileError" + case .sndFileWarning: "sndFileWarning" + case .chatError: "chatError" + } + } + + var details: String { + switch self { + case let .event(_, json): return json + case .chatSuspended: return noDetails + case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .chatItemsStatusesUpdated(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) + } + } + + var noDetails: String { "\(eventType): no details" } + + static func chatEvent(_ s: String) -> SEChatEvent { + let d = s.data(using: .utf8)! + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { + type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } + if type == "chatError" { + if let jError = jResp["chatError"] as? NSDictionary { + return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) + } + } + } + json = serializeJSON(j, options: .prettyPrinted) + } + return SEChatEvent.event(type: type ?? "invalid", json: json ?? s) + } + var chatError: ChatError? { + switch self { + case let .chatError(_, error): error + default: nil + } + } + + var chatErrorType: ChatErrorType? { + switch self { case let .chatError(_, .error(error)): error default: nil } diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index a555c14472..b4d26b6d54 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -303,7 +303,7 @@ class ShareModel: ObservableObject { } } } - let r: SEChatResponse? = recvSimpleXMsg(messageTimeout: 1_000_000) + let r: SEChatEvent? = recvSimpleXMsg(messageTimeout: 1_000_000) switch r { case let .sndFileProgressXFTP(_, ci, _, sentSize, totalSize): guard isMessage(for: ci) else { continue } @@ -353,8 +353,6 @@ class ShareModel: ObservableObject { return ErrorAlert(title: "File error", message: "\(fileErrorInfo(ci) ?? errorMessage)") case let .chatError(_, chatError): return ErrorAlert(chatError) - case let .chatCmdError(_, chatError): - return ErrorAlert(chatError) default: continue } } diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index b10b544a43..0baf52b26c 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -119,10 +119,10 @@ public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: // in microseconds public let MESSAGE_TIMEOUT: Int32 = 15_000_000 -public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> CR? { +public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> CEvt? { if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout) { let s = fromCString(cjson) - return s == "" ? nil : CR.chatResponse(s) + return s == "" ? nil : CEvt.chatEvent(s) } return nil } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3cfe67e158..f635cfb7bb 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -33,6 +33,14 @@ public protocol ChatRespProtocol: Decodable, Error { var chatErrorType: ChatErrorType? { get } } +public protocol ChatEventProtocol: Decodable, Error { + var eventType: String { get } + var details: String { get } + static func chatEvent(_ s: String) -> Self + var chatError: ChatError? { get } + var chatErrorType: ChatErrorType? { get } +} + public func parseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [ChatData])? { if let jApiChats = jResp["apiChats"] as? NSDictionary, let user: UserRef = try? decodeObject(jApiChats["user"] as Any), 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 24df07a052..8c1166dccd 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 @@ -5792,8 +5792,6 @@ sealed class CR { @Serializable @SerialName("groupMemberRatchetSyncStarted") class GroupMemberRatchetSyncStarted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() @Serializable @SerialName("contactRatchetSync") class ContactRatchetSync(val user: UserRef, val contact: Contact, val ratchetSyncProgress: RatchetSyncProgress): CR() @Serializable @SerialName("groupMemberRatchetSync") class GroupMemberRatchetSync(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val ratchetSyncProgress: RatchetSyncProgress): CR() - @Serializable @SerialName("contactVerificationReset") class ContactVerificationReset(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("groupMemberVerificationReset") class GroupMemberVerificationReset(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): CR() @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @@ -5835,10 +5833,6 @@ sealed class CR { // TODO remove above @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR() - @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR() - @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() - @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemsStatusesUpdated") class ChatItemsStatusesUpdated(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR() @@ -5869,11 +5863,9 @@ sealed class CR { @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: UserRef, val intoContact: Contact, val mergedContact: Contact): CR() - @Serializable @SerialName("groupInvitation") class GroupInvitation(val user: UserRef, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() - @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: UserRef, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val connLinkContact: CreatedConnLink, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupLink") class GroupLink(val user: UserRef, val groupInfo: GroupInfo, val connLinkContact: CreatedConnLink, val memberRole: GroupMemberRole): CR() @@ -5980,8 +5972,6 @@ sealed class CR { is GroupMemberRatchetSyncStarted -> "groupMemberRatchetSyncStarted" is ContactRatchetSync -> "contactRatchetSync" is GroupMemberRatchetSync -> "groupMemberRatchetSync" - is ContactVerificationReset -> "contactVerificationReset" - is GroupMemberVerificationReset -> "groupMemberVerificationReset" is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" @@ -6021,10 +6011,6 @@ sealed class CR { is ContactSubSummary -> "contactSubSummary" is NetworkStatusResp -> "networkStatus" is NetworkStatuses -> "networkStatuses" - is GroupSubscribed -> "groupSubscribed" - is MemberSubErrors -> "memberSubErrors" - is GroupEmpty -> "groupEmpty" - is UserContactLinkSubscribed -> "userContactLinkSubscribed" is NewChatItems -> "newChatItems" is ChatItemsStatusesUpdated -> "chatItemsStatusesUpdated" is ChatItemUpdated -> "chatItemUpdated" @@ -6054,11 +6040,9 @@ sealed class CR { is LeftMember -> "leftMember" is GroupDeleted -> "groupDeleted" is ContactsMerged -> "contactsMerged" - is GroupInvitation -> "groupInvitation" is UserJoinedGroup -> "userJoinedGroup" is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" - is GroupRemoved -> "groupRemoved" is GroupUpdated -> "groupUpdated" is GroupLinkCreated -> "groupLinkCreated" is GroupLink -> "groupLink" @@ -6158,8 +6142,6 @@ sealed class CR { is GroupMemberRatchetSyncStarted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") is ContactRatchetSync -> withUser(user, "contact: ${json.encodeToString(contact)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") is GroupMemberRatchetSync -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") - is ContactVerificationReset -> withUser(user, "contact: ${json.encodeToString(contact)}") - is GroupMemberVerificationReset -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}") is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") @@ -6199,10 +6181,6 @@ sealed class CR { is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) - is GroupSubscribed -> withUser(user, json.encodeToString(group)) - is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) - is GroupEmpty -> withUser(user, json.encodeToString(group)) - is UserContactLinkSubscribed -> noDetails() is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemsStatusesUpdated -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) @@ -6232,11 +6210,9 @@ sealed class CR { is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") - is GroupInvitation -> withUser(user, json.encodeToString(groupInfo)) is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") - is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnLinkContact: $connLinkContact\nmemberRole: $memberRole") is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnLinkContact: $connLinkContact\nmemberRole: $memberRole") diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 6c3d8240e4..b6ad9eea96 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -43,12 +43,12 @@ mySquaringBot :: User -> ChatController -> IO () mySquaringBot _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - case resp of - CRContactConnected _ contact _ -> do + (_, evt) <- atomically . readTBQueue $ outputQ cc + case evt of + CEvtContactConnected _ contact _ -> do contactConnected contact sendMessage cc contact welcomeMessage - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do + CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do let msg = ciContentToText mc number_ = readMaybe (T.unpack msg) :: Maybe Integer sendMessage cc contact $ case number_ of diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 15f790e8b1..913f6a732a 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -11,7 +11,6 @@ import Control.Concurrent (forkIO) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad -import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Text as T import Broadcast.Options import Simplex.Chat.Bot @@ -38,39 +37,31 @@ broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - case resp of - CRContactConnected _ ct _ -> do + (_, evt) <- atomically . readTBQueue $ outputQ cc + case evt of + CEvtContactConnected _ ct _ -> do contactConnected ct sendMessage cc ct welcomeMessage - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) : _} - | publisher `elem` publishers -> - if allowContent mc - then do - sendChatCmd cc ListContacts >>= \case - CRContactsList _ cts -> void . forkIO $ do - sendChatCmd cc (SendMessageBroadcast mc) >>= \case - CRBroadcastSent {successes, failures} -> - sendReply $ "Forwarded to " <> tshow successes <> " contact(s), " <> tshow failures <> " errors" - r -> putStrLn $ "Error broadcasting message: " <> show r - r -> putStrLn $ "Error getting contacts list: " <> show r - else sendReply "!1 Message is not supported!" - | otherwise -> do - sendReply prohibitedMessage - deleteMessage cc ct $ chatItemId' ci + CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) : _} + | sender `notElem` publishers -> do + sendReply prohibitedMessage + deleteMessage cc ct $ chatItemId' ci + | allowContent mc -> + void $ forkIO $ + sendChatCmd cc (SendMessageBroadcast mc) >>= \case + CRBroadcastSent {successes, failures} -> + sendReply $ "Forwarded to " <> tshow successes <> " contact(s), " <> tshow failures <> " errors" + r -> putStrLn $ "Error broadcasting message: " <> show r + | otherwise -> + sendReply "!1 Message is not supported!" where sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . MCText - publisher = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} + sender = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} allowContent = \case MCText _ -> True MCLink {} -> True MCImage {} -> True _ -> False - broadcastTo Contact {activeConn = Nothing} = False - broadcastTo ct'@Contact {activeConn = Just conn@Connection {connStatus}} = - (connStatus == ConnSndReady || connStatus == ConnReady) - && not (connDisabled conn) - && contactId' ct' /= contactId' ct _ -> pure () where contactConnected ct = putStrLn $ T.unpack (localDisplayName' ct) <> " connected" diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index fddad1cf2c..d087df0bb5 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -2,9 +2,11 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} module Server where @@ -13,6 +15,7 @@ import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import GHC.Generics (Generic) @@ -23,11 +26,25 @@ import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options +import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Transport.Server (runLocalTCPServer) import Simplex.Messaging.Util (raceAny_) import UnliftIO.Exception import UnliftIO.STM +data ChatSrvRequest = ChatSrvRequest {corrId :: Text, cmd :: Text} + deriving (Generic, FromJSON) + +data ChatSrvResponse r = ChatSrvResponse {corrId :: Maybe Text, resp :: r} + +data AChatSrvResponse = forall r. ToJSON (ChatSrvResponse r) => ACR (ChatSrvResponse r) + +$(pure []) + +instance ToJSON r => ToJSON (ChatSrvResponse r) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''ChatSrvResponse) + toJSON = $(JQ.mkToJSON defaultJSON ''ChatSrvResponse) + simplexChatServer :: ServiceName -> ChatConfig -> ChatOpts -> IO () simplexChatServer chatPort cfg opts = simplexChatCore cfg opts . const $ runChatServer defaultChatServerConfig {chatPort} @@ -44,19 +61,9 @@ defaultChatServerConfig = clientQSize = 1 } -data ChatSrvRequest = ChatSrvRequest {corrId :: Text, cmd :: Text} - deriving (Generic, FromJSON) - -data ChatSrvResponse = ChatSrvResponse {corrId :: Maybe Text, resp :: ChatResponse} - deriving (Generic) - -instance ToJSON ChatSrvResponse where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - data ChatClient = ChatClient { rcvQ :: TBQueue (Text, ChatCommand), - sndQ :: TBQueue ChatSrvResponse + sndQ :: TBQueue AChatSrvResponse } newChatServerClient :: Natural -> STM ChatClient @@ -78,14 +85,14 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do getConnection sock = WS.makePendingConnection sock WS.defaultConnectionOptions >>= WS.acceptRequest send ws ChatClient {sndQ} = forever $ - atomically (readTBQueue sndQ) >>= WS.sendTextData ws . J.encode + atomically (readTBQueue sndQ) >>= \(ACR r) -> WS.sendTextData ws (J.encode r) client ChatClient {rcvQ, sndQ} = forever $ do atomically (readTBQueue rcvQ) >>= processCommand - >>= atomically . writeTBQueue sndQ + >>= atomically . writeTBQueue sndQ . ACR output ChatClient {sndQ} = forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - atomically $ writeTBQueue sndQ ChatSrvResponse {corrId = Nothing, resp} + (_, resp) <- atomically . readTBQueue $ outputQ cc + atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId = Nothing, resp} receive ws ChatClient {rcvQ, sndQ} = forever $ do s <- WS.receiveData ws case J.decodeStrict' s of @@ -96,7 +103,7 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do Left e -> sendError (Just corrId) e Nothing -> sendError Nothing "invalid request" where - sendError corrId e = atomically $ writeTBQueue sndQ ChatSrvResponse {corrId, resp = chatCmdError Nothing e} + sendError corrId e = atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId, resp = chatCmdError Nothing e} processCommand (corrId, cmd) = runReaderT (runExceptT $ processChatCommand cmd) cc >>= \case Right resp -> response resp diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 802221f976..412f87889c 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -63,41 +63,40 @@ data DirectoryEvent | DELogChatResponse Text deriving (Show) -crDirectoryEvent :: ChatResponse -> Maybe DirectoryEvent +crDirectoryEvent :: ChatEvent -> Maybe DirectoryEvent crDirectoryEvent = \case - CRContactConnected {contact} -> Just $ DEContactConnected contact - CRReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} - CRUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember - CRGroupUpdated {fromGroup, toGroup, member_} -> (\member -> DEGroupUpdated {member, fromGroup, toGroup}) <$> member_ - CRJoinedGroupMember {groupInfo, member = m} + CEvtContactConnected {contact} -> Just $ DEContactConnected contact + CEvtReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} + CEvtUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember + CEvtGroupUpdated {fromGroup, toGroup, member_} -> (\member -> DEGroupUpdated {member, fromGroup, toGroup}) <$> member_ + CEvtJoinedGroupMember {groupInfo, member = m} | pending m -> Just $ DEPendingMember groupInfo m | otherwise -> Nothing - CRNewChatItems {chatItems = AChatItem _ _ (GroupChat g) ci : _} -> case ci of + CEvtNewChatItems {chatItems = AChatItem _ _ (GroupChat g) 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} + CEvtMemberRole {groupInfo, member, toRole} | groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member - CRDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember - CRLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member - CRDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo - CRGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo - CRChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct - CRChatItemsDeleted {chatItemDeletions = ((ChatItemDeletion (AChatItem _ SMDRcv (DirectChat ct) _) _) : _), byUser = False} -> Just $ DEItemDeleteIgnored ct - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}) : _} -> + CEvtDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember + CEvtLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member + CEvtDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo + CEvtGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo + CEvtChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct + CEvtChatItemsDeleted {chatItemDeletions = ((ChatItemDeletion (AChatItem _ SMDRcv (DirectChat ct) _) _) : _), byUser = False} -> Just $ DEItemDeleteIgnored ct + CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}) : _} -> Just $ case (mc, itemLive) of (MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.dropWhileEnd isSpace t _ -> DEUnsupportedMessage ct ciId where ciId = chatItemId' ci err = ADC SDRUser DCUnknownCommand - CRMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage - CRChatCmdError {chatError} -> Just $ DELogChatResponse $ "chat cmd error: " <> tshow chatError - CRChatError {chatError} -> case chatError of + CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage + CEvtChatError {chatError} -> case chatError of ChatErrorAgent {agentError = BROKER _ NETWORK} -> Nothing ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing _ -> Just $ DELogChatResponse $ "chat error: " <> tshow chatError - CRChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) + CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) _ -> Nothing where pending m = memberStatus m == GSMemPendingApproval diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index fc9ac24e71..89fb9c30d8 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -153,7 +153,7 @@ directoryService :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> C directoryService st opts@DirectoryOpts {testing} env user cc = do initializeBotAddress' (not testing) cc race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc + (_, resp) <- atomically . readTBQueue $ outputQ cc directoryServiceEvent st opts env user cc resp acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) @@ -197,7 +197,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} -directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () +directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatEvent -> IO () directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct diff --git a/cabal.project b/cabal.project index b7c8832d9d..a9a4b45f1a 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: 3d10c9bf9e4d8196d39162ff8712f6b729b8c247 + tag: a632eea75b677cf2b146ad06ee875307d0321f23 source-repository-package type: git diff --git a/packages/simplex-chat-client/typescript/src/response.ts b/packages/simplex-chat-client/typescript/src/response.ts index 2e92e335df..5f91baa7db 100644 --- a/packages/simplex-chat-client/typescript/src/response.ts +++ b/packages/simplex-chat-client/typescript/src/response.ts @@ -84,7 +84,6 @@ export type ChatResponse = | CRGroupRemoved | CRGroupDeleted | CRGroupUpdated - | CRUserContactLinkSubscribed | CRUserContactLinkSubError | CRContactConnectionDeleted | CRMessageError @@ -182,7 +181,6 @@ type ChatResponseTag = | "groupRemoved" | "groupDeleted" | "groupUpdated" - | "userContactLinkSubscribed" | "userContactLinkSubError" | "newContactConnection" | "contactConnectionDeleted" @@ -721,10 +719,6 @@ export interface CRGroupUpdated extends CR { member_?: GroupMember } -export interface CRUserContactLinkSubscribed extends CR { - type: "userContactLinkSubscribed" -} - export interface CRUserContactLinkSubError extends CR { type: "userContactLinkSubError" chatError: ChatError diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 40aa4e7da0..842348157a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3d10c9bf9e4d8196d39162ff8712f6b729b8c247" = "1nnr6klv240da97qmrzlh8jywpimcnlrxnxnjrm2rd0w0w7gvra1"; + "https://github.com/simplex-chat/simplexmq.git"."a632eea75b677cf2b146ad06ee875307d0321f23" = "03vk7214941f5jwmf7sp26lxzh4c1xl89wqmlky379d6gwypbzy6"; "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/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 727d7f9ac5..5acf60556e 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -33,12 +33,12 @@ chatBotRepl :: String -> (Contact -> String -> IO String) -> User -> ChatControl chatBotRepl welcome answer _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, _, resp) <- atomically . readTBQueue $ outputQ cc - case resp of - CRContactConnected _ contact _ -> do + (_, event) <- atomically . readTBQueue $ outputQ cc + case event of + CEvtContactConnected _ contact _ -> do contactConnected contact void $ sendMessage cc contact $ T.pack welcome - CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do + CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do let msg = T.unpack $ ciContentToText mc void $ sendMessage cc contact . T.pack =<< answer contact msg _ -> pure () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4d835b41bb..a3b9f34346 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -55,7 +55,6 @@ import Numeric.Natural import qualified Paths_simplex_chat as SC import Simplex.Chat.AppSettings import Simplex.Chat.Call -import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -86,7 +85,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) @@ -176,7 +175,7 @@ data ChatHooks = ChatHooks preCmdHook :: Maybe (ChatController -> ChatCommand -> IO (Either ChatResponse ChatCommand)), -- eventHook can be used to additionally process or modify events, -- it is called before the event is sent to the user (or to the UI). - eventHook :: Maybe (ChatController -> ChatResponse -> IO ChatResponse), + eventHook :: Maybe (ChatController -> ChatEvent -> IO ChatEvent), -- acceptMember hook can be used to accept or reject member connecting via group link without API calls acceptMember :: Maybe (GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole))) } @@ -224,7 +223,7 @@ data ChatController = ChatController random :: TVar ChaChaDRG, eventSeq :: TVar Int, inputQ :: TBQueue String, - outputQ :: TBQueue (Maybe CorrId, Maybe RemoteHostId, ChatResponse), + outputQ :: TBQueue (Maybe RemoteHostId, ChatEvent), connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, @@ -548,7 +547,7 @@ data ChatCommand | QuitChat | ShowVersion | DebugLocks - | DebugEvent ChatResponse + | DebugEvent ChatEvent | GetAgentSubsTotal UserId | GetAgentServersSummary UserId | ResetAgentServersStats @@ -608,7 +607,6 @@ data ChatResponse | CRChatStarted | CRChatRunning | CRChatStopped - | CRChatSuspended | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo} @@ -616,7 +614,6 @@ data ChatResponse | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) - | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | CRServerOperatorConditions {conditions :: ServerOperatorConditions} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} @@ -632,30 +629,20 @@ data ChatResponse | CRGroupMemberSwitchStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} | CRContactSwitchAborted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} | CRGroupMemberSwitchAborted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} - | CRContactSwitch {user :: User, contact :: Contact, switchProgress :: SwitchProgress} - | CRGroupMemberSwitch {user :: User, groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} | CRContactRatchetSyncStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} | CRGroupMemberRatchetSyncStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} - | CRContactRatchetSync {user :: User, contact :: Contact, ratchetSyncProgress :: RatchetSyncProgress} - | CRGroupMemberRatchetSync {user :: User, groupInfo :: GroupInfo, member :: GroupMember, ratchetSyncProgress :: RatchetSyncProgress} - | CRContactVerificationReset {user :: User, contact :: Contact} - | CRGroupMemberVerificationReset {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRContactCode {user :: User, contact :: Contact, connectionCode :: Text} | CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} | CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text} | CRTagsUpdated {user :: User, userTags :: [ChatTag], chatTags :: [ChatTagId]} | CRNewChatItems {user :: User, chatItems :: [AChatItem]} - | CRChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]} | CRChatItemUpdated {user :: User, chatItem :: AChatItem} | CRChatItemNotChanged {user :: User, chatItem :: AChatItem} | CRChatItemReaction {user :: User, added :: Bool, reaction :: ACIReaction} | CRReactionMembers {user :: User, memberReactions :: [MemberReaction]} | CRChatItemsDeleted {user :: User, chatItemDeletions :: [ChatItemDeletion], byUser :: Bool, timed :: Bool} | CRGroupChatItemsDeleted {user :: User, groupInfo :: GroupInfo, chatItemIDs :: [ChatItemId], byUser :: Bool, member_ :: Maybe GroupMember} - | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent {user :: User, msgContent :: MsgContent, successes :: Int, failures :: Int, timestamp :: UTCTime} - | CRMsgIntegrityError {user :: User, msgError :: MsgErrorType} - | CRCmdAccepted {corr :: CorrId} | CRCmdOk {user_ :: Maybe User} | CRChatHelp {helpSection :: HelpSection} | CRWelcome {user :: User} @@ -666,8 +653,6 @@ data ChatResponse | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} - | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} - | CRBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} @@ -684,134 +669,54 @@ data ChatResponse | CRSentConfirmation {user :: User, connection :: PendingContactConnection} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} - | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} - | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} - | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} | CRUserContactLinkCreated {user :: User, connLinkContact :: CreatedLinkContact} | CRUserContactLinkDeleted {user :: User} - | CRReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} | CRAcceptingContactRequest {user :: User, contact :: Contact} - | CRAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} | CRContactAlreadyExists {user :: User, contact :: Contact} - | CRContactRequestAlreadyAccepted {user :: User, contact :: Contact} - | CRBusinessRequestAlreadyAccepted {user :: User, groupInfo :: GroupInfo} | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo} | CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation} - | CRRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr} | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} + -- TODO add chatItem :: AChatItem | CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} | CRStandaloneFileInfo {fileMeta :: Maybe J.Value} | CRRcvStandaloneFileCreated {user :: User, rcvFileTransfer :: RcvFileTransfer} -- returned by _download - | CRRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats - | CRRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileComplete {user :: User, chatItem :: AChatItem} - | CRRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer} | CRRcvFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileWarning {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} - | CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileCancelled {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRSndStandaloneFileCreated {user :: User, fileTransferMeta :: FileTransferMeta} -- returned by _upload - | CRSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used - | CRSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} - | CRSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta} - | CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]} - | CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} - | CRSndFileWarning {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} | CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRContactConnecting {user :: User, contact :: Contact} - | CRContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} - | CRContactSndReady {user :: User, contact :: Contact} - | CRContactAnotherClient {user :: User, contact :: Contact} - | CRSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} - | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError} - | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} - | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} - | CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} - | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} - | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} - | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: 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} | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} - | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} - | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember, withMessages :: Bool} - | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, withMessages :: Bool} - | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} - | CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} - | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} - | CRGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} - | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} | CRGroupDescription {user :: User, groupInfo :: GroupInfo} -- only used in CLI | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} | CRGroupLink {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} - | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} - | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} - | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} - | CRMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} - | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} - | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} - | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} - | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} - | CRCallInvitation {callInvitation :: RcvCallInvitation} - | CRCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} - | CRCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} - | CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} - | CRCallEnded {user :: User, contact :: Contact} | CRCallInvitations {callInvitations :: [RcvCallInvitation]} - | CRUserContactLinkSubscribed -- TODO delete - | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer} | CRNtfConns {ntfConns :: [NtfConn]} | CRConnNtfMessages {receivedMsgs :: NonEmpty (Maybe NtfMsgInfo)} - | CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgAckInfo} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String, localAddrs :: NonEmpty RCCtrlAddress} - | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} - | CRNewRemoteHost {remoteHost :: RemoteHostInfo} - | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} - | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId, rhsState :: RemoteHostSessionState, rhStopReason :: RemoteHostStopReason} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} - | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} - | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} - | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} #if !defined(dbPostgres) | CRArchiveExported {archiveErrors :: [ArchiveError]} @@ -826,25 +731,134 @@ data ChatResponse | CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]} | CRAgentSubsDetails {agentSubs :: SubscriptionsInfo} | CRAgentQueuesInfo {agentQueuesInfo :: AgentQueuesInfo} - | CRContactDisabled {user :: User, contact :: Contact} - | CRConnectionDisabled {connectionEntity :: ConnectionEntity} - | CRConnectionInactive {connectionEntity :: ConnectionEntity, inactive :: Bool} - | CRAgentRcvQueuesDeleted {deletedRcvQueues :: NonEmpty DeletedRcvQueue} - | CRAgentConnsDeleted {agentConnIds :: NonEmpty AgentConnId} - | CRAgentUserDeleted {agentUserId :: Int64} - | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} - | CRChatError {user_ :: Maybe User, chatError :: ChatError} - | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} | CRAppSettings {appSettings :: AppSettings} - | CRTimedAction {action :: String, durationMilliseconds :: Int64} | CRCustomChatResponse {user_ :: Maybe User, response :: Text} - | CRTerminalEvent TerminalEvent + deriving (Show) + +data ChatEvent + = CEvtChatSuspended + | CEvtContactSwitch {user :: User, contact :: Contact, switchProgress :: SwitchProgress} + | CEvtGroupMemberSwitch {user :: User, groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} + | CEvtContactRatchetSync {user :: User, contact :: Contact, ratchetSyncProgress :: RatchetSyncProgress} + | CEvtGroupMemberRatchetSync {user :: User, groupInfo :: GroupInfo, member :: GroupMember, ratchetSyncProgress :: RatchetSyncProgress} + | CEvtNewChatItems {user :: User, chatItems :: [AChatItem]} -- there is the same command response + | CEvtChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]} + | CEvtChatItemUpdated {user :: User, chatItem :: AChatItem} -- there is the same command response + | CEvtChatItemNotChanged {user :: User, chatItem :: AChatItem} -- there is the same command response + | CEvtChatItemReaction {user :: User, added :: Bool, reaction :: ACIReaction} -- there is the same command response + | CEvtGroupChatItemsDeleted {user :: User, groupInfo :: GroupInfo, chatItemIDs :: [ChatItemId], byUser :: Bool, member_ :: Maybe GroupMember} -- there is the same command response + | CEvtChatItemsDeleted {user :: User, chatItemDeletions :: [ChatItemDeletion], byUser :: Bool, timed :: Bool} -- there is the same command response + | CEvtChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} + | CEvtUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} -- there is the same command response + | CEvtGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CEvtBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} + | CEvtSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} -- there is the same command response + | CEvtContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CEvtGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} + | CEvtContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} + | CEvtContactDeletedByContact {user :: User, contact :: Contact} + | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} + | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response + | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} + | CEvtContactRequestAlreadyAccepted {user :: User, contact :: Contact} + | CEvtBusinessRequestAlreadyAccepted {user :: User, groupInfo :: GroupInfo} + | CEvtRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr} + | CEvtRcvFileAccepted {user :: User, chatItem :: AChatItem} -- there is the same command response + -- TODO add chatItem :: AChatItem + | CEvtRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} -- there is the same command response + | CEvtRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats + | CEvtRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileComplete {user :: User, chatItem :: AChatItem} + | CEvtRcvStandaloneFileComplete {user :: User, targetPath :: FilePath, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileSndCancelled {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileError {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} + | CEvtRcvFileWarning {user :: User, chatItem_ :: Maybe AChatItem, agentError :: AgentErrorType, rcvFileTransfer :: RcvFileTransfer} + | CEvtSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CEvtSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CEvtSndFileRcvCancelled {user :: User, chatItem_ :: Maybe AChatItem, sndFileTransfer :: SndFileTransfer} + | CEvtSndFileStartXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} -- not used + | CEvtSndFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, sentSize :: Int64, totalSize :: Int64} + | CEvtSndFileRedirectStartXFTP {user :: User, fileTransferMeta :: FileTransferMeta, redirectMeta :: FileTransferMeta} + | CEvtSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} + | CEvtSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]} + | CEvtSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} + | CEvtSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} + | CEvtSndFileWarning {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} + | CEvtContactConnecting {user :: User, contact :: Contact} + | CEvtContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} + | CEvtContactSndReady {user :: User, contact :: Contact} + | CEvtContactAnotherClient {user :: User, contact :: Contact} + | CEvtSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} + | CEvtContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} + | CEvtContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} + | CEvtContactSubError {user :: User, contact :: Contact, chatError :: ChatError} + | CEvtContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} + | CEvtUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} + | CEvtNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} + | CEvtNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} -- there is the same command response + | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} + | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} + | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} + | CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CEvtJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- there is the same command response + | CEvtJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} + | CEvtMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} + | CEvtMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool} + | CEvtConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} + | CEvtDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember, withMessages :: Bool} + | CEvtDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, withMessages :: Bool} + | CEvtLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} + | CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} + | CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} + | CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} -- there is the same command response + | CEvtAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI + | CEvtNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} + | CEvtCallInvitation {callInvitation :: RcvCallInvitation} + | CEvtCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} + | CEvtCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} + | CEvtCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} + | CEvtCallEnded {user :: User, contact :: Contact} + | CEvtNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgAckInfo} + | CEvtRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} + | CEvtNewRemoteHost {remoteHost :: RemoteHostInfo} + | 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} + | CEvtRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} + | CEvtContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} + | CEvtContactDisabled {user :: User, contact :: Contact} + | CEvtConnectionDisabled {connectionEntity :: ConnectionEntity} + | CEvtConnectionInactive {connectionEntity :: ConnectionEntity, inactive :: Bool} + | CEvtAgentRcvQueuesDeleted {deletedRcvQueues :: NonEmpty DeletedRcvQueue} + | CEvtAgentConnsDeleted {agentConnIds :: NonEmpty AgentConnId} + | CEvtAgentUserDeleted {agentUserId :: Int64} + | CEvtMessageError {user :: User, severity :: Text, errorMessage :: Text} + | CEvtChatError {user_ :: Maybe User, chatError :: ChatError} + | CEvtChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} + | CEvtTimedAction {action :: String, durationMilliseconds :: Int64} + | CEvtTerminalEvent TerminalEvent deriving (Show) data TerminalEvent = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} + | TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | TEContactVerificationReset {user :: User, contact :: Contact} + | TEGroupMemberVerificationReset {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | TEGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} + | TEGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} + | TEGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} + | TEMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} + | TEMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} + | TEPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} + | TESndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} + | TERcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} deriving (Show) data DeletedRcvQueue = DeletedRcvQueue @@ -856,49 +870,37 @@ data DeletedRcvQueue = DeletedRcvQueue deriving (Show) -- some of these can only be used as command responses -allowRemoteEvent :: ChatResponse -> Bool +allowRemoteEvent :: ChatEvent -> Bool allowRemoteEvent = \case - CRChatStarted -> False - CRChatRunning -> False - CRChatStopped -> False - CRChatSuspended -> False - CRRemoteHostList _ -> False - CRCurrentRemoteHost _ -> False - CRRemoteHostStarted {} -> False - CRRemoteHostSessionCode {} -> False - CRNewRemoteHost _ -> False - CRRemoteHostConnected _ -> False - CRRemoteHostStopped {} -> False - CRRemoteFileStored {} -> False - CRRemoteCtrlList _ -> False - CRRemoteCtrlFound {} -> False - CRRemoteCtrlConnecting {} -> False - CRRemoteCtrlSessionCode {} -> False - CRRemoteCtrlConnected _ -> False - CRRemoteCtrlStopped {} -> False - CRSQLResult _ -> False -#if !defined(dbPostgres) - CRSlowSQLQueries {} -> False -#endif + CEvtChatSuspended -> False + CEvtRemoteHostSessionCode {} -> False + CEvtNewRemoteHost _ -> False + CEvtRemoteHostConnected _ -> False + CEvtRemoteHostStopped {} -> False + CEvtRemoteCtrlFound {} -> False + CEvtRemoteCtrlSessionCode {} -> False + CEvtRemoteCtrlStopped {} -> False _ -> True -logResponseToFile :: ChatResponse -> Bool -logResponseToFile = \case - CRContactsDisconnected {} -> True - CRContactsSubscribed {} -> True - CRContactSubError {} -> True - CRMemberSubError {} -> True - CRSndFileSubError {} -> True - CRRcvFileSubError {} -> True - CRHostConnected {} -> True - CRHostDisconnected {} -> True - CRConnectionDisabled {} -> True - CRAgentRcvQueuesDeleted {} -> True - CRAgentConnsDeleted {} -> True - CRAgentUserDeleted {} -> True - CRChatCmdError {} -> True - CRChatError {} -> True - CRMessageError {} -> True +logEventToFile :: ChatEvent -> Bool +logEventToFile = \case + CEvtContactsDisconnected {} -> True + CEvtContactsSubscribed {} -> True + CEvtContactSubError {} -> True + CEvtHostConnected {} -> True + CEvtHostDisconnected {} -> True + CEvtConnectionDisabled {} -> True + CEvtAgentRcvQueuesDeleted {} -> True + CEvtAgentConnsDeleted {} -> True + CEvtAgentUserDeleted {} -> True + -- CEvtChatCmdError {} -> True -- TODO this should be separately logged to file + CEvtChatError {} -> True + CEvtMessageError {} -> True + CEvtTerminalEvent te -> case te of + TEMemberSubError {} -> True + TESndFileSubError {} -> True + TERcvFileSubError {} -> True + _ -> False _ -> False -- (Maybe GroupMemberId) can later be changed to GroupSndScope = GSSAll | GSSAdmins | GSSMember GroupMemberId @@ -1406,7 +1408,7 @@ data RemoteCtrlSession tls :: TLS, rcsSession :: RCCtrlSession, http2Server :: Async (), - remoteOutputQ :: TBQueue ChatResponse + remoteOutputQ :: TBQueue ChatEvent } data RemoteCtrlSessionState @@ -1512,15 +1514,15 @@ throwChatError :: ChatErrorType -> CM a throwChatError = throwError . ChatError toViewTE :: TerminalEvent -> CM () -toViewTE = toView . CRTerminalEvent +toViewTE = toView . CEvtTerminalEvent {-# INLINE toViewTE #-} -- | Emit local events. -toView :: ChatResponse -> CM () +toView :: ChatEvent -> CM () toView = lift . toView' {-# INLINE toView #-} -toView' :: ChatResponse -> CM' () +toView' :: ChatEvent -> CM' () toView' ev = do cc@ChatController {outputQ = localQ, remoteCtrlSession = session, config = ChatConfig {chatHooks}} <- ask event <- case eventHook chatHooks of @@ -1531,7 +1533,7 @@ toView' ev = do Just (_, RCSessionConnected {remoteOutputQ}) | allowRemoteEvent event -> writeTBQueue remoteOutputQ event -- TODO potentially, it should hold some events while connecting - _ -> writeTBQueue localQ (Nothing, Nothing, event) + _ -> writeTBQueue localQ (Nothing, event) withStore' :: (DB.Connection -> IO a) -> CM a withStore' action = withStore $ liftIO . action @@ -1660,6 +1662,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CEvt") ''ChatEvent) + $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) $(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index eeb54c6aef..77871ccc1b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -225,8 +225,8 @@ startReceiveUserFiles :: User -> CM () startReceiveUserFiles user = do filesToReceive <- withStore' (`getRcvFilesToReceive` user) forM_ filesToReceive $ \ft -> - flip catchChatError (toView . CRChatError (Just user)) $ - toView =<< receiveFile' user ft False Nothing Nothing + flip catchChatError (toView . CEvtChatError (Just user)) $ + toView =<< receiveFileEvt' user ft False Nothing Nothing restoreCalls :: CM' () restoreCalls = do @@ -502,7 +502,7 @@ processChatCommand' vr = \case pure $ CRChatTags user tags APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) - unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + unless (null errs) $ toView $ CEvtChatErrors (Just user) (map ChatErrorStore errs) pure $ CRApiChats user previews APIGetChat (ChatRef cType cId) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled @@ -665,9 +665,9 @@ processChatCommand' vr = \case APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of CTDirect -> withContactLock "deleteChatItem" chatId $ do (ct, items) <- getCommandDirectChatItems user chatId itemIds - case mode of - CIDMInternal -> deleteDirectCIs user ct items True False - CIDMInternalMark -> markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime + deletions <- case mode of + CIDMInternal -> deleteDirectCIs user ct items + CIDMInternalMark -> markDirectCIsDeleted user ct items =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ @@ -676,13 +676,14 @@ processChatCommand' vr = \case forM_ (L.nonEmpty events) $ \events' -> sendDirectContactMessages user ct events' if featureAllowed SCFFullDelete forUser ct - then deleteDirectCIs user ct items True False - else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime + then deleteDirectCIs user ct items + else markDirectCIsDeleted user ct items =<< liftIO getCurrentTime + pure $ CRChatItemsDeleted user deletions True False CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds - case mode of - CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime - CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime + deletions <- case mode of + CIDMInternal -> deleteGroupCIs user gInfo items Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable items @@ -692,6 +693,7 @@ processChatCommand' vr = \case -- TODO [knocking] validate: only current members or only single pending approval member mapM_ (sendGroupMessages user gInfo ms) events delGroupChatItems user gInfo items False + pure $ CRChatItemsDeleted user deletions True False CTLocal -> do (nf, items) <- getCommandLocalChatItems user chatId itemIds deleteLocalCIs user nf items True False @@ -714,7 +716,8 @@ processChatCommand' vr = \case APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do (gInfo, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - delGroupChatItemsForMembers user gInfo ms items + deletions <- delGroupChatItemsForMembers user gInfo ms items + pure $ CRChatItemsDeleted user deletions True False APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do g <- getGroupInfo db vr user gId deleteTs <- liftIO getCurrentTime @@ -723,12 +726,13 @@ 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" - case mode of - CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime - CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime + deletions <- case mode of + CIDMInternal -> deleteGroupCIs user gInfo items Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo delGroupChatItemsForMembers user gInfo ms items + pure $ CRChatItemsDeleted user deletions True False where isRcvReport = \case CChatItem _ ChatItem {content = CIRcvMsgContent (MCReport {})} -> True @@ -1166,7 +1170,7 @@ processChatCommand' vr = \case let call' = Call {contactId, callId, callUUID, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] ok user else pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFCalls)) SendCallInvitation cName callType -> withUser $ \user -> do @@ -1293,7 +1297,7 @@ processChatCommand' vr = \case APIGetNtfConns nonce encNtfInfo -> withUser $ \user -> do ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo (errs, ntfMsgs) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (getMsgConn db) (L.toList ntfInfos)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs pure $ CRNtfConns $ catMaybes ntfMsgs where getMsgConn :: DB.Connection -> NotificationInfo -> IO (Maybe NtfConn) @@ -1404,7 +1408,7 @@ processChatCommand' vr = \case oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do lift $ setExpireCIFlag user False - expireChat user globalTTL `catchChatError` (toView . CRChatError (Just user)) + expireChat user globalTTL `catchChatError` (toView . CEvtChatError (Just user)) lift $ setChatItemsExpiration user globalTTL ttlCount ok user where @@ -1474,7 +1478,7 @@ processChatCommand' vr = \case liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` (toView . CEvtChatError (Just user)) ok user _ -> pure $ chatCmdError (Just user) "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do @@ -1865,10 +1869,10 @@ processChatCommand' vr = \case Nothing -> do g <- withFastStore $ \db -> getGroupInfo db vr user gId unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwChatError $ CECommandError "direct messages not allowed" - toView $ CRNoMemberContactCreating user g m + toView $ CEvtNoMemberContactCreating user g m processChatCommand (APICreateMemberContact gId mId) >>= \case - cr@(CRNewMemberContact _ Contact {contactId} _ _) -> do - toView cr + CRNewMemberContact _ ct@Contact {contactId} _ _ -> do + toViewTE $ TENewMemberContact user ct g m processChatCommand $ APISendMemberContactInvitation contactId (Just mc) cr -> pure cr Just ctId -> do @@ -2015,7 +2019,7 @@ processChatCommand' vr = \case updateGroupMemberStatus db userId fromMember GSMemInvited updateGroupMemberStatus db userId membership GSMemInvited throwError e - updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` (toView . CRChatError (Just user)) + updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` (toView . CEvtChatError (Just user)) pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user -> do @@ -2042,9 +2046,9 @@ processChatCommand' vr = \case assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems (errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems - unless (null acis) $ toView $ CRNewChatItems user acis + unless (null acis) $ toView $ CEvtNewChatItems user acis let errs = errs1 <> errs2 - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds @@ -2130,9 +2134,9 @@ processChatCommand' vr = \case 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_ - unless (null acis) $ toView $ CRNewChatItems user acis + unless (null acis) $ toView $ CEvtNewChatItems user acis (errs, blocked) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updateGroupMemberBlocked db user gInfo mrs) blockMems) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs -- TODO not batched - requires agent batch api forM_ blocked $ \m -> toggleNtf user m (not blockFlag) pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag} @@ -2156,8 +2160,8 @@ processChatCommand' vr = \case let (errs3, deleted3, acis3) = concatTuples rs acis = acis2 <> acis3 errs = errs1 <> errs2 <> errs3 - unless (null acis) $ toView $ CRNewChatItems user acis - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null acis) $ toView $ CEvtNewChatItems user acis + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs when withMessages $ deleteMessages user gInfo $ currentMems <> pendingMems pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2 <> deleted3) withMessages -- same order is not guaranteed where @@ -2218,7 +2222,7 @@ processChatCommand' vr = \case -- 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] + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] -- TODO delete direct connections that were unused deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history @@ -2324,7 +2328,7 @@ processChatCommand' vr = \case let ct' = ct {contactGrpInvSent = True} forM_ msgContent_ $ \mc -> do ci <- saveSndChatItem user (CDDirectSnd ct') sndMsg (CISndMsgContent mc) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] pure $ CRNewMemberContactSentInv user ct' g m _ -> throwChatError CEGroupMemberNotActive CreateGroupLink gName mRole short -> withUser $ \user -> do @@ -2353,7 +2357,7 @@ processChatCommand' vr = \case LastChats count_ -> withUser' $ \user -> do let count = fromMaybe 5000 count_ (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user False (PTLast count) clqNoFilters) - unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + unless (null errs) $ toView $ CEvtChatErrors (Just user) (map ChatErrorStore errs) pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName @@ -2594,19 +2598,9 @@ processChatCommand' vr = \case -- in a modified CLI app or core - the hook should return Either ChatResponse ChatCommand CustomChatCommand _cmd -> withUser $ \user -> pure $ chatCmdError (Just user) "not supported" where - -- below code would make command responses asynchronous where they can be slow - -- in View.hs `r'` should be defined as `id` in this case - -- procCmd :: m ChatResponse -> m ChatResponse - -- procCmd action = do - -- ChatController {chatLock = l, smpAgent = a, outputQ = q, random = gVar} <- ask - -- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 - -- void . forkIO $ - -- withAgentLock a . withLock l name $ - -- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchChatError` (pure . CRChatError)) - -- pure $ CRCmdAccepted corrId - -- use function below to make commands "synchronous" procCmd :: CM ChatResponse -> CM ChatResponse procCmd = id + {-# INLINE procCmd #-} ok_ = pure $ CRCmdOk Nothing ok = pure . CRCmdOk . Just getChatRef :: User -> ChatName -> CM ChatRef @@ -2768,7 +2762,7 @@ processChatCommand' vr = \case let idsEvts = L.map ctSndEvent changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts lift $ createContactsSndFeatureItems user' changedCts' pure @@ -2808,7 +2802,7 @@ processChatCommand' vr = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateProfile" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) + void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` (toView . CEvtChatError (Just user)) lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> CM ChatResponse @@ -2832,7 +2826,7 @@ processChatCommand' vr = \case let cd = CDGroupSnd g' unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g') ci] + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g') ci] createGroupFeatureChangedItems user cd CISndGroupFeature g g' pure $ CRGroupUpdated user g g' Nothing checkValidName :: GroupName -> CM () @@ -2847,7 +2841,7 @@ 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 -> GroupInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion] delGroupChatItemsForMembers user gInfo ms items = do assertDeletable gInfo items assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate @@ -2876,16 +2870,16 @@ processChatCommand' vr = \case CIGroupRcv GroupMember {memberId} -> (msgId, memberId) CIGroupSnd -> (msgId, membershipMemId) - delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> CM ChatResponse + delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> CM [ChatItemDeletion] delGroupChatItems user gInfo@GroupInfo {membership} 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) + unless (null ciIds) $ toView $ CEvtGroupChatItemsDeleted 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 items m deletedTs + else markGroupCIsDeleted user gInfo items m deletedTs updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> @@ -2962,7 +2956,7 @@ processChatCommand' vr = \case let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole timed_ <- contactCITimed ct ci <- saveSndChatItem' user (CDDirectSnd ct) msg content Nothing Nothing Nothing timed_ False - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) drgRandomBytes :: Int -> CM ByteString @@ -3011,7 +3005,7 @@ processChatCommand' vr = \case deleteCIFiles user filesInfo withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) `catchChatError` \case - e@(ChatErrorAgent NO_USER _) -> toView $ CRChatError (Just user) e + e@(ChatErrorAgent NO_USER _) -> toView $ CEvtChatError (Just user) e e -> throwError e withFastStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing @@ -3064,7 +3058,7 @@ processChatCommand' vr = \case connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse connectWithPlan user@User {userId} incognito ccLink plan | connectionPlanProceed plan = do - case plan of CPError e -> toView $ CRChatError (Just user) e; _ -> pure () + case plan of CPError e -> toView $ CEvtChatError (Just user) e; _ -> pure () case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) -> processChatCommand $ APIConnectContactViaAddress userId incognito contactId @@ -3364,7 +3358,7 @@ processChatCommand' vr = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CEvtChatError (Just user)) where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -3388,18 +3382,18 @@ processChatCommand' vr = \case -- no errors ([], _) -> pure () -- at least one item is successfully created - (errs, _ci : _) -> toView $ CRChatErrors (Just user) errs + (errs, _ci : _) -> toView $ CEvtChatErrors (Just user) errs -- single error ([err], []) -> throwError err -- multiple errors (errs@(err : _), []) -> do - toView $ CRChatErrors (Just user) errs + toView $ CEvtChatErrors (Just user) errs throwError err getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect]) getCommandDirectChatItems user ctId itemIds = do ct <- withFastStore $ \db -> getContact db vr user ctId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs pure (ct, items) where getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) @@ -3408,7 +3402,7 @@ processChatCommand' vr = \case getCommandGroupChatItems user gId itemIds = do gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs pure (gInfo, items) where getGroupCI :: DB.Connection -> GroupInfo -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) @@ -3417,7 +3411,7 @@ processChatCommand' vr = \case getCommandLocalChatItems user nfId itemIds = do nf <- withStore $ \db -> getNoteFolder db user nfId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs pure (nf, items) where getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal)) @@ -3542,7 +3536,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' delay interval <- asks $ ciExpirationInterval . config forever $ do - flip catchChatError' (toView' . CRChatError (Just user)) $ do + flip catchChatError' (toView' . CEvtChatError (Just user)) $ do expireFlags <- asks expireCIFlags atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated @@ -3574,7 +3568,7 @@ agentSubscriber = do q <- asks $ subQ . smpAgent forever (atomically (readTBQueue q) >>= process) `E.catchAny` \e -> do - toView' $ CRChatError Nothing $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing + toView' $ CEvtChatError Nothing $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing E.throwIO e where process :: (ACorrId, AEntityId, AEvt) -> CM' () @@ -3584,7 +3578,7 @@ agentSubscriber = do SAERcvFile -> processAgentMsgRcvFile corrId entId msg SAESndFile -> processAgentMsgSndFile corrId entId msg where - run action = action `catchChatError'` (toView' . CRChatError Nothing) + run action = action `catchChatError'` (toView' . CEvtChatError Nothing) type AgentBatchSubscribe = AgentClient -> [ConnId] -> ExceptT AgentErrorType IO (Map ConnId (Either AgentErrorType ())) @@ -3689,9 +3683,9 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do notifyCLI = do let cRs = resultsFor rs cts cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs - toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors - notifyAPI = toView . CRNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) + toView . CEvtContactSubSummary user $ map (uncurry ContactSubStatus) cRs + when ce $ mapM_ (toView . uncurry (CEvtContactSubError user)) cErrors + notifyAPI = toView . CEvtNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) statuses = M.foldrWithKey' addStatus [] cts where addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] @@ -3708,44 +3702,44 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do e -> show e -- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM () - contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs + contactLinkSubsToView rs = toView . CEvtUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () groupSubsToView rs gs ms ce = do mapM_ groupSub $ sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs - toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs + toViewTE . TEMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms groupSub :: ShortGroup -> CM () groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do - when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors - toView groupEvent + when ce $ mapM_ (toViewTE . uncurry (TEMemberSubError user g)) mErrors + toViewTE groupEvent where mErrors :: [(ShortGroupMember, ChatError)] mErrors = sortOn (\(ShortGroupMember {memberName = n}, _) -> n) . filterErrors $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs - groupEvent :: ChatResponse + groupEvent :: TerminalEvent groupEvent - | membershipStatus == GSMemInvited = CRGroupInvitation user g - | null members = CRGroupEmpty user g - | otherwise = CRGroupSubscribed user g + | membershipStatus == GSMemInvited = TEGroupInvitation user g + | null members = TEGroupEmpty user g + | otherwise = TEGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM () sndFileSubsToView rs sfts = do let sftRs = resultsFor rs sfts forM_ sftRs $ \(ft@SndFileTransfer {fileId, fileStatus}, err_) -> do - forM_ err_ $ toView . CRSndFileSubError user ft + forM_ err_ $ toViewTE . TESndFileSubError user ft void . forkIO $ do threadDelay 1000000 when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withChatLock "subscribe sendFileChunk" $ sendFileChunk user ft rcvFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId RcvFileTransfer -> CM () - rcvFileSubsToView rs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . filterErrors . resultsFor rs + rcvFileSubsToView rs = mapM_ (toViewTE . uncurry (TERcvFileSubError user)) . filterErrors . resultsFor rs pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> CM () - pendingConnSubsToView rs = toView . CRPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs + pendingConnSubsToView rs = toViewTE . TEPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> CM [a] - withStore_ a = withStore' (`a` user) `catchChatError` \e -> toView (CRChatError (Just user) e) $> [] + withStore_ a = withStore' (`a` user) `catchChatError` \e -> toView (CEvtChatError (Just user) e) $> [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -3767,28 +3761,28 @@ cleanupManager = do liftIO $ threadDelay' initialDelay stepDelay <- asks (cleanupManagerStepDelay . config) forever $ do - flip catchChatError (toView . CRChatError Nothing) $ do + flip catchChatError (toView . CEvtChatError Nothing) $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay - cleanupMessages `catchChatError` (toView . CRChatError Nothing) + cleanupMessages `catchChatError` (toView . CEvtChatError Nothing) -- TODO possibly, also cleanup async commands - cleanupProbes `catchChatError` (toView . CRChatError Nothing) + cleanupProbes `catchChatError` (toView . CEvtChatError Nothing) liftIO $ threadDelay' $ diffToMicroseconds interval where - runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CRChatError Nothing) $ do + runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CEvtChatError Nothing) $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users - forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) - forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) + forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CEvtChatError (Just u)) + forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CEvtChatError (Just u)) cleanupUser cleanupInterval stepDelay user = do - cleanupTimedItems cleanupInterval user `catchChatError` (toView . CRChatError (Just user)) + cleanupTimedItems cleanupInterval user `catchChatError` (toView . CEvtChatError (Just user)) liftIO $ threadDelay' stepDelay -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted - cleanupDeletedContacts user `catchChatError` (toView . CRChatError (Just user)) + cleanupDeletedContacts user `catchChatError` (toView . CEvtChatError (Just user)) liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime @@ -3800,7 +3794,7 @@ cleanupManager = do contacts <- withStore' $ \db -> getDeletedContacts db vr user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) - `catchChatError` (toView . CRChatError (Just user)) + `catchChatError` (toView . CEvtChatError (Just user)) cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts @@ -3826,7 +3820,7 @@ expireChatItems user@User {userId} globalTTL sync = do loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do - process a `catchChatError` (toView . CRChatError (Just user)) + process a `catchChatError` (toView . CEvtChatError (Just user)) loop as process continue :: CM () -> CM () continue a = diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index dca3a7f678..a2c8ae74b2 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -188,7 +188,7 @@ toggleNtf :: User -> GroupMember -> Bool -> CM () toggleNtf user m ntfOn = when (memberActive m) $ forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CEvtChatError (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 @@ -441,25 +441,25 @@ deleteFilesLocally files = withFilesFolder :: (FilePath -> CM ()) -> CM () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action -deleteDirectCIs :: User -> Contact -> [CChatItem 'CTDirect] -> Bool -> Bool -> CM ChatResponse -deleteDirectCIs user ct items byUser timed = do +deleteDirectCIs :: User -> Contact -> [CChatItem 'CTDirect] -> CM [ChatItemDeletion] +deleteDirectCIs user ct items = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteCIFiles user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser timed + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + pure deletions where deleteItem db (CChatItem md ci) = 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 -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion] +deleteGroupCIs user gInfo items 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) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser timed + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + pure deletions where deleteItem :: DB.Connection -> CChatItem 'CTGroup -> IO ChatItemDeletion deleteItem db (CChatItem md ci) = do @@ -491,7 +491,7 @@ deleteLocalCIs user nf items byUser timed = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteFilesLocally ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs pure $ CRChatItemsDeleted user deletions byUser timed where deleteItem db (CChatItem md ci) = do @@ -505,25 +505,26 @@ deleteCIFiles user filesInfo = do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo -markDirectCIsDeleted :: User -> Contact -> [CChatItem 'CTDirect] -> Bool -> UTCTime -> CM ChatResponse -markDirectCIsDeleted user ct items byUser deletedTs = do +markDirectCIsDeleted :: User -> Contact -> [CChatItem 'CTDirect] -> UTCTime -> CM [ChatItemDeletion] +markDirectCIsDeleted user ct items deletedTs = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items cancelFilesInProgress user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser False + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + pure deletions where markDeleted db (CChatItem md ci) = 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 -> [CChatItem 'CTGroup] -> Maybe GroupMember -> UTCTime -> CM [ChatItemDeletion] +markGroupCIsDeleted user gInfo items 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) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - pure $ CRChatItemsDeleted user deletions byUser False + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + pure deletions + -- pure $ CRChatItemsDeleted user deletions byUser False where markDeleted db (CChatItem md ci) = do ci' <- markGroupChatItemDeleted db user gInfo ci byGroupMember_ deletedTs @@ -569,7 +570,7 @@ updateCallItemStatus user ct@Contact {contactId} Call {chatItemId} receivedStatu updateDirectChatItemView :: User -> Contact -> ChatItemId -> ACIContent -> Bool -> Bool -> Maybe CITimed -> Maybe MessageId -> CM () updateDirectChatItemView user ct chatItemId (ACIContent msgDir ciContent) edited live timed_ msgId_ = do ci' <- withStore $ \db -> updateDirectChatItem db user ct chatItemId ciContent edited live timed_ msgId_ - toView $ CRChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') callStatusItemContent :: User -> Contact -> ChatItemId -> WebRTCCallStatus -> CM (Maybe ACIContent) callStatusItemContent user Contact {contactId} chatItemId receivedStatus = do @@ -617,11 +618,25 @@ receiveFile' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath receiveFile' user ft userApprovedRelays rcvInline_ filePath_ = do (CRRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError where - processError = \case - -- TODO AChatItem in Cancelled events - ChatErrorAgent (SMP _ SMP.AUTH) _ -> pure $ CRRcvFileAcceptedSndCancelled user ft - ChatErrorAgent (CONN DUPLICATE) _ -> pure $ CRRcvFileAcceptedSndCancelled user ft - e -> throwError e + -- TODO AChatItem in Cancelled events + processError e + | rctFileCancelled e = pure $ CRRcvFileAcceptedSndCancelled user ft + | otherwise = throwError e + +receiveFileEvt' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM ChatEvent +receiveFileEvt' user ft userApprovedRelays rcvInline_ filePath_ = do + (CEvtRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError + where + -- TODO AChatItem in Cancelled events + processError e + | rctFileCancelled e = pure $ CEvtRcvFileAcceptedSndCancelled user ft + | otherwise = throwError e + +rctFileCancelled :: ChatError -> Bool +rctFileCancelled = \case + ChatErrorAgent (SMP _ SMP.AUTH) _ -> True + ChatErrorAgent (CONN DUPLICATE) _ -> True + _ -> False acceptFileReceive :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM AChatItem acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = FileInvitation {fileName = fName, fileConnReq, fileInline, fileSize}, fileStatus, grpMemberId, cryptoArgs} userApprovedRelays rcvInline_ filePath_ = do @@ -728,7 +743,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} aci_ <- resetRcvCIFileStatus user fileId CIFSRcvInvitation forM_ aci_ $ \aci -> do cleanupACIFile aci - toView $ CRChatItemUpdated user aci + toView $ CEvtChatItemUpdated user aci throwChatError $ CEFileNotApproved fileId unknownSrvs cleanupACIFile :: AChatItem -> CM () @@ -782,7 +797,7 @@ startReceivingFile user fileId = do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 getChatItemByFileId db vr user fileId - toView $ CRRcvFileStart user ci + toView $ CEvtRcvFileStart user ci getRcvFilePath :: FileTransferId -> Maybe FilePath -> String -> Bool -> CM FilePath getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of @@ -983,7 +998,7 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId else forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` (toView . CRChatError (Just user)) + processIntro intro `catchChatError` (toView . CEvtChatError (Just user)) memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = let mInfo = memberInfo reMember @@ -1006,7 +1021,7 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac (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 + unless (null errors) $ toView $ CEvtChatErrors (Just user) errors let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_ forM_ (L.nonEmpty events') $ \events'' -> sendGroupMemberMessages user conn events'' groupId @@ -1140,12 +1155,14 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do case cType of CTDirect -> do (ct, ci) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId - deleteDirectCIs user ct [ci] True True >>= toView + deletions <- deleteDirectCIs user ct [ci] + toView $ CEvtChatItemsDeleted user deletions True True CTGroup -> do (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime - deleteGroupCIs user gInfo [ci] True True Nothing deletedTs >>= toView - _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" + deletions <- deleteGroupCIs user gInfo [ci] Nothing deletedTs + toView $ CEvtChatItemsDeleted user deletions True True + _ -> toView . CEvtChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" startUpdatedTimedItemThread :: User -> ChatRef -> ChatItem c d -> ChatItem c d -> CM () startUpdatedTimedItemThread user chatRef ci ci' = @@ -1169,7 +1186,7 @@ createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = ct' = ct {activeConn = Just conn'} :: Contact when (contactPQEnabled ct /= contactPQEnabled ct') $ do createInternalChatItem user (CDDirectSnd ct') ciContent Nothing - toView $ CRContactPQEnabled user ct' pqSndEnabled' + toView $ CEvtContactPQEnabled user ct' pqSndEnabled' pure (ct', conn') updateContactPQRcv :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) @@ -1185,7 +1202,7 @@ updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' ct' = ct {activeConn = Just conn'} :: Contact when (contactPQEnabled ct /= contactPQEnabled ct') $ do createInternalChatItem user (CDDirectRcv ct') ciContent Nothing - toView $ CRContactPQEnabled user ct' pqRcvEnabled' + toView $ CEvtContactPQEnabled user ct' pqRcvEnabled' pure (ct', conn') updatePeerChatVRange :: Connection -> VersionRangeChat -> CM Connection @@ -1270,7 +1287,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo liftIO $ updateSndFileStatus db ft FSComplete liftIO $ deleteSndFileChunks db ft updateDirectCIFileStatus db vr user fileId CIFSSndComplete - toView $ CRSndFileComplete user ci ft + toView $ CEvtSndFileComplete user ci ft lift $ closeFileHandle fileId sndFiles deleteAgentConnectionAsync user acId @@ -1320,7 +1337,7 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus, cryptoArgs, fileInvitati removeFile fsFilePath `catchChatError` \_ -> pure () renameFile tmpFile fsFilePath Left e -> do - toView $ CRChatError Nothing e + toView $ CEvtChatError Nothing e removeFile tmpFile `catchChatError` \_ -> pure () withStore' (`removeFileCryptoArgs` fileId) where @@ -1345,7 +1362,7 @@ isFileActive fileId files = do cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM (Maybe ConnId) cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInline} = - cancel' `catchChatError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) + cancel' `catchChatError` (\e -> toView (CEvtChatError (Just user) e) $> fileConnId) where cancel' = do lift $ closeFileHandle fileId rcvFiles @@ -1363,13 +1380,13 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInlin cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM [ConnId] cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) - `catchChatError` (toView . CRChatError (Just user)) + `catchChatError` (toView . CEvtChatError (Just user)) case xftpSndFile of Nothing -> catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) Just xsf -> do forM_ fts (\ft -> cancelSndFileTransfer user ft False) - lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` (toView . CRChatError (Just user)) + lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` (toView . CEvtChatError (Just user)) pure [] -- TODO v6.0 remove @@ -1377,7 +1394,7 @@ cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM (Maybe ConnId) cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = if fileStatus == FSCancelled || fileStatus == FSComplete then pure Nothing - else cancel' `catchChatError` (\e -> toView (CRChatError (Just user) e) $> fileConnId) + else cancel' `catchChatError` (\e -> toView (CEvtChatError (Just user) e) $> fileConnId) where cancel' = do withStore' $ \db -> do @@ -1498,7 +1515,7 @@ sendGroupMemberMessages user conn events groupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) let idsEvts = L.map (GroupId groupId,) events (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs forM_ (L.nonEmpty msgs) $ \msgs' -> batchSendConnMessages user conn MsgFlags {notification = True} msgs' @@ -1627,7 +1644,7 @@ sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> Non 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? when shouldSendProfileUpdate $ - sendProfileUpdate `catchChatError` (toView . CRChatError (Just user)) + sendProfileUpdate `catchChatError` (toView . CEvtChatError (Just user)) sendGroupMessages_ user gInfo members events where User {profile = p, userMemberProfileUpdatedAt} = user @@ -1786,7 +1803,7 @@ memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = sendGroupMemberMessage :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () sendGroupMemberMessage user gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) - messageMember msg `catchChatError` (toView . CRChatError (Just user)) + messageMember msg `catchChatError` (toView . CEvtChatError (Just user)) where messageMember :: SndMessage -> CM () messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case @@ -1850,7 +1867,7 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me if sameMemberId refMemberId am then forM_ (memberConn forwardingMember) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId - else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" throwError e _ -> throwError e @@ -1974,7 +1991,7 @@ deleteAgentConnectionAsync user acId = deleteAgentConnectionAsync' user acId Fal deleteAgentConnectionAsync' :: User -> ConnId -> Bool -> CM () deleteAgentConnectionAsync' user acId waitDelivery = do - withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` (toView . CEvtChatError (Just user)) deleteAgentConnectionsAsync :: User -> [ConnId] -> CM () deleteAgentConnectionsAsync user acIds = deleteAgentConnectionsAsync' user acIds False @@ -1982,7 +1999,7 @@ deleteAgentConnectionsAsync user acIds = deleteAgentConnectionsAsync' user acIds deleteAgentConnectionsAsync' :: User -> [ConnId] -> Bool -> CM () deleteAgentConnectionsAsync' _ [] _ = pure () deleteAgentConnectionsAsync' user acIds waitDelivery = do - withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` (toView . CEvtChatError (Just user)) agentXFTPDeleteRcvFile :: RcvFileId -> FileTransferId -> CM () agentXFTPDeleteRcvFile aFileId fileId = do @@ -2083,8 +2100,8 @@ createContactsFeatureItems :: createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do let dirsCIContents = map contactChangedFeatures cts (errs, acis) <- partitionEithers <$> createInternalItemsForChats user Nothing dirsCIContents - unless (null errs) $ toView' $ CRChatErrors (Just user) errs - toView' $ CRNewChatItems user acis + unless (null errs) $ toView' $ CEvtChatErrors (Just user) errs + toView' $ CEvtNewChatItems user acis where contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, [CIContent d]) contactChangedFeatures (Contact {mergedPreferences = cups}, ct'@Contact {mergedPreferences = cups'}) = do @@ -2129,7 +2146,7 @@ createGroupFeatureItems user cd ciContent GroupInfo {fullGroupPreferences} = createInternalChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> CM () createInternalChatItem user cd content itemTs_ = lift (createInternalItemsForChats user itemTs_ [(cd, [content])]) >>= \case - [Right aci] -> toView $ CRNewChatItems user [aci] + [Right aci] -> toView $ CEvtNewChatItems user [aci] [Left e] -> throwError e rs -> throwChatError $ CEInternalError $ "createInternalChatItem: expected 1 result, got " <> show (length rs) @@ -2165,7 +2182,7 @@ createLocalChatItems :: createLocalChatItems user cd itemsData createdAt = do withStore' $ \db -> updateChatTs db user cd createdAt (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors (Just user) errs pure items where createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) @@ -2254,5 +2271,5 @@ timeItToView s action = do a <- action t2 <- liftIO getCurrentTime let diff = diffToMilliseconds $ diffUTCTime t2 t1 - toView' $ CRTimedAction s diff + toView' $ CEvtTimedAction s diff pure a diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 38d67aa150..53aee8938f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -90,20 +90,20 @@ smallGroupsRcptsMemLimit = 20 processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessage _ _ (DEL_RCVQS delQs) = - toView $ CRAgentRcvQueuesDeleted $ L.map rcvQ delQs + toView $ CEvtAgentRcvQueuesDeleted $ L.map rcvQ delQs where rcvQ (connId, server, rcvId, err_) = DeletedRcvQueue (AgentConnId connId) server (AgentQueueId rcvId) err_ processAgentMessage _ _ (DEL_CONNS connIds) = - toView $ CRAgentConnsDeleted $ L.map AgentConnId connIds + toView $ CEvtAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = - toView $ CRChatError Nothing $ ChatErrorAgent e Nothing + toView $ CEvtChatError Nothing $ ChatErrorAgent e Nothing processAgentMessage corrId connId msg = do lockEntity <- critical (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do vr <- chatVersionRange -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) + Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CEvtChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. @@ -121,22 +121,22 @@ critical a = processAgentMessageNoConn :: AEvent 'AENone -> CM () processAgentMessageNoConn = \case - CONNECT p h -> hostEvent $ CRHostConnected p h - DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns NSDisconnected CRContactsDisconnected - UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed - SUSPENDED -> toView CRChatSuspended - DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId + CONNECT p h -> hostEvent $ CEvtHostConnected p h + DISCONNECT p h -> hostEvent $ CEvtHostDisconnected p h + DOWN srv conns -> serverEvent srv conns NSDisconnected CEvtContactsDisconnected + UP srv conns -> serverEvent srv conns NSConnected CEvtContactsSubscribed + SUSPENDED -> toView CEvtChatSuspended + DEL_USER agentUserId -> toView $ CEvtAgentUserDeleted agentUserId ERRS cErrs -> errsEvent cErrs where - hostEvent :: ChatResponse -> CM () + hostEvent :: ChatEvent -> CM () hostEvent = whenM (asks $ hostEvents . config) . toView serverEvent srv conns nsStatus event = do chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds ifM (asks $ coreApi . config) (notifyAPI connIds) notifyCLI where connIds = map AgentConnId conns - notifyAPI = toView . CRNetworkStatus nsStatus + notifyAPI = toView . CEvtNetworkStatus nsStatus notifyCLI = do cs <- withStore' (`getConnectionsContacts` conns) toView $ event srv cs @@ -144,7 +144,7 @@ processAgentMessageNoConn = \case errsEvent cErrs = do vr <- chatVersionRange errs <- lift $ rights <$> withStoreBatch' (\db -> map (getChatErr vr db) cErrs) - toView $ CRChatErrors Nothing errs + toView $ CEvtChatErrors Nothing errs where getChatErr :: VersionRangeChat -> DB.Connection -> (ConnId, AgentErrorType) -> IO ChatError getChatErr vr db (connId, err) = @@ -156,7 +156,7 @@ processAgentMsgSndFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPSndFileDBIds` AgentSndFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgSndFile" fileId $ withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` (toView . CRChatError (Just user)) + Just user -> process user fileId `catchChatError` (toView . CEvtChatError (Just user)) _ -> do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId @@ -176,7 +176,7 @@ processAgentMsgSndFile _corrId aFileId msg = do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status lookupChatItemByFileId db vr user fileId - toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal + toView $ CEvtSndFileProgressXFTP user ci ft sndProgress sndTotal SFDONE sndDescr rfds -> do withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId @@ -188,18 +188,18 @@ processAgentMsgSndFile _corrId aFileId msg = do [] -> sendFileError (FileErrOther "no receiver descriptions") "no receiver descriptions" vr ft rfd : _ -> case [fd | fd@(FD.ValidFileDescription FD.FileDescription {chunks = [_]}) <- rfds] of [] -> case xftpRedirectFor of - Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft + Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CEvtSndFileRedirectStartXFTP user ft Just _ -> sendFileError (FileErrOther "chaining redirects") "Prohibit chaining redirects" vr ft rfds' -> do -- we have 1 chunk - use it as URI whether it is redirect or not ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor - toView $ CRSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' + toView $ CEvtSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) -> case (msgId_, itemDeleted) of (Just sharedMsgId, Nothing) -> do when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" -- TODO either update database status or move to SFPROG - toView $ CRSndFileProgressXFTP user ci ft 1 1 + toView $ CEvtSndFileProgressXFTP user ci ft 1 1 case (rfds, sfts, d, cInfo) of (rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) @@ -208,9 +208,9 @@ processAgentMsgSndFile _corrId aFileId msg = do Just rs -> case L.last rs of Right ([msgDeliveryId], _) -> withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId - Right (deliveryIds, _) -> toView $ CRChatError (Just user) $ ChatError $ CEInternalError $ "SFDONE, sendFileDescriptions: expected 1 delivery id, got " <> show (length deliveryIds) - Left e -> toView $ CRChatError (Just user) e - Nothing -> toView $ CRChatError (Just user) $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" + Right (deliveryIds, _) -> toView $ CEvtChatError (Just user) $ ChatError $ CEInternalError $ "SFDONE, sendFileDescriptions: expected 1 delivery id, got " <> show (length deliveryIds) + Left e -> toView $ CEvtChatError (Just user) e + Nothing -> toView $ CEvtChatError (Just user) $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do ms <- withStore' $ \db -> getGroupMembers db vr user g @@ -223,7 +223,7 @@ processAgentMsgSndFile _corrId aFileId msg = do liftIO $ updateCIFileStatus db user fileId CIFSSndComplete getChatItemByFileId db vr user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileCompleteXFTP user ci' ft + toView $ CEvtSndFileCompleteXFTP user ci' ft where memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') @@ -244,7 +244,7 @@ processAgentMsgSndFile _corrId aFileId msg = do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSSndWarning $ agentFileError e) lookupChatItemByFileId db vr user fileId - toView $ CRSndFileWarning user ci ft err + toView $ CEvtSndFileWarning user ci ft err SFERR e -> sendFileError (agentFileError e) (tshow e) vr ft where @@ -259,7 +259,7 @@ processAgentMsgSndFile _corrId aFileId msg = do let (errs, msgReqs) = partitionEithers . L.toList $ L.zipWith (fmap . toMsgReq) connsIdsEvts sndMsgs_ delivered <- mapM deliverMessages (L.nonEmpty msgReqs) let errs' = errs <> maybe [] (lefts . L.toList) delivered - unless (null errs') $ toView $ CRChatErrors (Just user) errs' + unless (null errs') $ toView $ CEvtChatErrors (Just user) errs' pure delivered where connDescrEvents :: Int -> NonEmpty (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) @@ -278,7 +278,7 @@ processAgentMsgSndFile _corrId aFileId msg = do liftIO $ updateFileCancelled db user fileId (CIFSSndError ferr) lookupChatItemByFileId db vr user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileError user ci ft err + toView $ CEvtSndFileError user ci ft err agentFileError :: AgentErrorType -> FileError agentFileError = \case @@ -298,7 +298,7 @@ processAgentMsgRcvFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPRcvFileDBIds` AgentRcvFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgRcvFile" fileId $ withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` (toView . CRChatError (Just user)) + Just user -> process user fileId `catchChatError` (toView . CEvtChatError (Just user)) _ -> do lift $ withAgent' (`xftpDeleteRcvFile` aFileId) throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId @@ -318,7 +318,7 @@ processAgentMsgRcvFile _corrId aFileId msg = do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status lookupChatItemByFileId db vr user fileId - toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal ft + toView $ CEvtRcvFileProgressXFTP user ci rcvProgress rcvTotal ft RFDONE xftpPath -> case liveRcvFileTransferPath ft of Nothing -> throwChatError $ CEInternalError "no target path for received XFTP file" @@ -331,25 +331,25 @@ processAgentMsgRcvFile _corrId aFileId msg = do updateCIFileStatus db user fileId CIFSRcvComplete lookupChatItemByFileId db vr user fileId agentXFTPDeleteRcvFile aFileId fileId - toView $ maybe (CRRcvStandaloneFileComplete user fsTargetPath ft) (CRRcvFileComplete user) ci_ + toView $ maybe (CEvtRcvStandaloneFileComplete user fsTargetPath ft) (CEvtRcvFileComplete user) ci_ RFWARN e -> do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSRcvWarning $ agentFileError e) lookupChatItemByFileId db vr user fileId - toView $ CRRcvFileWarning user ci e ft + toView $ CEvtRcvFileWarning user ci e ft RFERR e | e == FILE NOT_APPROVED -> do aci_ <- resetRcvCIFileStatus user fileId CIFSRcvAborted forM_ aci_ cleanupACIFile agentXFTPDeleteRcvFile aFileId fileId - forM_ aci_ $ \aci -> toView $ CRChatItemUpdated user aci + forM_ aci_ $ \aci -> toView $ CEvtChatItemUpdated user aci | otherwise -> do aci_ <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ agentFileError e) lookupChatItemByFileId db vr user fileId forM_ aci_ cleanupACIFile agentXFTPDeleteRcvFile aFileId fileId - toView $ CRRcvFileError user aci_ e ft + toView $ CEvtRcvFileError user aci_ e ft processAgentMessageConn :: VersionRangeChat -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do @@ -360,9 +360,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = entity <- critical $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of - RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct - _ -> toView $ CRSubscriptionEnd user entity - MSGNTF msgId msgTs_ -> toView $ CRNtfMessage user entity $ ntfMsgAckInfo msgId msgTs_ + RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct + _ -> toView $ CEvtSubscriptionEnd user entity + MSGNTF msgId msgTs_ -> toView $ CEvtNtfMessage user entity $ ntfMsgAckInfo msgId msgTs_ _ -> case entity of RcvDirectMsgConnection conn contact_ -> processDirectMessage agentMessage entity conn contact_ @@ -438,13 +438,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MWARN _ err -> processConnMWARN connEntity conn err MERR _ err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS _ err -> do -- error cannot be AUTH error here - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -468,11 +468,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e + processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> toView $ CEvtChatError (Just user) e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "contact msg=error " <> eInfo <> " " <> tshow e - toView $ CRChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) + toView $ CEvtChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) checkSendRcpt ct' $ rights aChatMsgs -- not crucial to use ct'' from processEvent where aChatMsgs = parseChatMessages msgBody @@ -543,7 +543,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing True allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend - toView $ CRBusinessLinkConnecting user gInfo host ct + toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport @@ -567,7 +567,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) lift $ setContactNetworkStatus ct' NSConnected - toView $ CRContactConnected user ct' (fmap fromLocalProfile incognitoProfile) + toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct') $ do createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo pqEnc) Nothing createFeatureEnabledItems ct' @@ -601,11 +601,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = cis <- updateDirectItemsStatus' db ct conn msgId (CISSndSent SSPComplete) liftIO $ forM cis $ \ci -> setDirectSndChatItemViaProxy db user ct ci (isJust proxy) let acis = map ctItem cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) SWITCH qd phase cStats -> do - toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) + toView $ CEvtContactSwitch user ct (SwitchProgress qd phase cStats) when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing @@ -628,12 +628,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = . mapM (\(ci, content') -> updateDirectChatItem' db user contactId ci content' False False Nothing Nothing) . mdeUpdatedCI e case ci_ of - Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + Just ci -> toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> do - toView $ CRContactRatchetSync user ct (RatchetSyncProgress rss cStats) + toView $ CEvtContactRatchetSync user ct (RatchetSyncProgress rss cStats) createInternalChatItem user (CDDirectRcv ct) (CIRcvDecryptionError mde n) Nothing ratchetSyncEventItem ct' = do - toView $ CRContactRatchetSync user ct' (RatchetSyncProgress rss cStats) + toView $ CEvtContactRatchetSync user ct' (RatchetSyncProgress rss cStats) createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent $ RCERatchetSync rss) Nothing OK -> -- [async agent commands] continuation on receiving OK @@ -643,7 +643,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> when (directOrUsed ct && sqSecured) $ do lift $ setContactNetworkStatus ct NSConnected - toView $ CRContactSndReady user ct + toView $ CEvtContactSndReady user ct forM_ viaUserContactLink $ \userContactLinkId -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId let (UserContactLink {autoAccept}, _) = ucl @@ -655,14 +655,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processConnMWARN connEntity conn err MERR msgId err -> do updateDirectItemStatus ct conn msgId (CISSndError $ agentSndError err) - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do -- error cannot be AUTH error here updateDirectItemsStatusMsgs ct conn (L.toList msgIds) (CISSndError $ agentSndError err) - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -671,7 +671,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just AutoAccept {autoReply = Just mc} -> do (msg, _) <- sendDirectContactMessage user ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] _ -> pure () processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () @@ -698,7 +698,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setNewContactMemberConnRequest db user m cReq groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId - toView $ CRSentGroupInvitation user gInfo ct m + toView $ CEvtSentGroupInvitation user gInfo ct m where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> CM () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do @@ -776,7 +776,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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'} + toView $ CEvtUserJoinedGroup user gInfo {membership = membership {memberStatus = status'}} m {memberStatus = status'} let cd = CDGroupRcv gInfo m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CIRcvGroupFeature gInfo @@ -787,7 +787,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion GCInviteeMember -> do memberConnectedChatItem gInfo m - toView $ CRJoinedGroupMember user gInfo m {memberStatus = status'} + toView $ CEvtJoinedGroupMember user gInfo m {memberStatus = status'} let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem when (connChatVersion < batchSend2Version) sendGroupAutoReply @@ -831,12 +831,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent tags eInfo chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e + processEvent tags eInfo chatMsg `catchChatError` \e -> toView $ CEvtChatError (Just user) e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e - toView $ CRChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) - forwardMsgs (rights aChatMsgs) `catchChatError` (toView . CRChatError (Just user)) + toView $ CEvtChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) + forwardMsgs (rights aChatMsgs) `catchChatError` (toView . CEvtChatError (Just user)) checkSendRcpt $ rights aChatMsgs where aChatMsgs = parseChatMessages msgBody @@ -918,7 +918,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy) when continued $ sendPendingGroupMessages user m conn SWITCH qd phase cStats -> do - toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) + toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) 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 @@ -930,7 +930,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setConnectionVerified db user connId Nothing let m' = m {activeConn = Just (conn {connectionCode = Nothing} :: Connection)} :: GroupMember ratchetSyncEventItem m' - toView $ CRGroupMemberVerificationReset user gInfo m' + toViewTE $ TEGroupMemberVerificationReset user gInfo m' createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent RCEVerificationCodeReset) Nothing _ -> ratchetSyncEventItem m where @@ -942,12 +942,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = . 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 $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) _ -> do - toView $ CRGroupMemberRatchetSync user gInfo m (RatchetSyncProgress rss cStats) + toView $ CEvtGroupMemberRatchetSync 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) + toView $ CEvtGroupMemberRatchetSync user gInfo m' (RatchetSyncProgress rss cStats) createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent $ RCERatchetSync rss) Nothing OK -> -- [async agent commands] continuation on receiving OK @@ -965,16 +965,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MERR msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSError $ agentSndError err) -- group errors are silenced to reduce load on UI event log - -- toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + -- toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do let newStatus = GSSError $ agentSndError err -- error cannot be AUTH error here withStore' $ \db -> forM_ msgIds $ \msgId -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure () - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1000,7 +1000,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = msg <- sendGroupMessage' user gInfo [m] (XMsgNew $ MCSimple (extMsgContent mc Nothing)) ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) withStore' $ \db -> createGroupSndStatus db (chatItemId' ci) (groupMemberId' m) GSSNew - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] agentMsgDecryptError :: AgentCryptoError -> (MsgDecryptError, Word32) agentMsgDecryptError = \case @@ -1045,7 +1045,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSConnected updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 - toView $ CRSndFileStart user ci ft + toView $ CEvtSndFileStart user ci ft sendFileChunk user ft SENT msgId _proxy -> do withStore' $ \db -> updateSndFileChunkSent db ft msgId @@ -1059,7 +1059,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just (ChatRef CTDirect _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled _ -> pure () lookupChatItemByFileId db vr user fileId - toView $ CRSndFileRcvCancelled user ci ft + toView $ CEvtSndFileRcvCancelled user ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ _ -> withAckMessage' "file msg" agentConnId meta $ pure () @@ -1070,7 +1070,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1119,10 +1119,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () MERR _ err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1133,7 +1133,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) ci <- withStore $ \db -> getChatItemByFileId db vr user fileId - toView $ CRRcvFileSndCancelled user ci ft + toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -1156,7 +1156,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateCIFileStatus db user fileId CIFSRcvComplete deleteRcvFileChunks db ft getChatItemByFileId db vr user fileId - toView $ CRRcvFileComplete user ci + toView $ CEvtRcvFileComplete user ci forM_ conn_ $ \conn -> deleteAgentConnectionAsync user (aConnId conn) RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo @@ -1171,10 +1171,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err ERR err -> do - toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) + toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1182,8 +1182,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> CM () profileContactRequest invId chatVRange p@Profile {displayName} xContactId_ reqPQSup = do withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case - CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact - CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo + CORContact contact -> toView $ CEvtContactRequestAlreadyAccepted user contact + CORGroup gInfo -> toView $ CEvtBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId let (UserContactLink {connLinkContact = CCLink connReq _, autoAccept}, gLinkInfo_) = ucl @@ -1195,16 +1195,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if isSimplexTeam && v < businessChatsVersion then do ct <- acceptContactRequestAsync user cReq Nothing reqPQSup - toView $ CRAcceptingContactRequest user ct + toView $ CEvtAcceptingContactRequest user ct else do gInfo <- acceptBusinessJoinRequestAsync user cReq - toView $ CRAcceptingBusinessRequest user gInfo + toView $ CEvtAcceptingBusinessRequest user gInfo | otherwise -> case gLinkInfo_ of Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequestAsync user cReq incognitoProfile reqPQSup - toView $ CRAcceptingContactRequest user ct + toView $ CEvtAcceptingContactRequest user ct Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId acceptMember_ <- asks $ acceptMember . chatHooks . config @@ -1216,14 +1216,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 + toView $ CEvtAcceptingGroupJoinRequestMember user gInfo mem Left rjctReason | v < groupJoinRejectVersion -> messageWarning $ "processUserContactRequest (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" | otherwise -> do mem <- acceptGroupJoinSendRejectAsync user gInfo cReq rjctReason toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason - _ -> toView $ CRReceivedContactRequest user cReq + _ -> toView $ CEvtReceivedContactRequest user cReq -- TODO [knocking] review memberCanSend :: GroupMember -> CM () -> CM () @@ -1238,12 +1238,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = authErrCounter' <- withStore' $ \db -> incAuthErrCounter db user conn when (authErrCounter' >= authErrDisableCount) $ case connEntity of RcvDirectMsgConnection ctConn (Just ct) -> do - toView $ CRContactDisabled user ct {activeConn = Just ctConn {authErrCounter = authErrCounter'}} - _ -> toView $ CRConnectionDisabled connEntity + toView $ CEvtContactDisabled user ct {activeConn = Just ctConn {authErrCounter = authErrCounter'}} + _ -> toView $ CEvtConnectionDisabled connEntity SMP _ SMP.QUOTA -> unless (connInactive conn) $ do withStore' $ \db -> setQuotaErrCounter db user conn quotaErrSetOnMERR - toView $ CRConnectionInactive connEntity True + toView $ CEvtConnectionInactive connEntity True _ -> pure () processConnMWARN :: ConnectionEntity -> Connection -> AgentErrorType -> CM () @@ -1253,8 +1253,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (connInactive conn) $ do quotaErrCounter' <- withStore' $ \db -> incQuotaErrCounter db user conn when (quotaErrCounter' >= quotaErrInactiveCount) $ - toView $ - CRConnectionInactive connEntity True + toView $ CEvtConnectionInactive connEntity True _ -> pure () continueSending :: ConnectionEntity -> Connection -> CM Bool @@ -1262,7 +1261,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if connInactive conn then do withStore' $ \db -> setQuotaErrCounter db user conn 0 - toView $ CRConnectionInactive connEntity False + toView $ CEvtConnectionInactive connEntity False pure True else pure False @@ -1366,7 +1365,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = notifyMemberConnected gInfo m ct_ = do memberConnectedChatItem gInfo m lift $ mapM_ (`setContactNetworkStatus` NSConnected) ct_ - toView $ CRConnectedToGroupMember user gInfo m ct_ + toView $ CEvtConnectedToGroupMember user gInfo m ct_ probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> CM () probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do @@ -1423,10 +1422,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> CM () - messageWarning = toView . CRMessageError user "warning" + messageWarning = toView . CEvtMessageError user "warning" messageError :: Text -> CM () - messageError = toView . CRMessageError user "error" + messageError = toView . CEvtMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do @@ -1452,13 +1451,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newChatItem content ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing 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}] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}] autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> CM () autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do -- ! autoAcceptFileSize is only used in tests ChatConfig {autoAcceptFileSize = sz} <- asks config - when (sz > fileSize) $ receiveFile' user ft False Nothing Nothing >>= toView + when (sz > fileSize) $ receiveFileEvt' user ft False Nothing Nothing >>= toView messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> CM () messageFileDescription ct@Contact {contactId} sharedMsgId fileDescr = do @@ -1482,7 +1481,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (rfd, ft') when fileDescrComplete $ do ci <- withStore $ \db -> getAChatItemBySharedMsgId db user cd sharedMsgId - toView $ CRRcvFileDescrReady user ci ft' rfd + toView $ CEvtRcvFileDescrReady user ci ft' rfd case (fileStatus, xftpRcvFile) of (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd userApprovedRelays cryptoArgs _ -> pure () @@ -1521,7 +1520,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content True live Nothing Nothing - toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') where brokerTs = metaBrokerTs msgMeta content = CIRcvMsgContent mc @@ -1540,24 +1539,25 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- getDirectCIReactions db ct sharedMsgId 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') + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' - else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + else toView $ CEvtChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> messageError "x.msg.update: contact attempted invalid message update" messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> CM () messageDelete ct@Contact {contactId} sharedMsgId _rcvMessage msgMeta = do - deleteRcvChatItem `catchCINotFound` (toView . CRChatItemDeletedNotFound user ct) + deleteRcvChatItem `catchCINotFound` (toView . CEvtChatItemDeletedNotFound user ct) where brokerTs = metaBrokerTs msgMeta deleteRcvChatItem = do cci@(CChatItem msgDir ci) <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId case msgDir of SMDRcv - | rcvItemDeletable ci brokerTs -> - if featureAllowed SCFFullDelete forContact ct - then deleteDirectCIs user ct [cci] False False >>= toView - else markDirectCIsDeleted user ct [cci] False brokerTs >>= toView + | rcvItemDeletable ci brokerTs -> do + deletions <- if featureAllowed SCFFullDelete forContact ct + then deleteDirectCIs user ct [cci] + else markDirectCIsDeleted user ct [cci] brokerTs + toView $ CEvtChatItemsDeleted user deletions False False | otherwise -> messageError "x.msg.del: contact attempted invalid message delete" SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" @@ -1575,7 +1575,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setDirectReaction db ct sharedMsgId False reaction add msgId brokerTs where updateChatItemReaction = do - cr_ <- withStore $ \db -> do + cEvt_ <- withStore $ \db -> do CChatItem md ci <- getDirectChatItemBySharedMsgId db user (contactId' ct) sharedMsgId if ciReactionAllowed ci then liftIO $ do @@ -1583,9 +1583,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- getDirectCIReactions db ct sharedMsgId let ci' = CChatItem md ci {reactions} r = ACIReaction SCTDirect SMDRcv (DirectChat ct) $ CIReaction CIDirectRcv ci' brokerTs reaction - pure $ Just $ CRChatItemReaction user add r + pure $ Just $ CEvtChatItemReaction user add r else pure Nothing - mapM_ toView cr_ + mapM_ toView cEvt_ groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM () groupMsgReaction g m sharedMsgId itemMemberId reaction add RcvMessage {msgId} brokerTs = do @@ -1596,7 +1596,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs where updateChatItemReaction = do - cr_ <- withStore $ \db -> do + cEvt_ <- withStore $ \db -> do CChatItem md ci <- getGroupMemberCIBySharedMsgId db user g itemMemberId sharedMsgId if ciReactionAllowed ci then liftIO $ do @@ -1604,9 +1604,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- getGroupCIReactions db g itemMemberId sharedMsgId let ci' = CChatItem md ci {reactions} r = ACIReaction SCTGroup SMDRcv (GroupChat g) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction - pure $ Just $ CRChatItemReaction user add r + pure $ Just $ CEvtChatItemReaction user add r else pure Nothing - mapM_ toView cr_ + mapM_ toView cEvt_ reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool reactionAllowed add reaction rs = (reaction `elem` rs) /= add && not (add && length rs >= maxMsgReactions) @@ -1656,7 +1656,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = do file_ <- processFileInv ci <- createNonLive file_ - toView =<< markGroupCIsDeleted user gInfo [CChatItem SMDRcv ci] False (Just moderator) moderatedAt + deletions <- markGroupCIsDeleted user gInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt + toView $ CEvtChatItemsDeleted user deletions False False createNonLive file_ = saveRcvCI (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions createContentItem = do @@ -1688,7 +1689,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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') + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc @@ -1710,9 +1711,9 @@ 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') + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) + else toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) 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" @@ -1730,13 +1731,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- regular deletion Nothing | sameMemberId memberId mem && msgMemberId == memberId && rcvItemDeletable ci brokerTs -> - delete cci Nothing >>= toView + delete cci Nothing | otherwise -> messageError "x.msg.del: member attempted invalid message delete" -- moderation (not limited by time) Just _ | sameMemberId memberId mem && msgMemberId == memberId -> - delete cci (Just m) >>= toView + delete cci (Just m) | otherwise -> moderate mem cci CIGroupSnd -> moderate membership cci @@ -1749,7 +1750,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = moderate mem cci = case sndMemberId_ of Just sndMemberId | sameMemberId sndMemberId mem -> checkRole mem $ do - delete cci (Just m) >>= toView + delete cci (Just m) archiveMessageReports cci m | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" _ -> messageError "x.msg.del: message of another member without memberId" @@ -1757,14 +1758,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRModerator || senderRole < memberRole = 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 :: CChatItem 'CTGroup -> Maybe GroupMember -> CM () + delete cci byGroupMember = do + deletions <- if groupFeatureMemberAllowed SGFFullDelete m gInfo + then deleteGroupCIs user gInfo [cci] byGroupMember brokerTs + else markGroupCIsDeleted user gInfo [cci] byGroupMember brokerTs + toView $ CEvtChatItemsDeleted user deletions False False archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs - unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) + unless (null ciIds) $ toView $ CEvtGroupChatItemsDeleted user gInfo ciIds False (Just byMember) -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> CM () @@ -1777,7 +1780,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] where brokerTs = metaBrokerTs msgMeta @@ -1815,7 +1818,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) ci <- withStore $ \db -> getChatItemByFileId db vr user fileId - toView $ CRRcvFileSndCancelled user ci ft + toView $ CEvtRcvFileSndCancelled user ci ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> CM () xFileAcptInv ct sharedMsgId fileConnReq_ fName = do @@ -1837,7 +1840,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- createSndDirectInlineFT db ct ft - pure $ CRSndFileStart user ci' sft + pure $ CEvtSndFileStart user ci' sft toView event ifM (allowSendInline fileSize fileInline) @@ -1867,8 +1870,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case file of Just CIFile {fileProtocol = FPXFTP} -> do ft <- withStore $ \db -> getFileTransferMeta db user fileId - toView $ CRSndFileCompleteXFTP user ci ft - _ -> toView $ CRSndFileComplete user ci sft + toView $ CEvtSndFileCompleteXFTP user ci ft + _ -> toView $ CEvtSndFileComplete user ci sft allowSendInline :: Integer -> Maybe InlineFileMode -> CM Bool allowSendInline fileSize = \case @@ -1909,7 +1912,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) ci <- withStore $ \db -> getChatItemByFileId db vr user fileId - toView $ CRRcvFileSndCancelled user ci ft + toView $ CEvtRcvFileSndCancelled user ci ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" @@ -1934,7 +1937,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- liftIO $ createSndGroupInlineFT db m conn ft - pure $ CRSndFileStart user ci' sft + pure $ CEvtSndFileStart user ci' sft toView event ifM (allowSendInline fileSize fileInline) @@ -1945,7 +1948,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMsgToView :: forall d. MsgDirectionI d => GroupInfo -> ChatItem 'CTGroup d -> CM () groupMsgToView gInfo ci = - toView $ CRNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo) ci] + toView $ CEvtNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo) ci] processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> CM () processGroupInvitation ct inv msg msgMeta = do @@ -1967,13 +1970,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) + 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 withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] - toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where brokerTs = metaBrokerTs msgMeta sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool @@ -1999,8 +2002,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci] - toView $ CRContactDeletedByContact user ct'' + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci] + toView $ CEvtContactDeletedByContact user ct'' else do contactConns <- withStore' $ \db -> getContactConnections db vr userId c deleteAgentConnectionsAsync user $ map aConnId contactConns @@ -2020,7 +2023,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (directOrUsed c' && createItems) $ do createProfileUpdatedItem c' lift $ createRcvFeatureItems user c c' - toView $ CRContactUpdated user c c' + toView $ CEvtContactUpdated user c c' pure c' | otherwise = pure c @@ -2070,7 +2073,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupMemberStatus db userId m GSMemConnected updateGroupMemberAccepted db user membership role let m' = m {memberStatus = GSMemConnected} - toView $ CRUserJoinedGroup user gInfo {membership = membership'} m' + toView $ CEvtUserJoinedGroup user gInfo {membership = membership'} m' let connectedIncognito = memberIncognito membership probeMatchingMemberContact m' connectedIncognito @@ -2082,7 +2085,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> do m' <- withStore $ \db -> updateMemberProfile db user m p' createProfileUpdatedItem m' - toView $ CRGroupMemberUpdated user gInfo m m' + toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db vr user mContactId @@ -2090,8 +2093,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' createProfileUpdatedItem m' - toView $ CRGroupMemberUpdated user gInfo m m' - toView $ CRContactUpdated user mCt ct' + toView $ CEvtGroupMemberUpdated user gInfo m m' + toView $ CEvtContactUpdated user mCt ct' pure m' else pure m where @@ -2106,7 +2109,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' - toView $ CRGroupUpdated user g g' (Just m) + toView $ CEvtGroupUpdated user g g' (Just m) _ -> pure () isMainBusinessMember BusinessChatInfo {chatType, businessId, customerId} GroupMember {memberId} = case chatType of BCBusiness -> businessId == memberId @@ -2213,8 +2216,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci} - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci} + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] else featureRejected CFCalls where brokerTs = metaBrokerTs msgMeta @@ -2222,7 +2225,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = featureRejected f = do let content = ciContentNoParse $ CIRcvChatFeatureRejected f ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty - toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] -- to party initiating call xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> CM () @@ -2233,7 +2236,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey) callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} askConfirmation = encryptedCall localCallType && not (encryptedCall callType) - toView CRCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} + toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) _ -> do msgCallStateError "x.call.offer" call @@ -2246,7 +2249,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = \call -> case callState call of CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession = rtcSession, sharedKey} - toView $ CRCallAnswer user ct rtcSession + toView $ CEvtCallAnswer user ct rtcSession pure (Just call {callState = callState'}, Just . ACIContent SMDRcv $ CIRcvCall CISCallNegotiated 0) _ -> do msgCallStateError "x.call.answer" call @@ -2260,12 +2263,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} - toView $ CRCallExtraInfo user ct rtcExtraInfo + toView $ CEvtCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} - toView $ CRCallExtraInfo user ct rtcExtraInfo + toView $ CEvtCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) _ -> do msgCallStateError "x.call.extra" call @@ -2275,7 +2278,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xCallEnd :: Contact -> CallId -> RcvMessage -> CM () xCallEnd ct callId msg = msgCurrentCall ct callId "x.call.end" msg $ \Call {chatItemId} -> do - toView $ CRCallEnded user ct + toView $ CEvtCallEnded user ct (Nothing,) <$> callStatusItemContent user ct chatItemId WCSDisconnected msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> (Call -> CM (Maybe Call, Maybe ACIContent)) -> CM () @@ -2317,7 +2320,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where merge c1' c2' = do c2'' <- withStore $ \db -> mergeContactRecords db vr user c1' c2' - toView $ CRContactsMerged user c1' c2' c2'' + toView $ CEvtContactsMerged user c1' c2' c2'' when (directOrUsed c2'') $ showSecurityCodeChanged c2'' pure $ Just c2'' where @@ -2356,14 +2359,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = associateMemberWithContact c1 m2@GroupMember {groupId} = do withStore' $ \db -> associateMemberWithContactRecord db user c1 m2 g <- withStore $ \db -> getGroupInfo db vr user groupId - toView $ CRContactAndMemberAssociated user c1 g m2 c1 + toView $ CEvtContactAndMemberAssociated user c1 g m2 c1 pure c1 associateContactWithMember :: GroupMember -> Contact -> CM Contact associateContactWithMember m1@GroupMember {groupId} c2 = do c2' <- withStore $ \db -> associateContactWithMemberRecord db vr user m1 c2 g <- withStore $ \db -> getGroupInfo db vr user groupId - toView $ CRContactAndMemberAssociated user c2 g m1 c2' + toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Bool) @@ -2373,15 +2376,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case chatMsgEvent of XInfo p -> do ct <- withStore $ \db -> createDirectContact db user conn' p - toView $ CRContactConnecting user ct + toView $ CEvtContactConnecting user ct pure (conn', False) XGrpLinkInv glInv -> do (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv - toView $ CRGroupLinkConnecting user gInfo host + toView $ CEvtGroupLinkConnecting user gInfo host pure (conn', True) XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct - toView $ CRGroupLinkConnecting user gInfo host + toView $ CEvtGroupLinkConnecting user gInfo host toViewTE $ TEGroupLinkRejected user gInfo rejectionReason pure (conn', True) -- TODO show/log error, other events in SMP confirmation @@ -2394,7 +2397,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 - toView $ CRUnknownMemberAnnounced user gInfo m unknownMember updatedMember + toView $ CEvtUnknownMemberAnnounced user gInfo m unknownMember updatedMember memberAnnouncedToView updatedMember Right _ -> messageError "x.grp.mem.new error: member already exists" Left _ -> do @@ -2405,7 +2408,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 + toView $ CEvtJoinedGroupMemberConnecting user gInfo m announcedMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> CM () xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do @@ -2491,7 +2494,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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} + toView CEvtMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -2519,11 +2522,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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} + toView CEvtMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId bm' <- setMemberBlocked bm - toView $ CRUnknownMemberBlocked user gInfo m bm' + toView $ CEvtUnknownMemberBlocked user gInfo m bm' Left e -> throwError $ ChatErrorStore e where setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm @@ -2582,7 +2585,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved when withMessages $ deleteMessages membership SMDSnd deleteMemberItem RGEUserDeleted - toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m withMessages + toView $ CEvtDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m withMessages else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.del with unknown member ID" @@ -2594,7 +2597,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteOrUpdateMemberRecord user member when withMessages $ deleteMessages member SMDRcv deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember 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 = @@ -2615,7 +2618,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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} + toView $ CEvtLeftMember user gInfo m {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do @@ -2628,7 +2631,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 + toView $ CEvtGroupDeleted 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,7 +2639,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' - toView $ CRGroupUpdated user g g' (Just m) + toView $ CEvtGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m unless (sameGroupProfileInfo p p') $ do ci <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') @@ -2653,7 +2656,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupPrefs_ g@GroupInfo {groupProfile = p} m ps' = unless (groupPreferences p == Just ps') $ do g' <- withStore' $ \db -> updateGroupPreferences db user g ps' - toView $ CRGroupUpdated user g g' (Just m) + toView $ CEvtGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' @@ -2696,14 +2699,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing - toView $ CRNewMemberContactReceivedInv user mCt' g m' + toView $ CEvtNewMemberContactReceivedInv 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] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci] securityCodeChanged :: Contact -> CM () securityCodeChanged ct = do - toView $ CRContactVerificationReset user ct + toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> CM () @@ -2713,7 +2716,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right author -> processForwardedMsg author msg Left (SEGroupMemberNotFoundByMemberId _) -> do unknownAuthor <- createUnknownMember gInfo memberId - toView $ CRUnknownMemberCreated user gInfo m unknownAuthor + toView $ CEvtUnknownMemberCreated user gInfo m unknownAuthor processForwardedMsg unknownAuthor msg Left e -> throwError $ ChatErrorStore e where @@ -2763,7 +2766,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateDirectItemsStatusMsgs ct conn msgIds newStatus = do cis <- withStore' $ \db -> forM msgIds $ \msgId -> runExceptT $ updateDirectItemsStatus' db ct conn msgId newStatus let acis = map ctItem $ concat $ rights cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) @@ -2771,7 +2774,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateDirectItemStatus ct conn msgId newStatus = do cis <- withStore $ \db -> updateDirectItemsStatus' db ct conn msgId newStatus let acis = map ctItem cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) @@ -2802,7 +2805,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = items <- withStore' (\db -> getGroupChatItemsByAgentMsgId db user groupId connId msgId) cis <- catMaybes <$> withStore (\db -> mapM (updateItem db) items) let acis = map gItem cis - unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where gItem = AChatItem SCTGroup SMDSnd (GroupChat gInfo) updateItem :: DB.Connection -> CChatItem 'CTGroup -> ExceptT StoreError IO (Maybe (ChatItem 'CTGroup 'MDSnd)) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 502dbc98d0..7f9f3eb505 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -51,7 +51,7 @@ import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), Migrati import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..)) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..)) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) @@ -72,10 +72,14 @@ data DBMigrationResult $(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "DBM") ''DBMigrationResult) -data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} +data APIResponse = APIResponse {remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} + +data APIEvent = APIEvent {remoteHostId :: Maybe RemoteHostId, resp :: ChatEvent} $(JQ.deriveToJSON defaultJSON ''APIResponse) +$(JQ.deriveToJSON defaultJSON ''APIEvent) + foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString foreign export ccall "chat_migrate_init_key" cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString @@ -286,21 +290,16 @@ chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString chatSendCmd cc = chatSendRemoteCmd cc Nothing chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO JSONByteString -chatSendRemoteCmd cc rh s = J.encode . APIResponse Nothing rh <$> runReaderT (execChatCommand rh s) cc +chatSendRemoteCmd cc rh s = J.encode . APIResponse rh <$> runReaderT (execChatCommand rh s) cc chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = json <$> readChatResponse where - json (corr, remoteHostId, resp) = J.encode APIResponse {corr, remoteHostId, resp} - readChatResponse = do - out@(_, _, cr) <- atomically $ readTBQueue outputQ - if filterEvent cr then pure out else readChatResponse - filterEvent = \case - CRGroupSubscribed {} -> False - CRGroupEmpty {} -> False - CRMemberSubSummary {} -> False - CRPendingSubSummary {} -> False - _ -> True + json (remoteHostId, resp) = J.encode APIEvent {remoteHostId, resp} + readChatResponse = + atomically (readTBQueue outputQ) >>= \case + (_, CEvtTerminalEvent {}) -> readChatResponse + out -> pure out chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d971656a26..bcdd60377f 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -192,7 +192,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ - toView CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} + toView CEvtRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello @@ -203,7 +203,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' - toView $ CRNewRemoteHost rhi + toView $ CEvtNewRemoteHost rhi -- set up HTTP transport and remote profile protocol disconnected <- toIO $ onDisconnected rhKey' sseq httpClient <- liftError' (httpError remoteHostId) $ attachRevHTTP2Client disconnected tls @@ -213,7 +213,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host - toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} + toView $ CEvtRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} upsertRemoteHost :: RCHostPairing -> Maybe RemoteHostInfo -> Maybe RCCtrlAddress -> Text -> SessionSeq -> RemoteHostSessionState -> CM RemoteHostInfo upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ rcAddr_ hostDeviceName sseq state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ @@ -235,7 +235,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do oq <- asks outputQ forever $ do r_ <- liftRH rhId $ remoteRecv rhClient 10000000 - forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) + forM r_ $ \r -> atomically $ writeTBQueue oq (Just rhId, r) httpError :: RemoteHostId -> HTTP2ClientError -> ChatError httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow @@ -271,7 +271,7 @@ cancelRemoteHostSession handlerInfo_ rhKey = do forM_ deregistered $ \session -> do liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) forM_ (snd <$> handlerInfo_) $ \rhStopReason -> - toView CRRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} + toView CEvtRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} where handlingError = isJust handlerInfo_ remoteHostId_ = case rhKey of @@ -417,7 +417,7 @@ findKnownRemoteCtrl = do Just rc -> pure rc atomically $ putTMVar foundCtrl (rc, inv) let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_ - toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} + toView CEvtRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionSearching {action, foundCtrl} _ -> Left $ ChatErrorRemoteCtrl RCEBadState @@ -482,7 +482,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) let remoteCtrlId_ = remoteCtrlId' <$> rc_ in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState - toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} + toView CEvtRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} checkAppVersion CtrlAppInfo {appVersionRange} = case compatibleAppVersion hostAppVersionRange appVersionRange of Just (AppCompatible v) -> pure v @@ -496,7 +496,7 @@ parseCtrlAppInfo :: JT.Value -> CM CtrlAppInfo parseCtrlAppInfo ctrlAppInfo = do liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo -handleRemoteCommand :: (ByteString -> CM' ChatResponse) -> RemoteCrypto -> TBQueue ChatResponse -> HTTP2Request -> CM' () +handleRemoteCommand :: (ByteString -> CM' ChatResponse) -> RemoteCrypto -> TBQueue ChatEvent -> HTTP2Request -> CM' () handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftIO (tryRemoteError' parseRequest) >>= \case @@ -527,7 +527,7 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque send resp attach sfKN send flush - Left e -> toView' . CRChatError Nothing . ChatErrorRemoteCtrl $ RCEProtocolError e + Left e -> toView' . CEvtChatError Nothing . ChatErrorRemoteCtrl $ RCEProtocolError e takeRCStep :: RCStepTMVar a -> CM a takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar @@ -556,7 +556,7 @@ handleSend execChatCommand command = do -- convert errors thrown in execChatCommand into error responses to prevent aborting the protocol wrapper RRChatResponse <$> execChatCommand (encodeUtf8 command) -handleRecv :: Int -> TBQueue ChatResponse -> IO RemoteResponse +handleRecv :: Int -> TBQueue ChatEvent -> IO RemoteResponse handleRecv time events = do logDebug $ "Recv: " <> tshow time RRChatEvent <$> (timeout time . atomically $ readTBQueue events) @@ -675,7 +675,7 @@ cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do forM_ session_ $ \session -> do liftIO $ cancelRemoteCtrl handlingError session forM_ (snd <$> handlerInfo_) $ \rcStopReason -> - toView CRRemoteCtrlStopped {rcsState = rcsSessionState session, rcStopReason} + toView CEvtRemoteCtrlStopped {rcsState = rcsSessionState session, rcStopReason} where handlingError = isJust handlerInfo_ diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 00fc56f897..b572780a1f 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -65,7 +65,7 @@ data RemoteCommand data RemoteResponse = RRChatResponse {chatResponse :: ChatResponse} - | RRChatEvent {chatEvent :: Maybe ChatResponse} -- 'Nothing' on poll timeout + | RRChatEvent {chatEvent :: Maybe ChatEvent} -- 'Nothing' on poll timeout | RRFileStored {filePath :: String} | RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- The protocol error happened on the server side @@ -115,10 +115,10 @@ remoteSend c cmd = RRChatResponse cr -> pure cr r -> badResponse r -remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatEvent) remoteRecv c ms = sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case - RRChatEvent cr_ -> pure cr_ + RRChatEvent cEvt_ -> pure cEvt_ r -> badResponse r remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index bf48d1d4f5..06a1e0f314 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -81,7 +81,6 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g CRChatCmdError _ _ -> when (isMessage cmd) $ echo s - CRChatError _ _ -> when (isMessage cmd) $ echo s CRCmdOk _ -> case cmd of Right APIDeleteUser {} -> setActive ct "" _ -> pure () diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index aa9adb059f..af90340cbc 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -10,12 +10,12 @@ import Data.Maybe (fromMaybe) import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Network.Socket -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatEvent (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Options.DB import Simplex.Chat.Terminal -import Simplex.Chat.View (serializeChatResponse, smpProxyModeStr) +import Simplex.Chat.View (ChatResponseEvent, serializeChatResponse, smpProxyModeStr) import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (..)) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) @@ -43,13 +43,14 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer simplexChatTerminal cfg opts t runCommand user cc = do when (chatCmdLog /= CCLNone) . void . forkIO . forever $ do - (_, _, r') <- atomically . readTBQueue $ outputQ cc - case r' of - CRNewChatItems {} -> printResponse r' - _ -> when (chatCmdLog == CCLAll) $ printResponse r' + (_, r) <- atomically . readTBQueue $ outputQ cc + case r of + CEvtNewChatItems {} -> printResponse r + _ -> when (chatCmdLog == CCLAll) $ printResponse r sendChatCmdStr cc chatCmd >>= printResponse threadDelay $ chatCmdDelay * 1000000 where + printResponse :: ChatResponseEvent r => r -> IO () printResponse r = do ts <- getCurrentTime tz <- getCurrentTimeZone diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 3694b20c67..5134d0efc9 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -146,19 +146,19 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> ChatOpts -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} ChatOpts {markRead} = do forever $ do - (_, outputRH, r) <- atomically $ readTBQueue outputQ + (outputRH, r) <- atomically $ readTBQueue outputQ case r of - CRNewChatItems u (ci : _) -> when markRead $ markChatItemRead u ci -- At the moment of writing received items are created one at a time - CRChatItemUpdated u ci -> when markRead $ markChatItemRead u ci - CRRemoteHostConnected {remoteHost = RemoteHostInfo {remoteHostId}} -> getRemoteUser remoteHostId - CRRemoteHostStopped {remoteHostId_} -> mapM_ removeRemoteUser remoteHostId_ + CEvtNewChatItems u (ci : _) -> when markRead $ markChatItemRead u ci -- At the moment of writing received items are created one at a time + CEvtChatItemUpdated u ci -> when markRead $ markChatItemRead u ci + CEvtRemoteHostConnected {remoteHost = RemoteHostInfo {remoteHostId}} -> getRemoteUser remoteHostId + CEvtRemoteHostStopped {remoteHostId_} -> mapM_ removeRemoteUser remoteHostId_ _ -> pure () - let printResp = case logFilePath of - Just path -> if logResponseToFile r then logResponse path else printToTerminal ct + let printEvent = case logFilePath of + Just path -> if logEventToFile r then logResponse path else printToTerminal ct _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems - responseString ct cc liveItems outputRH r >>= printResp - responseNotification ct cc r + responseString ct cc liveItems outputRH r >>= printEvent + chatEventNotification ct cc r where markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = case (chatDirNtf u chat chatDir (isUserMention ci), itemStatus) of @@ -174,10 +174,10 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) -responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () -responseNotification t@ChatTerminal {sendNotification} cc = \case +chatEventNotification :: ChatTerminal -> ChatController -> ChatEvent -> IO () +chatEventNotification t@ChatTerminal {sendNotification} cc = \case -- At the moment of writing received items are created one at a time - CRNewChatItems u ((AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) : _) -> + CEvtNewChatItems u ((AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) : _) -> when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of @@ -186,29 +186,29 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case _ -> pure () where text = msgText mc formattedText - CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> + CEvtChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ setActiveChat t cInfo - CRContactConnected u ct _ -> when (contactNtf u ct False) $ do + CEvtContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") - CRContactSndReady u ct -> + CEvtContactSndReady u ct -> whenCurrUser cc u $ setActiveContact t ct - CRContactAnotherClient u ct -> do + CEvtContactAnotherClient u ct -> do whenCurrUser cc u $ unsetActiveContact t ct when (contactNtf u ct False) $ sendNtf (viewContactName ct <> "> ", "connected to another client") - CRContactsDisconnected srv _ -> serverNtf srv "disconnected" - CRContactsSubscribed srv _ -> serverNtf srv "connected" - CRReceivedGroupInvitation u g ct _ _ -> + CEvtContactsDisconnected srv _ -> serverNtf srv "disconnected" + CEvtContactsSubscribed srv _ -> serverNtf srv "connected" + CEvtReceivedGroupInvitation u g ct _ _ -> when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") - CRUserJoinedGroup u g _ -> when (groupNtf u g False) $ do + CEvtUserJoinedGroup u g _ -> when (groupNtf u g False) $ do whenCurrUser cc u $ setActiveGroup t g sendNtf ("#" <> viewGroupName g, "you are connected to group") - CRJoinedGroupMember u g m -> + CEvtJoinedGroupMember u g m -> when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") - CRConnectedToGroupMember u g m _ -> + CEvtConnectedToGroupMember u g m _ -> when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") - CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = n} -> when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") _ -> pure () where @@ -274,7 +274,7 @@ whenCurrUser cc u a = do printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO () printRespToTerminal ct cc liveItems outputRH r = responseString ct cc liveItems outputRH r >>= printToTerminal ct -responseString :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO [StyledString] +responseString :: ChatResponseEvent r => ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> r -> IO [StyledString] responseString ct cc liveItems outputRH r = do cu <- getCurrentUser ct cc ts <- getCurrentTime diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6abbf6f03f..b87ba3a081 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -7,6 +7,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} @@ -86,22 +87,27 @@ data WCallCommand $(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "WCCall") ''WCallCommand) -serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> String +serializeChatResponse :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> r -> String serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ -responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] -responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case +class ChatResponseEvent r where + responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> r -> [StyledString] + +instance ChatResponseEvent ChatResponse where responseToView = chatResponseToView + +instance ChatResponseEvent ChatEvent where responseToView = chatEventToView + +chatResponseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] +chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] - CRChatSuspended -> ["chat suspended"] CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRChatTags u tags -> ttyUser u $ [viewJSON tags] - CRApiParsedMarkdown ft -> [viewJSON ft] CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) @@ -120,52 +126,23 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh CRGroupMemberSwitchStarted {} -> ["switch started"] CRContactSwitchAborted {} -> ["switch aborted"] CRGroupMemberSwitchAborted {} -> ["switch aborted"] - CRContactSwitch u ct progress -> ttyUser u $ viewContactSwitch ct progress - CRGroupMemberSwitch u g m progress -> ttyUser u $ viewGroupMemberSwitch g m progress CRContactRatchetSyncStarted {} -> ["connection synchronization started"] CRGroupMemberRatchetSyncStarted {} -> ["connection synchronization started"] - CRContactRatchetSync u ct progress -> ttyUser u $ viewContactRatchetSync ct progress - CRGroupMemberRatchetSync u g m progress -> ttyUser u $ viewGroupMemberRatchetSync g m progress - CRContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct - CRGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView - CRNewChatItems u chatItems - | length chatItems > 20 -> - if - | all (\aci -> aChatItemDir aci == MDRcv) chatItems -> ttyUser u [sShow (length chatItems) <> " new messages"] - | all (\aci -> aChatItemDir aci == MDSnd) chatItems -> ttyUser u [sShow (length chatItems) <> " messages sent"] - | otherwise -> ttyUser u [sShow (length chatItems) <> " new messages created"] - | otherwise -> - concatMap - (\(AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item) - chatItems + CRNewChatItems u chatItems -> viewChatItems ttyUser unmuted u chatItems ts tz CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] - CRChatItemsStatusesUpdated u chatItems - | length chatItems <= 20 -> - concatMap - (\ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts) - chatItems - | testView && showReceipts -> - ttyUser u [sShow (length chatItems) <> " message statuses updated"] - | otherwise -> [] CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci CRTagsUpdated u _ _ -> ttyUser u ["chat tags updated"] - CRChatItemsDeleted u deletions byUser timed -> case deletions of - [ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] -> - ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView - deletions' -> ttyUser u [sShow (length deletions') <> " messages deleted"] - CRGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u [ttyGroup' g <> ": " <> sShow (length ciIds) <> " messages deleted by " <> if byUser then "user" else "member" <> maybe "" (\m -> " " <> ttyMember m) member_] + CRChatItemsDeleted u deletions byUser timed -> ttyUser u $ viewChatItemsDeleted (unmuted u) deletions byUser timed ts tz testView + CRGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u $ viewGroupChatItemsDeleted g ciIds byUser member_ CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRReactionMembers u memberReactions -> ttyUser u $ viewReactionMembers memberReactions - CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc s f t -> ttyUser u $ viewSentBroadcast mc s f ts tz t - CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr - CRCmdAccepted _ -> [] CRCmdOk u_ -> ttyUser' u_ ["ok"] CRChatHelp section -> case section of HSMain -> chatHelpInfo @@ -187,18 +164,12 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRGroupsList u gs -> ttyUser u $ viewGroupsList gs - CRSentGroupInvitation u g c _ -> - ttyUser u $ - case contactConn c of - Just Connection {viaGroupLink} - | viaGroupLink -> [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] - | otherwise -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] - Nothing -> [] + CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci CRUserProfile u p -> ttyUser u $ viewUserProfile p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] - CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u' + CRUserPrivacy u u' -> ttyUserPrefix hu outputRH u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info CRInvitation u ccLink _ -> ttyUser u $ viewConnReqInvitation ccLink CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c @@ -208,29 +179,18 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] - CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c - CRAcceptingBusinessRequest u g -> ttyUser u $ viewAcceptingBusinessRequest g CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] - CRContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] - CRBusinessRequestAlreadyAccepted u g -> ttyUser u [ttyFullGroup g <> ": sent you a duplicate connection request, but you are already connected, no action needed"] CRUserContactLinkCreated u ccLink -> ttyUser u $ connReqContact_ "Your new chat address is created!" ccLink CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] - CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] - CRBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRUserDeletedMembers u g members wm -> case members of [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group" <> withMessages wm] mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group" <> withMessages wm] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] - CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] - CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc - CRRcvFileDescrReady _ _ _ _ -> [] - CRRcvFileProgressXFTP {} -> [] CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts @@ -241,110 +201,27 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c CRGroupAliasUpdated u g -> ttyUser u $ viewGroupAliasUpdated g CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c - CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' - CRGroupMemberUpdated {} -> [] - CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' - CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile CRRcvStandaloneFileCreated u ft -> ttyUser u $ receivingFileStandalone "started" ft - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci - CRRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft - CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] - CRRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e] - CRRcvFileWarning u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "warning: " ci <> [sShow e] - CRRcvFileWarning u Nothing e ft -> ttyUser u $ receivingFileStandalone "warning: " ft <> [sShow e] - CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft - CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft - CRSndFileStartXFTP {} -> [] - CRSndFileProgressXFTP {} -> [] - CRSndFileRedirectStartXFTP u ft ftRedirect -> ttyUser u $ standaloneUploadRedirect ft ftRedirect - CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris - CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci - CRSndFileCancelledXFTP {} -> [] - CRSndFileError u Nothing ft e -> ttyUser u $ uploadingFileStandalone "error" ft <> [plain e] - CRSndFileError u (Just ci) _ e -> ttyUser u $ uploadingFile "error" ci <> [plain e] - CRSndFileWarning u Nothing ft e -> ttyUser u $ uploadingFileStandalone "warning: " ft <> [plain e] - CRSndFileWarning u (Just ci) _ e -> ttyUser u $ uploadingFile "warning: " ci <> [plain e] - CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> - ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [viewJSON j]) info_ - CRContactConnecting u _ -> ttyUser u [] - CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView - CRContactSndReady u ct -> ttyUser u [ttyFullContact ct <> ": you can send messages to contact"] - CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] - CRSubscriptionEnd u acEntity -> - let Connection {connId} = entityConnection acEntity - in ttyUser u [sShow connId <> ": END"] - CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactSubError u c e -> ttyUser u [ttyContact' c <> ": contact error " <> sShow e] - CRContactSubSummary u summary -> - ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" - where - (errors, subscribed) = partition (isJust . contactError) summary - CRUserContactSubSummary u summary -> - ttyUser u $ - map addressSS addresses - <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") - where - (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary - addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError - (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks - CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] - CRGroupInvitation u g -> ttyUser u [groupInvitationSub g] - 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 - 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"] - 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 CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked - CRDeletedMemberUser u g by wm -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group" <> withMessages wm] <> groupPreserved g - CRDeletedMember u g by m wm -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group" <> withMessages wm] - CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] - CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupDescription u g -> ttyUser u $ viewGroupDescription g CRGroupLinkCreated u g ccLink mRole -> ttyUser u $ groupLink_ "Group link is created!" g ccLink mRole CRGroupLink u g ccLink mRole -> ttyUser u $ groupLink_ "Group link:" g ccLink mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g - CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] - CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] - CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] - CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' - CRMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] - CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g - CRPendingSubSummary u _ -> ttyUser u [] - CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> - ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRRcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> - ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation RcvCallInvitation {user, contact, callType, sharedKey} -> ttyUser user $ viewCallInvitation contact callType sharedKey - CRCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey - CRCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer - CRCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] - CRCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"] CRCallInvitations _ -> [] - CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] - CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv] CRNtfConns {ntfConns} -> map (\NtfConn {agentConnId, expectedMsg_} -> plain $ show agentConnId <> " " <> show expectedMsg_) ntfConns CRConnNtfMessages ntfMsgs -> [sShow ntfMsgs] - CRNtfMessage {} -> [] CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" @@ -359,40 +236,16 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh ] where started = " started on " <> B.unpack (strEncode address) <> ":" <> ctrlPort - CRRemoteHostSessionCode {remoteHost_, sessionCode} -> - [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, - "Compare session code with host:", - plain sessionCode - ] - CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] - CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] - CRRemoteHostStopped {remoteHostId_} -> - [ maybe "new remote host" (mappend "remote host " . sShow) remoteHostId_ <> " stopped" - ] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> - [ ("remote controller " <> sShow remoteCtrlId <> " found: ") - <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ - ] - <> ["use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] - where - deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") <> viewRemoteCtrl ctrlAppInfo appVersion True ] - CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> - [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, - "Compare session code with controller and use:", - "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId - ] CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] - CRRemoteCtrlStopped {rcStopReason} -> viewRemoteCtrlStopped rcStopReason - CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] CRSQLResult rows -> map plain rows #if !defined(dbPostgres) CRArchiveExported archiveErrs -> if null archiveErrs then ["ok"] else ["archive export errors: " <> plain (show archiveErrs)] @@ -433,41 +286,18 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh [ "agent queues info:", plain . LB.unpack $ J.encode agentQueuesInfo ] - CRContactDisabled u c -> ttyUser u ["[" <> ttyContact' c <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] - CRConnectionDisabled entity -> viewConnectionEntityDisabled entity - CRConnectionInactive entity inactive -> viewConnectionEntityInactive entity inactive - CRAgentRcvQueuesDeleted delQs -> ["completed deleting rcv queues: " <> sShow (length delQs) | logLevel <= CLLInfo] - CRAgentConnsDeleted acIds -> ["completed deleting connections: " <> sShow (length acIds) | logLevel <= CLLInfo] - CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] - CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e - CRChatError u e -> ttyUser' u $ viewChatError False logLevel testView e - CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError False logLevel testView) errs CRAppSettings as -> ["app settings: " <> viewJSON as] - CRTimedAction _ _ -> [] CRCustomChatResponse u r -> ttyUser' u $ map plain $ T.lines r - 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] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss - | (showNtfs && isNothing viewPwdHash) || activeUser = ttyUserPrefix user ss + | (showNtfs && isNothing viewPwdHash) || activeUser = ttyUserPrefix hu outputRH user ss | otherwise = [] - ttyUserPrefix :: User -> [StyledString] -> [StyledString] - ttyUserPrefix _ [] = [] - ttyUserPrefix User {userId, localDisplayName = u} ss - | null prefix = ss - | otherwise = prependFirst ("[" <> mconcat prefix <> "] ") ss - where - prefix = intersperse ", " $ remotePrefix <> userPrefix - remotePrefix = [maybe "local" (("remote: " <>) . highlight . show) outputRH | outputRH /= currentRH] - userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] - currentUserId = (\User {userId = uId} -> uId) <$> user_ ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] - ttyUserPrefix' = maybe id ttyUserPrefix + ttyUserPrefix' = maybe id $ ttyUserPrefix hu outputRH testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where @@ -498,10 +328,6 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText}}) membership_ = let deleted_ = maybe "" (\t -> " [" <> t <> "]") (chatItemDeletedText ci membership_) in itemText <> deleted_ - viewErrorsSummary :: [a] -> StyledString -> [StyledString] - viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] - contactList :: [ContactRef] -> String - contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] @@ -513,6 +339,203 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh | otherwise = [] withMessages wm = if wm then " with all messages" else "" +ttyUserPrefix :: (Maybe RemoteHostId, Maybe User) -> Maybe RemoteHostId -> User -> [StyledString] -> [StyledString] +ttyUserPrefix _ _ _ [] = [] +ttyUserPrefix (currentRH, user_) outputRH User {userId, localDisplayName = u} ss + | null prefix = ss + | otherwise = prependFirst ("[" <> mconcat prefix <> "] ") ss + where + prefix = intersperse ", " $ remotePrefix <> userPrefix + remotePrefix = [maybe "local" (("remote: " <>) . highlight . show) outputRH | outputRH /= currentRH] + userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] + currentUserId = (\User {userId = uId} -> uId) <$> user_ + +viewErrorsSummary :: [a] -> StyledString -> [StyledString] +viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] + +contactList :: [ContactRef] -> String +contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs + +chatEventToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatEvent -> [StyledString] +chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case + CEvtChatSuspended -> ["chat suspended"] + CEvtContactSwitch u ct progress -> ttyUser u $ viewContactSwitch ct progress + CEvtGroupMemberSwitch u g m progress -> ttyUser u $ viewGroupMemberSwitch g m progress + CEvtContactRatchetSync u ct progress -> ttyUser u $ viewContactRatchetSync ct progress + CEvtGroupMemberRatchetSync u g m progress -> ttyUser u $ viewGroupMemberRatchetSync g m progress + CEvtNewChatItems u chatItems -> viewChatItems ttyUser unmuted u chatItems ts tz + CEvtChatItemsStatusesUpdated u chatItems + | length chatItems <= 20 -> + concatMap + (\ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts) + chatItems + | testView && showReceipts -> + ttyUser u [sShow (length chatItems) <> " message statuses updated"] + | otherwise -> [] + CEvtChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz + CEvtChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci + CEvtChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz + CEvtChatItemsDeleted u deletions byUser timed -> ttyUser u $ viewChatItemsDeleted (unmuted u) deletions byUser timed ts tz testView + CEvtGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u $ viewGroupChatItemsDeleted g ciIds byUser member_ + CEvtChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] + CEvtUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] + CEvtSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c + CEvtContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] + CEvtAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c + CEvtAcceptingBusinessRequest u g -> ttyUser u $ viewAcceptingBusinessRequest g + CEvtContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] + CEvtBusinessRequestAlreadyAccepted u g -> ttyUser u [ttyFullGroup g <> ": sent you a duplicate connection request, but you are already connected, no action needed"] + CEvtGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] + CEvtBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] + CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] + CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] + CEvtUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] + CEvtRcvFileDescrReady _ _ _ _ -> [] + CEvtRcvFileAccepted u ci -> ttyUser u $ savingFile' ci + CEvtRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft + CEvtRcvFileProgressXFTP {} -> [] + CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CEvtGroupMemberUpdated {} -> [] + CEvtContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile + CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci + CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci + CEvtRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft + CEvtRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft + CEvtRcvFileError u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] + CEvtRcvFileError u Nothing e ft -> ttyUser u $ receivingFileStandalone "error" ft <> [sShow e] + CEvtRcvFileWarning u (Just ci) e _ -> ttyUser u $ receivingFile_' hu testView "warning: " ci <> [sShow e] + CEvtRcvFileWarning u Nothing e ft -> ttyUser u $ receivingFileStandalone "warning: " ft <> [sShow e] + CEvtSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft + CEvtSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft + CEvtSndFileStartXFTP {} -> [] + CEvtSndFileProgressXFTP {} -> [] + CEvtSndFileRedirectStartXFTP u ft ftRedirect -> ttyUser u $ standaloneUploadRedirect ft ftRedirect + CEvtSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris + CEvtSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci + CEvtSndFileCancelledXFTP {} -> [] + CEvtSndFileError u Nothing ft e -> ttyUser u $ uploadingFileStandalone "error" ft <> [plain e] + CEvtSndFileError u (Just ci) _ e -> ttyUser u $ uploadingFile "error" ci <> [plain e] + CEvtSndFileWarning u Nothing ft e -> ttyUser u $ uploadingFileStandalone "warning: " ft <> [plain e] + CEvtSndFileWarning u (Just ci) _ e -> ttyUser u $ uploadingFile "warning: " ci <> [plain e] + CEvtSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> + ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] + CEvtContactConnecting u _ -> ttyUser u [] + CEvtContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView + CEvtContactSndReady u ct -> ttyUser u [ttyFullContact ct <> ": you can send messages to contact"] + CEvtContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] + CEvtSubscriptionEnd u acEntity -> + let Connection {connId} = entityConnection acEntity + in ttyUser u [sShow connId <> ": END"] + CEvtContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CEvtContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CEvtContactSubError u c e -> ttyUser u [ttyContact' c <> ": contact error " <> sShow e] + CEvtContactSubSummary u summary -> + ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" + where + (errors, subscribed) = partition (isJust . contactError) summary + CEvtUserContactSubSummary u summary -> + ttyUser u $ + map addressSS addresses + <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") + where + (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary + addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError + (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks + CEvtNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] + CEvtNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] + CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r + CEvtUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g + CEvtJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m + CEvtHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] + CEvtHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] + CEvtJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + CEvtConnectedToGroupMember u g m _ -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] + CEvtMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' + CEvtMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked + CEvtDeletedMemberUser u g by wm -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group" <> withMessages wm] <> groupPreserved g + CEvtDeletedMember u g by m wm -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group" <> withMessages wm] + CEvtLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] + CEvtGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] + CEvtGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m + CEvtAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] + CEvtNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] + CEvtNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] + CEvtContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' + CEvtCallInvitation RcvCallInvitation {user, contact, callType, sharedKey} -> ttyUser user $ viewCallInvitation contact callType sharedKey + CEvtCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey + CEvtCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer + CEvtCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] + CEvtCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"] + CEvtNtfMessage {} -> [] + CEvtRemoteHostSessionCode {remoteHost_, sessionCode} -> + [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, + "Compare session code with host:", + plain sessionCode + ] + CEvtNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] + CEvtRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] + CEvtRemoteHostStopped {remoteHostId_} -> + [ maybe "new remote host" (mappend "remote host " . sShow) remoteHostId_ <> " stopped" + ] + CEvtRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> + [ ("remote controller " <> sShow remoteCtrlId <> " found: ") + <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ + ] + <> ["use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] + where + deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " + CEvtRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> + [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, + "Compare session code with controller and use:", + "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId + ] + CEvtRemoteCtrlStopped {rcStopReason} -> viewRemoteCtrlStopped rcStopReason + CEvtContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] + CEvtContactDisabled u c -> ttyUser u ["[" <> ttyContact' c <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] + CEvtConnectionDisabled entity -> viewConnectionEntityDisabled entity + CEvtConnectionInactive entity inactive -> viewConnectionEntityInactive entity inactive + CEvtAgentRcvQueuesDeleted delQs -> ["completed deleting rcv queues: " <> sShow (length delQs) | logLevel <= CLLInfo] + CEvtAgentConnsDeleted acIds -> ["completed deleting connections: " <> sShow (length acIds) | logLevel <= CLLInfo] + CEvtAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] + CEvtMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] + CEvtChatError u e -> ttyUser' u $ viewChatError False logLevel testView e + CEvtChatErrors u errs -> ttyUser' u $ concatMap (viewChatError False logLevel testView) errs + CEvtTimedAction _ _ -> [] + CEvtTerminalEvent 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] + TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] + TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct + TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m + TEGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g + TEGroupInvitation u g -> ttyUser u [groupInvitationSub g] + TEGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] + TEMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] + TEMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" + TEPendingSubSummary u _ -> ttyUser u [] + TESndFileSubError u SndFileTransfer {fileId, fileName} e -> + ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + TERcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> + ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + where + ttyUser :: User -> [StyledString] -> [StyledString] + ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss + | (showNtfs && isNothing viewPwdHash) || activeUser = ttyUserPrefix hu outputRH user ss + | otherwise = [] + ttyUser' :: Maybe User -> [StyledString] -> [StyledString] + ttyUser' = maybe id ttyUser + withMessages wm = if wm then " with all messages" else "" + unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci + unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] + unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False + unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] + unmuted' u chat chatDir mention s + | chatDirNtf u chat chatDir mention = s + | testView = map (<> " ") s + | otherwise = [] + userNtf :: User -> Bool userNtf User {showNtfs, activeUser} = showNtfs || activeUser @@ -592,6 +615,23 @@ viewChats ts tz = concatMap chatPreview . reverse GroupChat g -> [" " <> ttyToGroup g] _ -> [] +viewChatItems :: + (User -> [StyledString] -> [StyledString]) -> + (forall c d. User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString]) -> + User -> + [AChatItem] -> + UTCTime -> + TimeZone -> + [StyledString] +viewChatItems ttyUser unmuted u chatItems ts tz + | length chatItems <= 20 = + concatMap + (\(AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item) + chatItems + | all (\aci -> aChatItemDir aci == MDRcv) chatItems = ttyUser u [sShow (length chatItems) <> " new messages"] + | all (\aci -> aChatItemDir aci == MDSnd) chatItems = ttyUser u [sShow (length chatItems) <> " messages sent"] + | otherwise = ttyUser u [sShow (length chatItems) <> " new messages created"] + viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> viewCI @@ -725,14 +765,10 @@ localTs tz ts = do viewChatItemStatusUpdated :: AChatItem -> CurrentTime -> TimeZone -> Bool -> Bool -> [StyledString] viewChatItemStatusUpdated (AChatItem _ _ chat item@ChatItem {meta = CIMeta {itemStatus}}) ts tz testView showReceipts = case itemStatus of - CISSndRcvd rcptStatus SSPPartial -> - if testView && showReceipts - then prependFirst (viewDeliveryReceiptPartial rcptStatus <> " ") $ viewChatItem chat item False ts tz - else [] - CISSndRcvd rcptStatus SSPComplete -> - if testView && showReceipts - then prependFirst (viewDeliveryReceipt rcptStatus <> " ") $ viewChatItem chat item False ts tz - else [] + CISSndRcvd rcptStatus SSPPartial | testView && showReceipts -> + prependFirst (viewDeliveryReceiptPartial rcptStatus <> " ") $ viewChatItem chat item False ts tz + CISSndRcvd rcptStatus SSPComplete | testView && showReceipts -> + prependFirst (viewDeliveryReceipt rcptStatus <> " ") $ viewChatItem chat item False ts tz _ -> [] viewDeliveryReceiptPartial :: MsgReceiptStatus -> StyledString @@ -796,6 +832,23 @@ viewItemNotChanged (AChatItem _ msgDir _ _) = case msgDir of SMDSnd -> ["message didn't change"] SMDRcv -> [] +viewChatItemsDeleted :: + (forall c d. ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString]) -> + [ChatItemDeletion] -> + Bool -> + Bool -> + UTCTime -> + TimeZone -> + Bool -> + [StyledString] +viewChatItemsDeleted unmuted deletions byUser timed ts tz testView = case deletions of + [ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] -> + unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView + deletions' -> [sShow (length deletions') <> " messages deleted"] + +viewGroupChatItemsDeleted :: GroupInfo -> [ChatItemId] -> Bool -> Maybe GroupMember -> [StyledString] +viewGroupChatItemsDeleted g ciIds byUser member_ = [ttyGroup' g <> ": " <> sShow (length ciIds) <> " messages deleted by " <> if byUser then "user" else "member" <> maybe "" (\m -> " " <> ttyMember m) member_] + viewItemDelete :: ChatInfo c -> ChatItem c d -> Maybe AChatItem -> Bool -> Bool -> CurrentTime -> TimeZone -> Bool -> [StyledString] viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem byUser timed ts tz testView | timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView] @@ -1222,6 +1275,13 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs | localAlias == "" = "" | otherwise = " (alias: " <> plain localAlias <> ")" +viewSentGroupInvitation :: GroupInfo -> Contact -> [StyledString] +viewSentGroupInvitation g c = case contactConn c of + Just Connection {viaGroupLink} + | viaGroupLink -> [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] + | otherwise -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + Nothing -> [] + groupInvitation' :: GroupInfo -> StyledString groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = highlight ("#" <> viewName ldn) 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 206/289] 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 207/289] 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 208/289] 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 209/289] 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 24b0f0290b6218df9c84b148712fd1f2a76041b0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 5 May 2025 11:51:22 +0100 Subject: [PATCH 210/289] core: pass event and response error without dedicated constructor (#5869) * core: pass event and response error without dedicated constructor * ios: WIP * android, desktop: update UI for new API * ios: fix parser * fix showing invalid chats * fix mobile api tests * ios: split ChatResponse to 3 enums, decode API results on the same thread * tweak types * remove throws * rename --- apps/ios/Shared/AppDelegate.swift | 2 +- apps/ios/Shared/Model/AppAPITypes.swift | 636 ++++------ apps/ios/Shared/Model/ChatModel.swift | 13 +- apps/ios/Shared/Model/SimpleXAPI.swift | 817 ++++++------ .../Shared/Views/Call/ActiveCallView.swift | 2 +- .../Chat/ChatItem/CIInvalidJSONView.swift | 7 +- .../Views/ChatList/ChatListNavLink.swift | 6 +- .../Database/DatabaseEncryptionView.swift | 2 +- .../Views/Migration/MigrateFromDevice.swift | 22 +- .../Views/Migration/MigrateToDevice.swift | 18 +- .../Views/Onboarding/CreateProfile.swift | 8 +- .../RemoteAccess/ConnectDesktopView.swift | 12 +- apps/ios/Shared/Views/TerminalView.swift | 42 +- apps/ios/SimpleX NSE/NSEAPITypes.swift | 114 +- .../ios/SimpleX NSE/NotificationService.swift | 85 +- apps/ios/SimpleX SE/ShareAPI.swift | 181 +-- apps/ios/SimpleX SE/ShareModel.swift | 14 +- apps/ios/SimpleXChat/API.swift | 140 +- apps/ios/SimpleXChat/APITypes.swift | 130 +- apps/ios/SimpleXChat/ChatTypes.swift | 8 +- apps/ios/SimpleXChat/CryptoFile.swift | 8 +- apps/ios/SimpleXChat/ErrorAlert.swift | 16 +- .../java/chat/simplex/app/MainActivity.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 14 +- .../chat/simplex/common/model/SimpleXAPI.kt | 1130 ++++++++--------- .../chat/simplex/common/views/TerminalView.kt | 2 +- .../simplex/common/views/call/CallView.kt | 10 +- .../simplex/common/views/chat/ChatView.kt | 10 +- .../views/database/DatabaseEncryptionView.kt | 4 +- .../views/migration/MigrateFromDevice.kt | 26 +- .../common/views/migration/MigrateToDevice.kt | 32 +- .../common/views/remote/ConnectDesktopView.kt | 14 +- .../views/usersettings/PrivacySettings.kt | 4 +- apps/simplex-bot-advanced/Main.hs | 4 +- .../src/Broadcast/Bot.hs | 6 +- apps/simplex-chat/Server.hs | 42 +- .../src/Directory/Events.hs | 14 +- .../src/Directory/Service.hs | 48 +- simplex-chat.cabal | 2 + src/Simplex/Chat/Bot.hs | 14 +- src/Simplex/Chat/Controller.hs | 42 +- src/Simplex/Chat/Core.hs | 25 +- src/Simplex/Chat/Library/Commands.hs | 193 ++- src/Simplex/Chat/Library/Internal.hs | 94 +- src/Simplex/Chat/Library/Subscriber.hs | 82 +- src/Simplex/Chat/Mobile.hs | 22 +- src/Simplex/Chat/Remote.hs | 21 +- src/Simplex/Chat/Remote/Protocol.hs | 48 +- src/Simplex/Chat/Terminal/Input.hs | 15 +- src/Simplex/Chat/Terminal/Main.hs | 15 +- src/Simplex/Chat/Terminal/Output.hs | 29 +- src/Simplex/Chat/View.hs | 27 +- tests/JSONFixtures.hs | 32 +- tests/MobileTests.hs | 2 +- 54 files changed, 2131 insertions(+), 2177 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index ad8c661e1c..3f6998c9ec 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -54,7 +54,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { try await apiVerifyToken(token: token, nonce: nonce, code: verification) m.tokenStatus = .active } catch { - if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr { + if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr { m.tokenStatus = .expired } logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 37d016e93d..d5a067a2b8 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -580,8 +580,8 @@ enum ChatCommand: ChatCmdProtocol { } } -enum ChatResponse: Decodable, Error, ChatRespProtocol { - case response(type: String, json: String) +// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. +enum ChatResponse0: Decodable, ChatAPIResult { case activeUser(user: User) case usersList(users: [UserInfo]) case chatStarted @@ -611,6 +611,95 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64]) + + var responseType: String { + switch self { + case .activeUser: "activeUser" + case .usersList: "usersList" + case .chatStarted: "chatStarted" + case .chatRunning: "chatRunning" + case .chatStopped: "chatStopped" + case .apiChats: "apiChats" + case .apiChat: "apiChat" + case .chatTags: "chatTags" + case .chatItemInfo: "chatItemInfo" + case .serverTestResult: "serverTestResult" + case .serverOperatorConditions: "serverOperators" + case .userServers: "userServers" + case .userServersValidation: "userServersValidation" + case .usageConditions: "usageConditions" + case .chatItemTTL: "chatItemTTL" + case .networkConfig: "networkConfig" + case .contactInfo: "contactInfo" + case .groupMemberInfo: "groupMemberInfo" + case .queueInfo: "queueInfo" + case .contactSwitchStarted: "contactSwitchStarted" + case .groupMemberSwitchStarted: "groupMemberSwitchStarted" + case .contactSwitchAborted: "contactSwitchAborted" + case .groupMemberSwitchAborted: "groupMemberSwitchAborted" + case .contactRatchetSyncStarted: "contactRatchetSyncStarted" + case .groupMemberRatchetSyncStarted: "groupMemberRatchetSyncStarted" + case .contactCode: "contactCode" + case .groupMemberCode: "groupMemberCode" + case .connectionVerified: "connectionVerified" + case .tagsUpdated: "tagsUpdated" + } + } + + var details: String { + switch self { + case let .activeUser(user): return String(describing: user) + case let .usersList(users): return String(describing: users) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case .chatStopped: return noDetails + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") + case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") + case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") + case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" + case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") + case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" + case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) + case let .networkConfig(networkConfig): return String(describing: networkConfig) + case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") + case let .queueInfo(u, rcvMsgInfo, queueInfo): + let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } + return withUser(u, "rcvMsgInfo: \(msgInfo)\nqueueInfo: \(encodeJSON(queueInfo))") + case let .contactSwitchStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") + case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") + case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") + case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") + case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") + case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))") + } + } + + static func fallbackResult(_ type: String, _ json: NSDictionary) -> ChatResponse0? { + if type == "apiChats" { + if let r = parseApiChats(json) { + return .apiChats(user: r.user, chats: r.chats) + } + } else if type == "apiChat" { + if let jApiChat = json["apiChat"] as? NSDictionary, + let user: UserRef = try? decodeObject(jApiChat["user"] as Any), + let jChat = jApiChat["chat"] as? NSDictionary, + let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) { + return .apiChat(user: user, chat: chat, navInfo: navInfo) + } + } + return nil + } +} + +enum ChatResponse1: Decodable, ChatAPIResult { case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) @@ -620,6 +709,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) + case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -644,6 +735,95 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case reactionMembers(user: UserRef, memberReactions: [MemberReaction]) case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool) case contactsList(user: UserRef, contacts: [Contact]) + + var responseType: String { + switch self { + case .invitation: "invitation" + case .connectionIncognitoUpdated: "connectionIncognitoUpdated" + case .connectionUserChanged: "connectionUserChanged" + case .connectionPlan: "connectionPlan" + case .sentConfirmation: "sentConfirmation" + case .sentInvitation: "sentInvitation" + case .sentInvitationToContact: "sentInvitationToContact" + case .contactAlreadyExists: "contactAlreadyExists" + case .contactDeleted: "contactDeleted" + case .contactConnectionDeleted: "contactConnectionDeleted" + case .groupDeletedUser: "groupDeletedUser" + case .chatCleared: "chatCleared" + case .userProfileNoChange: "userProfileNoChange" + case .userProfileUpdated: "userProfileUpdated" + case .userPrivacy: "userPrivacy" + case .contactAliasUpdated: "contactAliasUpdated" + case .groupAliasUpdated: "groupAliasUpdated" + case .connectionAliasUpdated: "connectionAliasUpdated" + case .contactPrefsUpdated: "contactPrefsUpdated" + case .userContactLink: "userContactLink" + case .userContactLinkUpdated: "userContactLinkUpdated" + case .userContactLinkCreated: "userContactLinkCreated" + case .userContactLinkDeleted: "userContactLinkDeleted" + case .acceptingContactRequest: "acceptingContactRequest" + case .contactRequestRejected: "contactRequestRejected" + case .networkStatuses: "networkStatuses" + case .newChatItems: "newChatItems" + case .groupChatItemsDeleted: "groupChatItemsDeleted" + case .forwardPlan: "forwardPlan" + case .chatItemUpdated: "chatItemUpdated" + case .chatItemNotChanged: "chatItemNotChanged" + case .chatItemReaction: "chatItemReaction" + case .reactionMembers: "reactionMembers" + case .chatItemsDeleted: "chatItemsDeleted" + case .contactsList: "contactsList" + } + } + + var details: String { + switch self { + 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 .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) + case .userProfileNoChange: return noDetails + case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) + case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) + case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") + case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) + case .userContactLinkDeleted: return noDetails + case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) + case .contactRequestRejected: return noDetails + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .newChatItems(u, chatItems): + let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") + return withUser(u, itemsString) + case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): + return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") + case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") + case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))") + case let .chatItemsDeleted(u, items, byUser): + let itemsString = items.map { item in + "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") + return withUser(u, itemsString + "\nbyUser: \(byUser)") + case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) + case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)") + case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))") + case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") + case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) + case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) + case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) + } + } +} + +enum ChatResponse2: Decodable, ChatAPIResult { // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) @@ -651,7 +831,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: SimpleXChat.Group) - case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) 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) @@ -678,7 +857,6 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) case ntfConns(ntfConns: [NtfConn]) case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) - case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) // remote desktop responses case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) @@ -689,306 +867,100 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case agentSubsTotal(user: UserRef, subsTotal: SMPServerSubs, hasSession: Bool) case agentServersSummary(user: UserRef, serversSummary: PresentedServersSummary) case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) - case chatCmdError(user_: UserRef?, chatError: ChatError) case archiveExported(archiveErrors: [ArchiveError]) case archiveImported(archiveErrors: [ArchiveError]) case appSettings(appSettings: AppSettings) var responseType: String { - get { - switch self { - case let .response(type, _): return "* \(type)" - case .activeUser: return "activeUser" - case .usersList: return "usersList" - case .chatStarted: return "chatStarted" - case .chatRunning: return "chatRunning" - case .chatStopped: return "chatStopped" - case .apiChats: return "apiChats" - case .apiChat: return "apiChat" - case .chatTags: return "chatTags" - case .chatItemInfo: return "chatItemInfo" - case .serverTestResult: return "serverTestResult" - case .serverOperatorConditions: return "serverOperators" - case .userServers: return "userServers" - case .userServersValidation: return "userServersValidation" - case .usageConditions: return "usageConditions" - case .chatItemTTL: return "chatItemTTL" - case .networkConfig: return "networkConfig" - case .contactInfo: return "contactInfo" - case .groupMemberInfo: return "groupMemberInfo" - case .queueInfo: return "queueInfo" - case .contactSwitchStarted: return "contactSwitchStarted" - case .groupMemberSwitchStarted: return "groupMemberSwitchStarted" - case .contactSwitchAborted: return "contactSwitchAborted" - case .groupMemberSwitchAborted: return "groupMemberSwitchAborted" - case .contactRatchetSyncStarted: return "contactRatchetSyncStarted" - case .groupMemberRatchetSyncStarted: return "groupMemberRatchetSyncStarted" - case .contactCode: return "contactCode" - case .groupMemberCode: return "groupMemberCode" - case .connectionVerified: return "connectionVerified" - case .tagsUpdated: return "tagsUpdated" - case .invitation: return "invitation" - case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" - case .connectionUserChanged: return "connectionUserChanged" - case .connectionPlan: return "connectionPlan" - case .sentConfirmation: return "sentConfirmation" - case .sentInvitation: return "sentInvitation" - case .sentInvitationToContact: return "sentInvitationToContact" - case .contactAlreadyExists: return "contactAlreadyExists" - case .contactDeleted: return "contactDeleted" - case .chatCleared: return "chatCleared" - case .userProfileNoChange: return "userProfileNoChange" - case .userProfileUpdated: return "userProfileUpdated" - case .userPrivacy: return "userPrivacy" - case .contactAliasUpdated: return "contactAliasUpdated" - case .groupAliasUpdated: return "groupAliasUpdated" - case .connectionAliasUpdated: return "connectionAliasUpdated" - case .contactPrefsUpdated: return "contactPrefsUpdated" - case .userContactLink: return "userContactLink" - case .userContactLinkUpdated: return "userContactLinkUpdated" - case .userContactLinkCreated: return "userContactLinkCreated" - case .userContactLinkDeleted: return "userContactLinkDeleted" - case .acceptingContactRequest: return "acceptingContactRequest" - case .contactRequestRejected: return "contactRequestRejected" - case .networkStatuses: return "networkStatuses" - case .newChatItems: return "newChatItems" - case .groupChatItemsDeleted: return "groupChatItemsDeleted" - case .forwardPlan: return "forwardPlan" - case .chatItemUpdated: return "chatItemUpdated" - case .chatItemNotChanged: return "chatItemNotChanged" - case .chatItemReaction: return "chatItemReaction" - case .reactionMembers: return "reactionMembers" - case .chatItemsDeleted: return "chatItemsDeleted" - case .contactsList: return "contactsList" - case .groupCreated: return "groupCreated" - case .sentGroupInvitation: return "sentGroupInvitation" - case .userAcceptedGroupSent: return "userAcceptedGroupSent" - case .userDeletedMembers: return "userDeletedMembers" - case .leftMemberUser: return "leftMemberUser" - case .groupMembers: return "groupMembers" - case .groupDeletedUser: return "groupDeletedUser" - case .membersRoleUser: return "membersRoleUser" - case .membersBlockedForAllUser: return "membersBlockedForAllUser" - case .groupUpdated: return "groupUpdated" - case .groupLinkCreated: return "groupLinkCreated" - case .groupLink: return "groupLink" - case .groupLinkDeleted: return "groupLinkDeleted" - case .newMemberContact: return "newMemberContact" - case .newMemberContactSentInv: return "newMemberContactSentInv" - case .rcvFileAccepted: return "rcvFileAccepted" - case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" - case .standaloneFileInfo: return "standaloneFileInfo" - case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated" - case .rcvFileCancelled: return "rcvFileCancelled" - case .sndFileCancelled: return "sndFileCancelled" - case .sndStandaloneFileCreated: return "sndStandaloneFileCreated" - case .sndFileStartXFTP: return "sndFileStartXFTP" - case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" - case .callInvitations: return "callInvitations" - case .ntfTokenStatus: return "ntfTokenStatus" - case .ntfToken: return "ntfToken" - case .ntfConns: return "ntfConns" - case .connNtfMessages: return "connNtfMessages" - case .contactConnectionDeleted: return "contactConnectionDeleted" - case .remoteCtrlList: return "remoteCtrlList" - case .remoteCtrlConnecting: return "remoteCtrlConnecting" - case .remoteCtrlConnected: return "remoteCtrlConnected" - case .versionInfo: return "versionInfo" - case .cmdOk: return "cmdOk" - case .agentSubsTotal: return "agentSubsTotal" - case .agentServersSummary: return "agentServersSummary" - case .agentSubsSummary: return "agentSubsSummary" - case .chatCmdError: return "chatCmdError" - case .archiveExported: return "archiveExported" - case .archiveImported: return "archiveImported" - case .appSettings: return "appSettings" - } + switch self { + case .groupCreated: "groupCreated" + case .sentGroupInvitation: "sentGroupInvitation" + case .userAcceptedGroupSent: "userAcceptedGroupSent" + case .userDeletedMembers: "userDeletedMembers" + case .leftMemberUser: "leftMemberUser" + case .groupMembers: "groupMembers" + case .membersRoleUser: "membersRoleUser" + case .membersBlockedForAllUser: "membersBlockedForAllUser" + case .groupUpdated: "groupUpdated" + case .groupLinkCreated: "groupLinkCreated" + case .groupLink: "groupLink" + case .groupLinkDeleted: "groupLinkDeleted" + case .newMemberContact: "newMemberContact" + case .newMemberContactSentInv: "newMemberContactSentInv" + case .rcvFileAccepted: "rcvFileAccepted" + case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" + case .standaloneFileInfo: "standaloneFileInfo" + case .rcvStandaloneFileCreated: "rcvStandaloneFileCreated" + case .rcvFileCancelled: "rcvFileCancelled" + case .sndFileCancelled: "sndFileCancelled" + case .sndStandaloneFileCreated: "sndStandaloneFileCreated" + case .sndFileStartXFTP: "sndFileStartXFTP" + case .sndFileCancelledXFTP: "sndFileCancelledXFTP" + case .callInvitations: "callInvitations" + case .ntfTokenStatus: "ntfTokenStatus" + case .ntfToken: "ntfToken" + case .ntfConns: "ntfConns" + case .connNtfMessages: "connNtfMessages" + case .remoteCtrlList: "remoteCtrlList" + case .remoteCtrlConnecting: "remoteCtrlConnecting" + case .remoteCtrlConnected: "remoteCtrlConnected" + case .versionInfo: "versionInfo" + case .cmdOk: "cmdOk" + case .agentSubsTotal: "agentSubsTotal" + case .agentServersSummary: "agentServersSummary" + case .agentSubsSummary: "agentSubsSummary" + case .archiveExported: "archiveExported" + case .archiveImported: "archiveImported" + case .appSettings: "appSettings" } } var details: String { - get { - switch self { - case let .response(_, json): return json - case let .activeUser(user): return String(describing: user) - case let .usersList(users): return String(describing: users) - case .chatStarted: return noDetails - case .chatRunning: return noDetails - case .chatStopped: return noDetails - case let .apiChats(u, chats): return withUser(u, String(describing: chats)) - case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))") - case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") - case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") - case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") - case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" - case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") - case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") - case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" - case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) - case let .networkConfig(networkConfig): return String(describing: networkConfig) - case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") - case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") - case let .queueInfo(u, rcvMsgInfo, queueInfo): - let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } - return withUser(u, "rcvMsgInfo: \(msgInfo)\nqueueInfo: \(encodeJSON(queueInfo))") - case let .contactSwitchStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberSwitchStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactSwitchAborted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberSwitchAborted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactRatchetSyncStarted(u, contact, connectionStats): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))") - case let .groupMemberRatchetSyncStarted(u, groupInfo, member, connectionStats): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats: \(String(describing: connectionStats))") - case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") - case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") - case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") - case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))") - case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)") - case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) - case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))") - case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") - case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) - case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) - case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) - case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) - case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) - 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)) - case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) - case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) - case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) - case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") - case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) - case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) - case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) - case .userContactLinkDeleted: return noDetails - case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) - case .contactRequestRejected: return noDetails - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) - case let .newChatItems(u, chatItems): - let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") - return withUser(u, itemsString) - case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): - return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") - case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") - case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))") - case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))") - case let .chatItemsDeleted(u, items, byUser): - let itemsString = items.map { item in - "deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n") - return withUser(u, itemsString + "\nbyUser: \(byUser)") - case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) - case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") - case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") - case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") - case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .groupMembers(u, group): return withUser(u, String(describing: group)) - case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) - 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)) - case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") - case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") - case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) - case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") - case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") - case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) - case .rcvFileAcceptedSndCancelled: return noDetails - case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) - case .rcvStandaloneFileCreated: return noDetails - case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) - case .sndStandaloneFileCreated: return noDetails - case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .callInvitations(invs): return String(describing: invs) - case let .ntfTokenStatus(status): return String(describing: status) - case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" - case let .ntfConns(ntfConns): return String(describing: ntfConns) - case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" - case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) - case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" - case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) - case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" - case .cmdOk: return noDetails - case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)") - case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) - case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) - case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) - case let .archiveExported(archiveErrors): return String(describing: archiveErrors) - case let .archiveImported(archiveErrors): return String(describing: archiveErrors) - case let .appSettings(appSettings): return String(describing: appSettings) - } - } - } - - private var noDetails: String { get { "\(responseType): no details" } } - - static func chatResponse(_ s: String) -> ChatResponse { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = try callWithLargeStack { - try jsonDecoder.decode(APIResponse.self, from: d) - } - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "apiChats" { - if let r = parseApiChats(jResp) { - return .apiChats(user: r.user, chats: r.chats) - } - } else if type == "apiChat" { - if let jApiChat = jResp["apiChat"] as? NSDictionary, - let user: UserRef = try? decodeObject(jApiChat["user"] as Any), - let jChat = jApiChat["chat"] as? NSDictionary, - let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) { - return .apiChat(user: user, chat: chat, navInfo: navInfo) - } - } else if type == "chatCmdError" { - if let jError = jResp["chatCmdError"] as? NSDictionary { - return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return ChatResponse.response(type: type ?? "invalid", json: json ?? s) - } - - var chatError: ChatError? { switch self { - case let .chatCmdError(_, error): error - default: nil - } - } - - var chatErrorType: ChatErrorType? { - switch self { - case let .chatCmdError(_, .error(error)): error - default: nil + case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") + case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupMembers(u, group): return withUser(u, String(describing: group)) + 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)) + case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") + case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") + case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) + case .rcvFileAcceptedSndCancelled: return noDetails + case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) + case .rcvStandaloneFileCreated: return noDetails + case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) + case .sndStandaloneFileCreated: return noDetails + case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .callInvitations(invs): return String(describing: invs) + case let .ntfTokenStatus(status): return String(describing: status) + case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)" + case let .ntfConns(ntfConns): return String(describing: ntfConns) + case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" + case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) + case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" + case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) + case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" + case .cmdOk: return noDetails + case let .agentSubsTotal(u, subsTotal, hasSession): return withUser(u, "subsTotal: \(String(describing: subsTotal))\nhasSession: \(hasSession)") + case let .agentServersSummary(u, serversSummary): return withUser(u, String(describing: serversSummary)) + case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) + case let .archiveExported(archiveErrors): return String(describing: archiveErrors) + case let .archiveImported(archiveErrors): return String(describing: archiveErrors) + case let .appSettings(appSettings): return String(describing: appSettings) } } } -enum ChatEvent: Decodable, ChatEventProtocol { - case event(type: String, json: String) +enum ChatEvent: Decodable, ChatAPIResult { case chatSuspended case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) case groupMemberSwitch(user: UserRef, groupInfo: GroupInfo, member: GroupMember, switchProgress: SwitchProgress) @@ -1063,11 +1035,9 @@ enum ChatEvent: Decodable, ChatEventProtocol { case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) // pq case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) - case chatError(user_: UserRef?, chatError: ChatError) - var eventType: String { + var responseType: String { switch self { - case let .event(type, _): "* \(type)" case .chatSuspended: "chatSuspended" case .contactSwitch: "contactSwitch" case .groupMemberSwitch: "groupMemberSwitch" @@ -1135,13 +1105,11 @@ enum ChatEvent: Decodable, ChatEventProtocol { case .remoteCtrlConnected: "remoteCtrlConnected" case .remoteCtrlStopped: "remoteCtrlStopped" case .contactPQEnabled: "contactPQEnabled" - case .chatError: "chatError" } } var details: String { switch self { - case let .event(_, json): return json case .chatSuspended: return noDetails case let .contactSwitch(u, contact, switchProgress): return withUser(u, "contact: \(String(describing: contact))\nswitchProgress: \(String(describing: switchProgress))") case let .groupMemberSwitch(u, groupInfo, member, switchProgress): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nswitchProgress: \(String(describing: switchProgress))") @@ -1217,84 +1185,8 @@ enum ChatEvent: Decodable, ChatEventProtocol { case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } - } - - private var noDetails: String { "\(eventType): no details" } - - static func chatEvent(_ s: String) -> ChatEvent { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = // try callWithLargeStack { - try jsonDecoder.decode(APIResponse.self, from: d) -// } - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return ChatEvent.event(type: type ?? "invalid", json: json ?? s) - } - - var chatError: ChatError? { - switch self { - case let .chatError(_, error): error - default: nil - } - } - - var chatErrorType: ChatErrorType? { - switch self { - case let .chatError(_, .error(error)): error - default: nil - } - } -} - -private let largeStackSize: Int = 2 * 1024 * 1024 - -private func callWithLargeStack(_ f: @escaping () throws -> T) throws -> T { - let semaphore = DispatchSemaphore(value: 0) - var result: Result? - let thread = Thread { - do { - result = .success(try f()) - } catch { - result = .failure(error) - } - semaphore.signal() - } - - thread.stackSize = largeStackSize - thread.qualityOfService = Thread.current.qualityOfService - thread.start() - - semaphore.wait() - - switch result! { - case let .success(r): return r - case let .failure(e): throw e - } + } } struct NewUser: Encodable { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c73cb32c58..63d8b38e3c 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -30,9 +30,18 @@ actor TerminalItems { } } - func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async { + func addCommand(_ start: Date, _ cmd: ChatCommand, _ res: APIResult) async { await add(.cmd(start, cmd)) - await add(.resp(.now, resp)) + await addResult(res) + } + + func addResult(_ res: APIResult) async { + let item: TerminalItem = switch res { + case let .result(r): .res(.now, r) + case let .error(e): .err(.now, e) + case let .invalid(type, json): .bad(.now, type, json) + } + await add(item) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a6b9e719c7..d92411decd 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -19,36 +19,34 @@ private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) - case resp(Date, ChatResponse) - case event(Date, ChatEvent) + case res(Date, ChatAPIResult) + case err(Date, ChatError) + case bad(Date, String, Data?) var id: Date { - get { - switch self { - case let .cmd(d, _): return d - case let .resp(d, _): return d - case let .event(d, _): return d - } + switch self { + case let .cmd(d, _): d + case let .res(d, _): d + case let .err(d, _): d + case let .bad(d, _, _): d } } var label: String { - get { - switch self { - case let .cmd(_, cmd): return "> \(cmd.cmdString.prefix(30))" - case let .resp(_, resp): return "< \(resp.responseType)" - case let .event(_, evt): return "< \(evt.eventType)" - } + switch self { + case let .cmd(_, cmd): "> \(cmd.cmdString.prefix(30))" + case let .res(_, res): "< \(res.responseType)" + case let .err(_, err): "< error \(err.errorType)" + case let .bad(_, type, _): "< * \(type)" } } var details: String { - get { - switch self { - case let .cmd(_, cmd): return cmd.cmdString - case let .resp(_, resp): return resp.details - case let .event(_, evt): return evt.details - } + switch self { + case let .cmd(_, cmd): cmd.cmdString + case let .res(_, res): res.details + case let .err(_, err): String(describing: err) + case let .bad(_, _, json): dataToString(json) } } } @@ -90,18 +88,24 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } -func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse { +@inline(__always) +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R { + let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) + return try apiResult(res) +} + +func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) -> APIResult { if log { logger.debug("chatSendCmd \(cmd.cmdType)") } let start = Date.now - let resp: ChatResponse = bgTask + let resp: APIResult = bgTask ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) } : sendSimpleXCmd(cmd, ctrl) if log { logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") - if case let .response(_, json) = resp { - logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") + if case let .invalid(_, json) = resp { + logger.debug("chatSendCmd \(cmd.cmdType) response: \(dataToString(json))") } Task { await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp) @@ -110,16 +114,32 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = return resp } -func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse { +@inline(__always) +func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R { + let res: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) + return try apiResult(res) +} + +@inline(__always) +func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async -> APIResult { await withCheckedContinuation { cont in - cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log)) + cont.resume(returning: chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)) } } -func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatEvent? { +@inline(__always) +func apiResult(_ res: APIResult) throws -> R { + switch res { + case let .result(r): return r + case let .error(e): throw e + case let .invalid(type, _): throw ChatError.unexpectedResult(type: type) + } +} + +func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult? { await withCheckedContinuation { cont in - _ = withBGTask(bgDelay: msgDelay) { () -> ChatEvent? in - let evt: ChatEvent? = recvSimpleXMsg(ctrl) + _ = withBGTask(bgDelay: msgDelay) { () -> APIResult? in + let evt: APIResult? = recvSimpleXMsg(ctrl) cont.resume(returning: evt) return evt } @@ -127,18 +147,18 @@ func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatEvent? { } func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { - let r: ChatResponse = chatSendCmdSync(.showActiveUser, ctrl) + let r: APIResult = chatApiSendCmdSync(.showActiveUser, ctrl: ctrl) switch r { - case let .activeUser(user): return user - case .chatCmdError(_, .error(.noActiveUser)): return nil - default: throw r + case let .result(.activeUser(user)): return user + case .error(.error(.noActiveUser)): return nil + default: throw r.unexpected } } func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { - let r: ChatResponse = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl) + let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl: ctrl) if case let .activeUser(user) = r { return user } - throw r + throw r.unexpected } func listUsers() throws -> [UserInfo] { @@ -149,41 +169,35 @@ func listUsersAsync() async throws -> [UserInfo] { return try listUsersResponse(await chatSendCmd(.listUsers)) } -private func listUsersResponse(_ r: ChatResponse) throws -> [UserInfo] { +private func listUsersResponse(_ r: ChatResponse0) throws -> [UserInfo] { if case let .usersList(users) = r { return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } } - throw r + throw r.unexpected } func apiSetActiveUser(_ userId: Int64, viewPwd: String?) throws -> User { - let r = chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) if case let .activeUser(user) = r { return user } - throw r + throw r.unexpected } func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> User { - let r = await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) + let r: ChatResponse0 = try await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) if case let .activeUser(user) = r { return user } - throw r + throw r.unexpected } func apiSetAllContactReceipts(enable: Bool) async throws { - let r = await chatSendCmd(.setAllContactReceipts(enable: enable)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.setAllContactReceipts(enable: enable)) } func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws { - let r = await chatSendCmd(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) } func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws { - let r = await chatSendCmd(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) } func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User { @@ -203,90 +217,88 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User { } func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User { - let r: ChatResponse = await chatSendCmd(cmd) + let r: ChatResponse1 = try await chatSendCmd(cmd) if case let .userPrivacy(_, updatedUser) = r { return updatedUser } - throw r + throw r.unexpected } func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws { - let r: ChatResponse = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd)) } func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool { - let r: ChatResponse = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl) + let r: ChatResponse0 = try chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl: ctrl) switch r { case .chatStarted: return true case .chatRunning: return false - default: throw r + default: throw r.unexpected } } func apiCheckChatRunning() throws -> Bool { - let r = chatSendCmdSync(.checkChatRunning) + let r: ChatResponse0 = try chatSendCmdSync(.checkChatRunning) switch r { case .chatRunning: return true case .chatStopped: return false - default: throw r + default: throw r.unexpected } } func apiStopChat() async throws { - let r = await chatSendCmd(.apiStopChat) + let r: ChatResponse0 = try await chatSendCmd(.apiStopChat) switch r { case .chatStopped: return - default: throw r + default: throw r.unexpected } } func apiActivateChat() { chatReopenStore() - let r = chatSendCmdSync(.apiActivateChat(restoreChat: true)) - if case .cmdOk = r { return } - logger.error("apiActivateChat error: \(String(describing: r))") + do { + try sendCommandOkRespSync(.apiActivateChat(restoreChat: true)) + } catch { + logger.error("apiActivateChat error: \(responseError(error))") + } } func apiSuspendChat(timeoutMicroseconds: Int) { - let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) - if case .cmdOk = r { return } - logger.error("apiSuspendChat error: \(String(describing: r))") + do { + try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + } catch { + logger.error("apiSuspendChat error: \(responseError(error))") + } } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { - let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl) + let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl) if case .cmdOk = r { return } - throw r + throw r.unexpected } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) - if case .cmdOk = r { return } - throw r + try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable)) } func apiSaveAppSettings(settings: AppSettings) throws { - let r = chatSendCmdSync(.apiSaveSettings(settings: settings)) - if case .cmdOk = r { return } - throw r + try sendCommandOkRespSync(.apiSaveSettings(settings: settings)) } func apiGetAppSettings(settings: AppSettings) throws -> AppSettings { - let r = chatSendCmdSync(.apiGetSettings(settings: settings)) + let r: ChatResponse2 = try chatSendCmdSync(.apiGetSettings(settings: settings)) if case let .appSettings(settings) = r { return settings } - throw r + throw r.unexpected } func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { - let r = await chatSendCmd(.apiExportArchive(config: config)) + let r: ChatResponse2 = try await chatSendCmd(.apiExportArchive(config: config)) if case let .archiveExported(archiveErrors) = r { return archiveErrors } - throw r + throw r.unexpected } func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { - let r = await chatSendCmd(.apiImportArchive(config: config)) + let r: ChatResponse2 = try await chatSendCmd(.apiImportArchive(config: config)) if case let .archiveImported(archiveErrors) = r { return archiveErrors } - throw r + throw r.unexpected } func apiDeleteStorage() async throws { @@ -297,8 +309,8 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey))) } -func testStorageEncryption(key: String, _ ctrl: chat_ctrl? = nil) async throws { - try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl) +func testStorageEncryption(key: String, ctrl: chat_ctrl? = nil) async throws { + try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl: ctrl) } func apiGetChats() throws -> [ChatData] { @@ -311,31 +323,31 @@ func apiGetChatsAsync() async throws -> [ChatData] { return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId))) } -private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] { +private func apiChatsResponse(_ r: ChatResponse0) throws -> [ChatData] { if case let .apiChats(_, chats) = r { return chats } - throw r + throw r.unexpected } func apiGetChatTags() throws -> [ChatTag] { let userId = try currentUserId("apiGetChatTags") - let r = chatSendCmdSync(.apiGetChatTags(userId: userId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiGetChatTags(userId: userId)) if case let .chatTags(_, tags) = r { return tags } - throw r + throw r.unexpected } func apiGetChatTagsAsync() async throws -> [ChatTag] { let userId = try currentUserId("apiGetChatTags") - let r = await chatSendCmd(.apiGetChatTags(userId: userId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetChatTags(userId: userId)) if case let .chatTags(_, tags) = r { return tags } - throw r + throw r.unexpected } 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)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search)) if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) } - throw r + throw r.unexpected } func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { @@ -356,15 +368,15 @@ func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID } func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { - let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId)) if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo } - throw r + throw r.unexpected } func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { - let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds)) + let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds)) if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) } - throw r + throw r.unexpected } func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { @@ -373,19 +385,19 @@ func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: Ch } func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] { - let r = await chatSendCmd(.apiCreateChatTag(tag: tag)) + let r: ChatResponse0 = try await chatSendCmd(.apiCreateChatTag(tag: tag)) if case let .chatTags(_, userTags) = r { return userTags } - throw r + throw r.unexpected } func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) { - let r = await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds)) + let r: ChatResponse0 = try await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds)) if case let .tagsUpdated(_, userTags, chatTags) = r { return (userTags, chatTags) } - throw r + throw r.unexpected } func apiDeleteChatTag(tagId: Int64) async throws { @@ -407,7 +419,7 @@ func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? { let chatModel = ChatModel.shared - let r: ChatResponse + let r: APIResult if toChatType == .direct { var cItem: ChatItem? = nil let endTask = beginBGTask({ @@ -417,8 +429,8 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async } } }) - r = await chatSendCmd(cmd, bgTask: false) - if case let .newChatItems(_, aChatItems) = r { + r = await chatApiSendCmd(cmd, bgTask: false) + if case let .result(.newChatItems(_, aChatItems)) = r { let cItems = aChatItems.map { $0.chatItem } if let cItemLast = cItems.last { cItem = cItemLast @@ -429,40 +441,40 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async if let networkErrorAlert = networkErrorAlert(r) { AlertManager.shared.showAlert(networkErrorAlert) } else { - sendMessageErrorAlert(r) + sendMessageErrorAlert(r.unexpected) } endTask() return nil } else { - r = await chatSendCmd(cmd, bgDelay: msgDelay) - if case let .newChatItems(_, aChatItems) = r { + r = await chatApiSendCmd(cmd, bgDelay: msgDelay) + if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } } - sendMessageErrorAlert(r) + sendMessageErrorAlert(r.unexpected) return nil } } func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages)) - if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } - createChatItemsErrorAlert(r) + let r: APIResult = await chatApiSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages)) + if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } } + createChatItemsErrorAlert(r.unexpected) return nil } func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? { - let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) - if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } + let r: APIResult = await chatApiSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) + if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } } logger.error("apiReportMessage error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error creating report", - message: "Error: \(responseError(r))" + message: "Error: \(responseError(r.unexpected))" ) return nil } -private func sendMessageErrorAlert(_ r: ChatResponse) { +private func sendMessageErrorAlert(_ r: ChatError) { logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error sending message", @@ -470,7 +482,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) { ) } -private func createChatItemsErrorAlert(_ r: ChatResponse) { +private func createChatItemsErrorAlert(_ r: ChatError) { logger.error("apiCreateChatItems error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error creating message", @@ -479,56 +491,56 @@ 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) + let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay) switch r { case let .chatItemUpdated(_, aChatItem): return aChatItem.chatItem case let .chatItemNotChanged(_, aChatItem): return aChatItem.chatItem - default: throw r + default: throw r.unexpected } } 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) + let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay) if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem } - throw r + throw r.unexpected } func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] { let userId = try currentUserId("apiGetReactionMemebers") - let r = await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction )) + let r: ChatResponse1 = try await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction )) if case let .reactionMembers(_, memberReactions) = r { return memberReactions } - throw r + throw r.unexpected } 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) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay) if case let .chatItemsDeleted(_, items, _) = r { return items } - throw r + throw r.unexpected } func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] { - let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay) if case let .chatItemsDeleted(_, items, _) = r { return items } - throw r + throw r.unexpected } -func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse { - let r = await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay) +func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse1 { + let r: ChatResponse1 = try await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay) if case .groupChatItemsDeleted = r { return r } - throw r + throw r.unexpected } func apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { - let r = await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay) if case let .chatItemsDeleted(_, chatItemDeletions, _) = r { return chatItemDeletions } - throw r + throw r.unexpected } func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) { - let r = chatSendCmdSync(.apiGetNtfToken) + let r: APIResult = chatApiSendCmdSync(.apiGetNtfToken) switch r { - case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer) - case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil) + case let .result(.ntfToken(token, status, ntfMode, ntfServer)): return (token, status, ntfMode, ntfServer) + case .error(.errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil) default: logger.debug("apiGetNtfToken response: \(String(describing: r))") return (nil, nil, .off, nil) @@ -536,9 +548,9 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String } func apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) async throws -> NtfTknStatus { - let r = await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode)) + let r: ChatResponse2 = try await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode)) if case let .ntfTokenStatus(status) = r { return status } - throw r + throw r.unexpected } func registerToken(token: DeviceToken) { @@ -601,9 +613,9 @@ func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throw } func apiCheckToken(token: DeviceToken) async throws -> NtfTknStatus { - let r = await chatSendCmd(.apiCheckToken(token: token)) + let r: ChatResponse2 = try await chatSendCmd(.apiCheckToken(token: token)) if case let .ntfTokenStatus(status) = r { return status } - throw r + throw r.unexpected } func apiDeleteToken(token: DeviceToken) async throws { @@ -612,80 +624,80 @@ func apiDeleteToken(token: DeviceToken) async throws { func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> { let userId = try currentUserId("testProtoServer") - let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server)) + let r: ChatResponse0 = try await chatSendCmd(.apiTestProtoServer(userId: userId, server: server)) if case let .serverTestResult(_, _, testFailure) = r { if let t = testFailure { return .failure(t) } return .success(()) } - throw r + throw r.unexpected } func getServerOperators() async throws -> ServerOperatorConditions { - let r = await chatSendCmd(.apiGetServerOperators) + let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("getServerOperators error: \(String(describing: r))") - throw r + throw r.unexpected } func getServerOperatorsSync() throws -> ServerOperatorConditions { - let r = chatSendCmdSync(.apiGetServerOperators) + let r: ChatResponse0 = try chatSendCmdSync(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("getServerOperators error: \(String(describing: r))") - throw r + throw r.unexpected } func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions { - let r = await chatSendCmd(.apiSetServerOperators(operators: operators)) + let r: ChatResponse0 = try await chatSendCmd(.apiSetServerOperators(operators: operators)) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("setServerOperators error: \(String(describing: r))") - throw r + throw r.unexpected } func getUserServers() async throws -> [UserOperatorServers] { let userId = try currentUserId("getUserServers") - let r = await chatSendCmd(.apiGetUserServers(userId: userId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetUserServers(userId: userId)) if case let .userServers(_, userServers) = r { return userServers } logger.error("getUserServers error: \(String(describing: r))") - throw r + throw r.unexpected } func setUserServers(userServers: [UserOperatorServers]) async throws { let userId = try currentUserId("setUserServers") - let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers)) + let r: ChatResponse2 = try await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers)) if case .cmdOk = r { return } logger.error("setUserServers error: \(String(describing: r))") - throw r + throw r.unexpected } func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] { let userId = try currentUserId("validateServers") - let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) + let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) if case let .userServersValidation(_, serverErrors) = r { return serverErrors } logger.error("validateServers error: \(String(describing: r))") - throw r + throw r.unexpected } func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) { - let r = await chatSendCmd(.apiGetUsageConditions) + let r: ChatResponse0 = try await chatSendCmd(.apiGetUsageConditions) if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) } logger.error("getUsageConditions error: \(String(describing: r))") - throw r + throw r.unexpected } func setConditionsNotified(conditionsId: Int64) async throws { - let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId)) + let r: ChatResponse2 = try await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId)) if case .cmdOk = r { return } logger.error("setConditionsNotified error: \(String(describing: r))") - throw r + throw r.unexpected } func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions { - let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)) + let r: ChatResponse0 = try await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)) if case let .serverOperatorConditions(conditions) = r { return conditions } logger.error("acceptConditions error: \(String(describing: r))") - throw r + throw r.unexpected } func getChatItemTTL() throws -> ChatItemTTL { @@ -698,7 +710,7 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL { return try chatItemTTLResponse(await chatSendCmd(.apiGetChatItemTTL(userId: userId))) } -private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { +private func chatItemTTLResponse(_ r: ChatResponse0) throws -> ChatItemTTL { if case let .chatItemTTL(_, chatItemTTL) = r { if let ttl = chatItemTTL { return ChatItemTTL(ttl) @@ -706,7 +718,7 @@ private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { throw RuntimeError("chatItemTTLResponse: invalid ttl") } } - throw r + throw r.unexpected } func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { @@ -720,21 +732,21 @@ func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async thr } func getNetworkConfig() async throws -> NetCfg? { - let r = await chatSendCmd(.apiGetNetworkConfig) + let r: ChatResponse0 = try await chatSendCmd(.apiGetNetworkConfig) if case let .networkConfig(cfg) = r { return cfg } - throw r + throw r.unexpected } func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws { - let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl) + let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl: ctrl) if case .cmdOk = r { return } - throw r + throw r.unexpected } func apiSetNetworkInfo(_ networkInfo: UserNetworkInfo) throws { - let r = chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo)) + let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo)) if case .cmdOk = r { return } - throw r + throw r.unexpected } func reconnectAllServers() async throws { @@ -755,93 +767,93 @@ func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSett } func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { - let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) + let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } - throw r + throw r.unexpected } func apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) { - let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) } - throw r + throw r.unexpected } func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, ConnectionStats?) { - let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) } - throw r + throw r.unexpected } func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) { - let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId)) + let r: ChatResponse0 = try await chatSendCmd(.apiContactQueueInfo(contactId: contactId)) if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) } - throw r + throw r.unexpected } func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) { - let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId)) if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) } - throw r + throw r.unexpected } func apiSwitchContact(contactId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchContact(contactId: contactId)) if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberSwitchStarted(_, _, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiAbortSwitchContact(_ contactId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId)) if case let .contactSwitchAborted(_, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberSwitchAborted(_, _, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiSyncContactRatchet(_ contactId: Int64, _ force: Bool) throws -> ConnectionStats { - let r = chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force)) if case let .contactRatchetSyncStarted(_, _, connectionStats) = r { return connectionStats } - throw r + throw r.unexpected } func apiSyncGroupMemberRatchet(_ groupId: Int64, _ groupMemberId: Int64, _ force: Bool) throws -> (GroupMember, ConnectionStats) { - let r = chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force)) + let r: ChatResponse0 = try chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force)) if case let .groupMemberRatchetSyncStarted(_, _, member, connectionStats) = r { return (member, connectionStats) } - throw r + throw r.unexpected } func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) { - let r = await chatSendCmd(.apiGetContactCode(contactId: contactId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetContactCode(contactId: contactId)) if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) } - throw r + throw r.unexpected } func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, String) { - let r = await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse0 = try await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) } - throw r + throw r.unexpected } func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? { - let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) - if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } + let r: APIResult = chatApiSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) + if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) } logger.error("apiVerifyContact error: \(String(describing: r))") return nil } func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? { - let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) - if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } + let r: APIResult = chatApiSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) + if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) } logger.error("apiVerifyGroupMember error: \(String(describing: r))") return nil } @@ -852,23 +864,23 @@ func apiAddContact(incognito: Bool) async -> ((CreatedConnLink, PendingContactCo return (nil, nil) } let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) - let r = await chatSendCmd(.apiAddContact(userId: userId, short: short, incognito: incognito), bgTask: false) - if case let .invitation(_, connLinkInv, connection) = r { return ((connLinkInv, connection), nil) } + let r: APIResult = await chatApiSendCmd(.apiAddContact(userId: userId, short: short, incognito: incognito), bgTask: false) + if case let .result(.invitation(_, connLinkInv, connection)) = r { return ((connLinkInv, connection), nil) } let alert = connectionErrorAlert(r) return (nil, alert) } func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? { - let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito)) if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection } - throw r + throw r.unexpected } func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection { - let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) + let r: ChatResponse1 = try await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection} - throw r + throw r.unexpected } func apiConnectPlan(connLink: String) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { @@ -876,9 +888,9 @@ func apiConnectPlan(connLink: String) async -> ((CreatedConnLink, ConnectionPlan logger.error("apiConnectPlan: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnectPlan(userId: userId, connLink: connLink)) - if case let .connectionPlan(_, connLink, connPlan) = r { return ((connLink, connPlan), nil) } - let alert = apiConnectResponseAlert(r) ?? connectionErrorAlert(r) + let r: APIResult = await chatApiSendCmd(.apiConnectPlan(userId: userId, connLink: connLink)) + if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) } + let alert = apiConnectResponseAlert(r.unexpected) ?? connectionErrorAlert(r) return (nil, alert) } @@ -897,14 +909,14 @@ func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqT logger.error("apiConnect: no current user") return (nil, nil) } - let r: ChatResponse = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink)) + let r: APIResult = await chatApiSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink)) let m = ChatModel.shared switch r { - case let .sentConfirmation(_, connection): + case let .result(.sentConfirmation(_, connection)): return ((.invitation, connection), nil) - case let .sentInvitation(_, connection): + case let .result(.sentInvitation(_, connection)): return ((.contact, connection), nil) - case let .contactAlreadyExists(_, contact): + case let .result(.contactAlreadyExists(_, contact)): if let c = m.getContactChat(contact.contactId) { ItemsModel.shared.loadOpenChat(c.id) } @@ -912,28 +924,28 @@ func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqT return (nil, alert) default: () } - let alert = apiConnectResponseAlert(r) ?? connectionErrorAlert(r) + let alert = apiConnectResponseAlert(r.unexpected) ?? connectionErrorAlert(r) return (nil, alert) } -private func apiConnectResponseAlert(_ r: ChatResponse) -> Alert? { +private func apiConnectResponseAlert(_ r: ChatError) -> Alert? { switch r { - case .chatCmdError(_, .error(.invalidConnReq)): + case .error(.invalidConnReq): mkAlert( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) - case .chatCmdError(_, .error(.unsupportedConnReq)): + case .error(.unsupportedConnReq): mkAlert( title: "Unsupported connection link", message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." ) - case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): + case .errorAgent(.SMP(_, .AUTH)): mkAlert( title: "Connection error (AUTH)", message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) - case let .chatCmdError(_, .errorAgent(.SMP(_, .BLOCKED(info)))): + case let .errorAgent(.SMP(_, .BLOCKED(info))): Alert( title: Text("Connection blocked"), message: Text("Connection is blocked by server operator:\n\(info.reason.text)"), @@ -944,12 +956,12 @@ private func apiConnectResponseAlert(_ r: ChatResponse) -> Alert? { } } ) - case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))): + case .errorAgent(.SMP(_, .QUOTA)): mkAlert( title: "Undelivered messages", message: "The connection reached the limit of undelivered messages, your contact may be offline." ) - case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))): + case let .errorAgent(.INTERNAL(internalErr)): if internalErr == "SEUniqueID" { mkAlert( title: "Already connected?", @@ -969,13 +981,13 @@ func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { ) } -private func connectionErrorAlert(_ r: ChatResponse) -> Alert { +private func connectionErrorAlert(_ r: APIResult) -> Alert { if let networkErrorAlert = networkErrorAlert(r) { return networkErrorAlert } else { return mkAlert( title: "Connection error", - message: "Error: \(responseError(r))" + message: "Error: \(responseError(r.unexpected))" ) } } @@ -985,9 +997,9 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co logger.error("apiConnectContactViaAddress: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId)) - if case let .sentInvitationToContact(_, contact, _) = r { return (contact, nil) } - logger.error("apiConnectContactViaAddress error: \(responseError(r))") + let r: APIResult = await chatApiSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId)) + if case let .result(.sentInvitationToContact(_, contact, _)) = r { return (contact, nil) } + logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))") let alert = connectionErrorAlert(r) return (nil, alert) } @@ -996,11 +1008,11 @@ func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = . let chatId = type.rawValue + id.description DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) } defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .group = type, case .groupDeletedUser = r { return } - throw r + throw r.unexpected } func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact { @@ -1014,9 +1026,9 @@ func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } } - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) if case let .contactDeleted(_, contact) = r { return contact } - throw r + throw r.unexpected } func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async { @@ -1067,9 +1079,9 @@ func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(noti func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { - let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } - throw r + throw r.unexpected } func clearChat(_ chat: Chat) async { @@ -1084,119 +1096,125 @@ func clearChat(_ chat: Chat) async { func apiListContacts() throws -> [Contact] { let userId = try currentUserId("apiListContacts") - let r = chatSendCmdSync(.apiListContacts(userId: userId)) + let r: ChatResponse1 = try chatSendCmdSync(.apiListContacts(userId: userId)) if case let .contactsList(_, contacts) = r { return contacts } - throw r + throw r.unexpected } func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { let userId = try currentUserId("apiUpdateProfile") - let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) + let r: APIResult = await chatApiSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { - case .userProfileNoChange: return (profile, []) - case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts) - case .chatCmdError(_, .errorStore(.duplicateName)): return nil; - default: throw r + case .result(.userProfileNoChange): return (profile, []) + case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts) + case .error(.errorStore(.duplicateName)): return nil; + default: throw r.unexpected } } func apiSetProfileAddress(on: Bool) async throws -> User? { let userId = try currentUserId("apiSetProfileAddress") - let r = await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on)) switch r { case .userProfileNoChange: return nil case let .userProfileUpdated(user, _, _, _): return user - default: throw r + default: throw r.unexpected } } func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? { - let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact } - throw r + throw r.unexpected } func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { - let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) if case let .contactAliasUpdated(_, toContact) = r { return toContact } - throw r + throw r.unexpected } func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? { - let r = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias)) if case let .groupAliasUpdated(_, toGroup) = r { return toGroup } - throw r + throw r.unexpected } func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { - let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) + let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } - throw r + throw r.unexpected } func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool { - let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes)) - if case .cmdOk = r { return true } - logger.error("apiSetUserUIThemes bad response: \(String(describing: r))") - return false + do { + try await sendCommandOkResp(.apiSetUserUIThemes(userId: userId, themes: themes)) + return true + } catch { + logger.error("apiSetUserUIThemes bad response: \(responseError(error))") + return false + } } func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool { - let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes)) - if case .cmdOk = r { return true } - logger.error("apiSetChatUIThemes bad response: \(String(describing: r))") - return false + do { + try await sendCommandOkResp(.apiSetChatUIThemes(chatId: chatId, themes: themes)) + return true + } catch { + logger.error("apiSetChatUIThemes bad response: \(responseError(error))") + return false + } } func apiCreateUserAddress(short: Bool) async throws -> CreatedConnLink { let userId = try currentUserId("apiCreateUserAddress") - let r = await chatSendCmd(.apiCreateMyAddress(userId: userId, short: short)) + let r: ChatResponse1 = try await chatSendCmd(.apiCreateMyAddress(userId: userId, short: short)) if case let .userContactLinkCreated(_, connLink) = r { return connLink } - throw r + throw r.unexpected } func apiDeleteUserAddress() async throws -> User? { let userId = try currentUserId("apiDeleteUserAddress") - let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId)) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMyAddress(userId: userId)) if case let .userContactLinkDeleted(user) = r { return user } - throw r + throw r.unexpected } func apiGetUserAddress() throws -> UserContactLink? { let userId = try currentUserId("apiGetUserAddress") - return try userAddressResponse(chatSendCmdSync(.apiShowMyAddress(userId: userId))) + return try userAddressResponse(chatApiSendCmdSync(.apiShowMyAddress(userId: userId))) } func apiGetUserAddressAsync() async throws -> UserContactLink? { let userId = try currentUserId("apiGetUserAddressAsync") - return try userAddressResponse(await chatSendCmd(.apiShowMyAddress(userId: userId))) + return try userAddressResponse(await chatApiSendCmd(.apiShowMyAddress(userId: userId))) } -private func userAddressResponse(_ r: ChatResponse) throws -> UserContactLink? { +private func userAddressResponse(_ r: APIResult) throws -> UserContactLink? { switch r { - case let .userContactLink(_, contactLink): return contactLink - case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil - default: throw r + case let .result(.userContactLink(_, contactLink)): return contactLink + case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil + default: throw r.unexpected } } func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? { let userId = try currentUserId("userAddressAutoAccept") - let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) + let r: APIResult = await chatApiSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) switch r { - case let .userContactLinkUpdated(_, contactLink): return contactLink - case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil - default: throw r + case let .result(.userContactLinkUpdated(_, contactLink)): return contactLink + case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil + default: throw r.unexpected } } func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? { - let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId)) + let r: APIResult = await chatApiSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId)) let am = AlertManager.shared - if case let .acceptingContactRequest(_, contact) = r { return contact } - if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r { + if case let .result(.acceptingContactRequest(_, contact)) = r { return contact } + if case .error(.errorAgent(.SMP(_, .AUTH))) = r { am.showAlertMsg( title: "Connection error (AUTH)", message: "Sender may have deleted the connection request." @@ -1207,16 +1225,16 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont logger.error("apiAcceptContactRequest error: \(String(describing: r))") am.showAlertMsg( title: "Error accepting contact request", - message: "Error: \(responseError(r))" + message: "Error: \(responseError(r.unexpected))" ) } return nil } func apiRejectContactRequest(contactReqId: Int64) async throws { - let r = await chatSendCmd(.apiRejectContact(contactReqId: contactReqId)) + let r: ChatResponse1 = try await chatSendCmd(.apiRejectContact(contactReqId: contactReqId)) if case .contactRequestRejected = r { return } - throw r + throw r.unexpected } func apiChatRead(type: ChatType, id: Int64) async throws { @@ -1232,31 +1250,33 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { } func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) { - let r = await chatSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl) - if case let .sndStandaloneFileCreated(_, fileTransferMeta) = r { + let r: APIResult = await chatApiSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl: ctrl) + if case let .result(.sndStandaloneFileCreated(_, fileTransferMeta)) = r { return (fileTransferMeta, nil) } else { - logger.error("uploadStandaloneFile error: \(String(describing: r))") - return (nil, responseError(r)) + let err = responseError(r.unexpected) + logger.error("uploadStandaloneFile error: \(err)") + return (nil, err) } } func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) { - let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl) - if case let .rcvStandaloneFileCreated(_, rcvFileTransfer) = r { + let r: APIResult = await chatApiSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl: ctrl) + if case let .result(.rcvStandaloneFileCreated(_, rcvFileTransfer)) = r { return (rcvFileTransfer, nil) } else { - logger.error("downloadStandaloneFile error: \(String(describing: r))") - return (nil, responseError(r)) + let err = responseError(r.unexpected) + logger.error("downloadStandaloneFile error: \(err)") + return (nil, err) } } func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? { - let r = await chatSendCmd(.apiStandaloneFileInfo(url: url), ctrl) - if case let .standaloneFileInfo(fileMeta) = r { + let r: APIResult = await chatApiSendCmd(.apiStandaloneFileInfo(url: url), ctrl: ctrl) + if case let .result(.standaloneFileInfo(fileMeta)) = r { return fileMeta } else { - logger.error("standaloneFileInfo error: \(String(describing: r))") + logger.error("standaloneFileInfo error: \(responseError(r.unexpected))") return nil } } @@ -1271,12 +1291,12 @@ func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = f } func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async { - var fileIdsToApprove = [Int64]() - var srvsToApprove = Set() - var otherFileErrs = [ChatResponse]() - + var fileIdsToApprove: [Int64] = [] + var srvsToApprove: Set = [] + var otherFileErrs: [APIResult] = [] + for fileId in fileIds { - let r = await chatSendCmd( + let r: APIResult = await chatApiSendCmd( .receiveFile( fileId: fileId, userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(), @@ -1285,36 +1305,22 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool ) ) switch r { - case let .rcvFileAccepted(_, chatItem): + case let .result(.rcvFileAccepted(_, chatItem)): await chatItemSimpleUpdate(user, chatItem) -// TODO when aChatItem added -// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): -// await chatItemSimpleUpdate(user, aChatItem) -// Task { cleanupFile(aChatItem) } + // TODO when aChatItem added + // case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): + // await chatItemSimpleUpdate(user, aChatItem) + // Task { cleanupFile(aChatItem) } + case let .error(.error(.fileNotApproved(fileId, unknownServers))): + fileIdsToApprove.append(fileId) + srvsToApprove.formUnion(unknownServers) default: - if let chatError = r.chatErrorType { - switch chatError { - case let .fileNotApproved(fileId, unknownServers): - fileIdsToApprove.append(fileId) - srvsToApprove.formUnion(unknownServers) - default: - otherFileErrs.append(r) - } - } + otherFileErrs.append(r) } } - + if !auto { - let otherErrsStr = if otherFileErrs.isEmpty { - "" - } else if otherFileErrs.count == 1 { - "\(otherFileErrs[0])" - } else if otherFileErrs.count == 2 { - "\(otherFileErrs[0])\n\(otherFileErrs[1])" - } else { - "\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)" - } - + let otherErrsStr = fileErrorStrs(otherFileErrs) // If there are not approved files, alert is shown the same way both in case of singular and plural files reception if !fileIdsToApprove.isEmpty { let srvs = srvsToApprove @@ -1350,7 +1356,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it let errorResponse = otherFileErrs.first! switch errorResponse { - case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer): + case let .result(.rcvFileAcceptedSndCancelled(_, rcvFileTransfer)): logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)") await MainActor.run { showAlert( @@ -1358,19 +1364,14 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message") ) } + case .error(.error(.fileCancelled)), .error(.error(.fileAlreadyReceiving)): + logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") default: - if let chatError = errorResponse.chatErrorType { - switch chatError { - case .fileCancelled, .fileAlreadyReceiving: - logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") - default: - await MainActor.run { - showAlert( - NSLocalizedString("Error receiving file", comment: "alert title"), - message: responseError(errorResponse) - ) - } - } + await MainActor.run { + showAlert( + NSLocalizedString("Error receiving file", comment: "alert title"), + message: responseError(errorResponse.unexpected) + ) } } } else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert @@ -1382,8 +1383,22 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } } } + + func fileErrorStrs(_ errs: [APIResult]) -> String { + var errStr = "" + if errs.count >= 1 { + errStr = String(describing: errs[0].unexpected) + } + if errs.count >= 2 { + errStr += "\n\(String(describing: errs[1].unexpected))" + } + if errs.count > 2 { + errStr += "\nand \(errs.count - 2) other error(s)" + } + return errStr + } } - + func cancelFile(user: User, fileId: Int64) async { if let chatItem = await apiCancelFile(fileId: fileId) { await chatItemSimpleUpdate(user, chatItem) @@ -1392,12 +1407,12 @@ func cancelFile(user: User, fileId: Int64) async { } func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? { - let r = await chatSendCmd(.cancelFile(fileId: fileId), ctrl) + let r: APIResult = await chatApiSendCmd(.cancelFile(fileId: fileId), ctrl: ctrl) switch r { - case let .sndFileCancelled(_, chatItem, _, _) : return chatItem - case let .rcvFileCancelled(_, chatItem, _) : return chatItem + case let .result(.sndFileCancelled(_, chatItem, _, _)) : return chatItem + case let .result(.rcvFileCancelled(_, chatItem, _)) : return chatItem default: - logger.error("apiCancelFile error: \(String(describing: r))") + logger.error("apiCancelFile error: \(responseError(r.unexpected))") return nil } } @@ -1407,9 +1422,9 @@ func setLocalDeviceName(_ displayName: String) throws { } func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { - let r = await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) + let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } - throw r + throw r.unexpected } func findKnownRemoteCtrl() async throws { @@ -1417,21 +1432,21 @@ func findKnownRemoteCtrl() async throws { } func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { - let r = await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId)) + let r: ChatResponse2 = try await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } - throw r + throw r.unexpected } func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo { - let r = await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode)) + let r: ChatResponse2 = try await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode)) if case let .remoteCtrlConnected(rc) = r { return rc } - throw r + throw r.unexpected } func listRemoteCtrls() throws -> [RemoteCtrlInfo] { - let r = chatSendCmdSync(.listRemoteCtrls) + let r: ChatResponse2 = try chatSendCmdSync(.listRemoteCtrls) if case let .remoteCtrlList(rcInfo) = r { return rcInfo } - throw r + throw r.unexpected } func stopRemoteCtrl() async throws { @@ -1442,8 +1457,8 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws { try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId)) } -func networkErrorAlert(_ r: ChatResponse) -> Alert? { - if let alert = getNetworkErrorAlert(r) { +func networkErrorAlert(_ res: APIResult) -> Alert? { + if case let .error(e) = res, let alert = getNetworkErrorAlert(e) { return mkAlert(title: alert.title, message: alert.message) } else { return nil @@ -1505,15 +1520,15 @@ func apiEndCall(_ contact: Contact) async throws { } func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] { - let r = chatSendCmdSync(.apiGetCallInvitations) + let r: ChatResponse2 = try chatSendCmdSync(.apiGetCallInvitations) if case let .callInvitations(invs) = r { return invs } - throw r + throw r.unexpected } func apiGetCallInvitations() async throws -> [RcvCallInvitation] { - let r = await chatSendCmd(.apiGetCallInvitations) + let r: ChatResponse2 = try await chatSendCmd(.apiGetCallInvitations) if case let .callInvitations(invs) = r { return invs } - throw r + throw r.unexpected } func apiCallStatus(_ contact: Contact, _ status: String) async throws { @@ -1525,9 +1540,9 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { - let r = chatSendCmdSync(.apiGetNetworkStatuses) + let r: ChatResponse1 = try chatSendCmdSync(.apiGetNetworkStatuses) if case let .networkStatuses(_, statuses) = r { return statuses } - throw r + throw r.unexpected } func markChatRead(_ chat: Chat) async { @@ -1570,29 +1585,29 @@ func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsR } } -private func sendCommandOkResp(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) async throws { - let r = await chatSendCmd(cmd, ctrl) +private func sendCommandOkResp(_ cmd: ChatCommand, ctrl: chat_ctrl? = nil) async throws { + let r: ChatResponse2 = try await chatSendCmd(cmd, ctrl: ctrl) if case .cmdOk = r { return } - throw r + throw r.unexpected } private func sendCommandOkRespSync(_ cmd: ChatCommand) throws { - let r = chatSendCmdSync(cmd) + let r: ChatResponse2 = try chatSendCmdSync(cmd) if case .cmdOk = r { return } - throw r + throw r.unexpected } func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") - let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) + let r: ChatResponse2 = try chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } - throw r + throw r.unexpected } func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { - let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) + let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) if case let .sentGroupInvitation(_, _, _, member) = r { return member } - throw r + throw r.unexpected } enum JoinGroupResult { @@ -1602,31 +1617,31 @@ enum JoinGroupResult { } func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { - let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) + let r: APIResult = await chatApiSendCmd(.apiJoinGroup(groupId: groupId)) switch r { - case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo) - case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved - case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound - default: throw r + case let .result(.userAcceptedGroupSent(_, groupInfo, _)): return .joined(groupInfo: groupInfo) + case .error(.errorAgent(.SMP(_, .AUTH))): return .invitationRemoved + case .error(.errorStore(.groupNotFound)): return .groupNotFound + default: throw r.unexpected } } 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) + let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) if case let .userDeletedMembers(_, _, members, withMessages) = r { return members } - throw r + throw r.unexpected } func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] { - let r = await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false) + let r: ChatResponse2 = try await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false) if case let .membersRoleUser(_, _, members, _) = r { return members } - throw r + throw r.unexpected } func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] { - let r = await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false) + let r: ChatResponse2 = try await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false) if case let .membersBlockedForAllUser(_, _, members, _) = r { return members } - throw r + throw r.unexpected } func leaveGroup(_ groupId: Int64) async { @@ -1639,15 +1654,15 @@ func leaveGroup(_ groupId: Int64) async { } func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { - let r = await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) + let r: ChatResponse2 = try await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) if case let .leftMemberUser(_, groupInfo) = r { return groupInfo } - throw r + throw r.unexpected } // use ChatModel's loadGroupMembers from views func apiListMembers(_ groupId: Int64) async -> [GroupMember] { - let r: ChatResponse = await chatSendCmd(.apiListMembers(groupId: groupId)) - if case let .groupMembers(_, group) = r { return group.members } + let r: APIResult = await chatApiSendCmd(.apiListMembers(groupId: groupId)) + if case let .result(.groupMembers(_, group)) = r { return group.members } return [] } @@ -1660,73 +1675,73 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] { } func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws -> GroupInfo { - let r = await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile)) + let r: ChatResponse2 = try await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile)) if case let .groupUpdated(_, toGroup) = r { return toGroup } - throw r + throw r.unexpected } func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) - let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short)) + let r: ChatResponse2 = try await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short)) if case let .groupLinkCreated(_, _, connLink, memberRole) = r { return (connLink, memberRole) } - throw r + throw r.unexpected } func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { - let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) + let r: ChatResponse2 = try await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } - throw r + throw r.unexpected } func apiDeleteGroupLink(_ groupId: Int64) async throws { - let r = await chatSendCmd(.apiDeleteGroupLink(groupId: groupId)) + let r: ChatResponse2 = try await chatSendCmd(.apiDeleteGroupLink(groupId: groupId)) if case .groupLinkDeleted = r { return } - throw r + throw r.unexpected } func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRole)? { - let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) + let r: APIResult = chatApiSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, _, connLink, memberRole): + case let .result(.groupLink(_, _, connLink, memberRole)): return (connLink, memberRole) - case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): + case .error(.errorStore(storeError: .groupLinkNotFound)): return nil - default: throw r + default: throw r.unexpected } } func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact { - let r = await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId)) + let r: ChatResponse2 = try await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId)) if case let .newMemberContact(_, contact, _, _) = r { return contact } - throw r + throw r.unexpected } func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async throws -> Contact { - let r = await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay) + let r: ChatResponse2 = try await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay) if case let .newMemberContactSentInv(_, contact, _, _) = r { return contact } - throw r + throw r.unexpected } func apiGetVersion() throws -> CoreVersionInfo { - let r = chatSendCmdSync(.showVersion) + let r: ChatResponse2 = try chatSendCmdSync(.showVersion) if case let .versionInfo(info, _, _) = r { return info } - throw r + throw r.unexpected } func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) { let userId = try currentUserId("getAgentSubsTotal") - let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false) + let r: ChatResponse2 = try await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false) if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) } logger.error("getAgentSubsTotal error: \(String(describing: r))") - throw r + throw r.unexpected } func getAgentServersSummary() throws -> PresentedServersSummary { let userId = try currentUserId("getAgentServersSummary") - let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false) + let r: ChatResponse2 = try chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false) if case let .agentServersSummary(_, serversSummary) = r { return serversSummary } logger.error("getAgentServersSummary error: \(String(describing: r))") - throw r + throw r.unexpected } func resetAgentServersStats() async throws { @@ -1936,7 +1951,7 @@ class ChatReceiver { private var receiveMessages = true private var _lastMsgTime = Date.now - var messagesChannel: ((ChatEvent) -> Void)? = nil + var messagesChannel: ((APIResult) -> Void)? = nil static let shared = ChatReceiver() @@ -1954,7 +1969,12 @@ class ChatReceiver { while self.receiveMessages { if let msg = await chatRecvMsg() { self._lastMsgTime = .now - await processReceivedMsg(msg) + Task { await TerminalItems.shared.addResult(msg) } + switch msg { + case let .result(evt): await processReceivedMsg(evt) + case let .error(err): logger.debug("chatRecvMsg error: \(responseError(err))") + case let .invalid(type, json): logger.debug("chatRecvMsg event: * \(type) \(dataToString(json))") + } if let messagesChannel { messagesChannel(msg) } @@ -1972,12 +1992,9 @@ class ChatReceiver { } func processReceivedMsg(_ res: ChatEvent) async { - Task { - await TerminalItems.shared.add(.event(.now, res)) - } let m = ChatModel.shared let n = NetworkModel.shared - logger.debug("processReceivedMsg: \(res.eventType)") + logger.debug("processReceivedMsg: \(res.responseType)") switch res { case let .contactDeletedByContact(user, contact): if active(user) && contact.directOrUsed { @@ -2475,14 +2492,14 @@ func processReceivedMsg(_ res: ChatEvent) async { } } default: - logger.debug("unsupported event: \(res.eventType)") + logger.debug("unsupported event: \(res.responseType)") } func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async { if let call = m.activeCall, call.contact.apiId == contact.apiId { await perform(call) } else { - logger.debug("processReceivedMsg: ignoring \(res.eventType), not in call with the contact \(contact.id)") + logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") } } } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 3e88019e95..7c8996a99b 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -243,7 +243,7 @@ struct ActiveCallView: View { ChatReceiver.shared.messagesChannel = nil return } - if case let .chatItemsStatusesUpdated(_, chatItems) = msg, + if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg, chatItems.contains(where: { ci in ci.chatInfo.id == call.contact.id && ci.chatItem.content.isSndCall && diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift index 18fd682646..918d8f9449 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift @@ -7,10 +7,11 @@ // import SwiftUI +import SimpleXChat struct CIInvalidJSONView: View { @EnvironmentObject var theme: AppTheme - var json: String + var json: Data? @State private var showJSON = false var body: some View { @@ -25,7 +26,7 @@ struct CIInvalidJSONView: View { .textSelection(.disabled) .onTapGesture { showJSON = true } .appSheet(isPresented: $showJSON) { - invalidJSONView(json) + invalidJSONView(dataToString(json)) } } } @@ -49,6 +50,6 @@ func invalidJSONView(_ json: String) -> some View { struct CIInvalidJSONView_Previews: PreviewProvider { static var previews: some View { - CIInvalidJSONView(json: "{}") + CIInvalidJSONView(json: "{}".data(using: .utf8)!) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 790af64b3f..f5234ed331 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -579,14 +579,14 @@ struct ChatListNavLink: View { ) } - private func invalidJSONPreview(_ json: String) -> some View { + private func invalidJSONPreview(_ json: Data?) -> some View { Text("invalid chat data") .foregroundColor(.red) .padding(4) .frame(height: dynamicRowHeight) .onTapGesture { showInvalidJSON = true } .appSheet(isPresented: $showInvalidJSON) { - invalidJSONView(json) + invalidJSONView(dataToString(json)) .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } } @@ -689,7 +689,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { } func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { - if let r = error as? ChatResponse, + if let r = error as? ChatError, let alert = getNetworkErrorAlert(r) { return alert } else { diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 3cd37e4930..441a164f8a 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -173,7 +173,7 @@ struct DatabaseEncryptionView: View { } return true } catch let error { - if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { + if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError { await operationEnded(.currentPassphraseError) } else { await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))")) diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index c684ad627a..0af8fa7ad8 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -520,15 +520,15 @@ struct MigrateFromDevice: View { chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in await MainActor.run { switch msg { - case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize): + case let .result(.sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize)): if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total { migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl) } - case .sndFileRedirectStartXFTP: + case .result(.sndFileRedirectStartXFTP): DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { migrationState = .linkCreation } - case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs): + case let .result(.sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs)): let cfg = getNetCfg() let proxy: NetworkProxy? = if cfg.socksProxy == nil { nil @@ -546,11 +546,11 @@ struct MigrateFromDevice: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) } - case .sndFileError: + case .result(.sndFileError): alert = .error(title: "Upload failed", error: "Check your internet connection and try again") migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) default: - logger.debug("unsupported event: \(msg.eventType)") + logger.debug("unsupported event: \(msg.responseType)") } } } @@ -691,7 +691,7 @@ private struct PassphraseConfirmationView: View { migrationState = .uploadConfirmation } } catch let error { - if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse { + if case .errorDatabase(.errorOpen(.errorNotADatabase)) = error as? ChatError { showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) } else { alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error))) @@ -733,11 +733,11 @@ func chatStoppedView() -> some View { private class MigrationChatReceiver { let ctrl: chat_ctrl let databaseUrl: URL - let processReceivedMsg: (ChatEvent) async -> Void + let processReceivedMsg: (APIResult) async -> Void private var receiveLoop: Task? private var receiveMessages = true - init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatEvent) async -> Void) { + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult) async -> Void) { self.ctrl = ctrl self.databaseUrl = databaseUrl self.processReceivedMsg = processReceivedMsg @@ -752,11 +752,11 @@ private class MigrationChatReceiver { func receiveMsgLoop() async { // TODO use function that has timeout - if let msg: ChatEvent = await chatRecvMsg(ctrl) { + if let msg: APIResult = await chatRecvMsg(ctrl) { Task { - await TerminalItems.shared.add(.event(.now, msg)) + await TerminalItems.shared.addResult(msg) } - logger.debug("processReceivedMsg: \(msg.eventType)") + logger.debug("processReceivedMsg: \(msg.responseType)") await processReceivedMsg(msg) } if self.receiveMessages { diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 19cefa7f4d..93fe19cf33 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -496,10 +496,10 @@ struct MigrateToDevice: View { chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in await MainActor.run { switch msg { - case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer): + case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)): migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl) MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) - case .rcvStandaloneFileComplete: + case .result(.rcvStandaloneFileComplete): DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // User closed the whole screen before new state was saved if migrationState == nil { @@ -509,14 +509,14 @@ struct MigrateToDevice: View { MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) } } - case .rcvFileError: + case .result(.rcvFileError): alert = .error(title: "Download failed", error: "File was deleted or link is invalid") migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) - case .chatError(_, .error(.noRcvFileUser)): + case .error(.error(.noRcvFileUser)): alert = .error(title: "Download failed", error: "File was deleted or link is invalid") migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) default: - logger.debug("unsupported event: \(msg.eventType)") + logger.debug("unsupported event: \(msg.responseType)") } } } @@ -751,11 +751,11 @@ private func progressView() -> some View { private class MigrationChatReceiver { let ctrl: chat_ctrl let databaseUrl: URL - let processReceivedMsg: (ChatEvent) async -> Void + let processReceivedMsg: (APIResult) async -> Void private var receiveLoop: Task? private var receiveMessages = true - init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatEvent) async -> Void) { + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult) async -> Void) { self.ctrl = ctrl self.databaseUrl = databaseUrl self.processReceivedMsg = processReceivedMsg @@ -772,9 +772,9 @@ private class MigrationChatReceiver { // TODO use function that has timeout if let msg = await chatRecvMsg(ctrl) { Task { - await TerminalItems.shared.add(.event(.now, msg)) + await TerminalItems.shared.addResult(msg) } - logger.debug("processReceivedMsg: \(msg.eventType)") + logger.debug("processReceivedMsg: \(msg.responseType)") await processReceivedMsg(msg) } if self.receiveMessages { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 3199be21fe..c022a2a012 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -236,15 +236,15 @@ private func showCreateProfileAlert( _ error: Error ) { let m = ChatModel.shared - switch error as? ChatResponse { - case .chatCmdError(_, .errorStore(.duplicateName)), - .chatCmdError(_, .error(.userExists)): + switch error as? ChatError { + case .errorStore(.duplicateName), + .error(.userExists): if m.currentUser == nil { AlertManager.shared.showAlert(duplicateUserAlert) } else { showAlert(.duplicateUserError) } - case .chatCmdError(_, .error(.invalidDisplayName)): + case .error(.invalidDisplayName): if m.currentUser == nil { AlertManager.shared.showAlert(invalidDisplayNameAlert) } else { diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 67020e09e7..01b25baed8 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -456,12 +456,12 @@ struct ConnectDesktopView: View { } } catch let e { await MainActor.run { - switch e as? ChatResponse { - case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError - case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError - case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v) - case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil) - case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError + switch e as? ChatError { + case .errorRemoteCtrl(.badInvitation): alert = .badInvitationError + case .error(.commandError): alert = .badInvitationError + case let .errorRemoteCtrl(.badVersion(v)): alert = .badVersionError(version: v) + case .errorAgent(.RCP(.version)): alert = .badVersionError(version: nil) + case .errorAgent(.RCP(.ctrlAuth)): alert = .desktopDisconnectedError default: errorAlert(e) } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 2b58abef65..554219eb69 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -145,18 +145,18 @@ struct TerminalView: View { } func consoleSendMessage() { - let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { - let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) + let resp: APIResult = APIResult.error(ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) Task { - await TerminalItems.shared.addCommand(.now, cmd, resp) + await TerminalItems.shared.addCommand(.now, .string(composeState.message), resp) } } else { + let cmd = composeState.message DispatchQueue.global().async { Task { - composeState.inProgress = true - _ = await chatSendCmd(cmd) - composeState.inProgress = false + await MainActor.run { composeState.inProgress = true } + await sendTerminalCmd(cmd) + await MainActor.run { composeState.inProgress = false } } } } @@ -164,12 +164,38 @@ struct TerminalView: View { } } +func sendTerminalCmd(_ cmd: String) async { + let start: Date = .now + await withCheckedContinuation { (cont: CheckedContinuation) in + let d = sendSimpleXCmdStr(cmd) + Task { + guard let d else { + await TerminalItems.shared.addCommand(start, ChatCommand.string(cmd), APIResult.error(.invalidJSON(json: nil))) + return + } + let r0: APIResult = decodeAPIResult(d) + guard case .invalid = r0 else { + await TerminalItems.shared.addCommand(start, .string(cmd), r0) + return + } + let r1: APIResult = decodeAPIResult(d) + guard case .invalid = r1 else { + await TerminalItems.shared.addCommand(start, .string(cmd), r1) + return + } + let r2: APIResult = decodeAPIResult(d) + await TerminalItems.shared.addCommand(start, .string(cmd), r2) + } + cont.resume(returning: ()) + } +} + struct TerminalView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.terminalItems = [ - .resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), - .resp(.now, ChatResponse.response(type: "newChatItems", json: "{}")) + .err(.now, APIResult.invalid(type: "contactSubscribed", json: "{}".data(using: .utf8)!).unexpected), + .err(.now, APIResult.invalid(type: "newChatItems", json: "{}".data(using: .utf8)!).unexpected) ] return NavigationView { TerminalView() diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index 7569547e6a..6ab4a779d1 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -47,8 +47,7 @@ enum NSEChatCommand: ChatCmdProtocol { } } -enum NSEChatResponse: Decodable, Error, ChatRespProtocol { - case response(type: String, json: String) +enum NSEChatResponse: Decodable, ChatAPIResult { case activeUser(user: User) case chatStarted case chatRunning @@ -57,11 +56,9 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) case cmdOk(user_: UserRef?) - case chatCmdError(user_: UserRef?, chatError: ChatError) var responseType: String { switch self { - case let .response(type, _): "* \(type)" case .activeUser: "activeUser" case .chatStarted: "chatStarted" case .chatRunning: "chatRunning" @@ -70,13 +67,11 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { case .connNtfMessages: "connNtfMessages" case .ntfMessage: "ntfMessage" case .cmdOk: "cmdOk" - case .chatCmdError: "chatCmdError" } } var details: String { switch self { - case let .response(_, json): return json case let .activeUser(user): return String(describing: user) case .chatStarted: return noDetails case .chatRunning: return noDetails @@ -85,60 +80,11 @@ enum NSEChatResponse: Decodable, Error, ChatRespProtocol { case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))" case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case .cmdOk: return noDetails - case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) - } - } - - var noDetails: String { "\(responseType): no details" } - - static func chatResponse(_ s: String) -> NSEChatResponse { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "chatCmdError" { - if let jError = jResp["chatCmdError"] as? NSDictionary { - return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return NSEChatResponse.response(type: type ?? "invalid", json: json ?? s) - } - - var chatError: ChatError? { - switch self { - case let .chatCmdError(_, error): error - default: nil - } - } - - var chatErrorType: ChatErrorType? { - switch self { - case let .chatCmdError(_, .error(error)): error - default: nil } } } -enum NSEChatEvent: Decodable, Error, ChatEventProtocol { - case event(type: String, json: String) +enum NSEChatEvent: Decodable, ChatAPIResult { case chatSuspended case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) @@ -148,11 +94,9 @@ enum NSEChatEvent: Decodable, Error, ChatEventProtocol { case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) case callInvitation(callInvitation: RcvCallInvitation) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) - case chatError(user_: UserRef?, chatError: ChatError) - var eventType: String { + var responseType: String { switch self { - case let .event(type, _): "* \(type)" case .chatSuspended: "chatSuspended" case .contactConnected: "contactConnected" case .receivedContactRequest: "receivedContactRequest" @@ -162,13 +106,11 @@ enum NSEChatEvent: Decodable, Error, ChatEventProtocol { case .sndFileRcvCancelled: "sndFileRcvCancelled" case .callInvitation: "callInvitation" case .ntfMessage: "ntfMessage" - case .chatError: "chatError" } } var details: String { switch self { - case let .event(_, json): return json case .chatSuspended: return noDetails case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) @@ -180,54 +122,6 @@ enum NSEChatEvent: Decodable, Error, ChatEventProtocol { case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .callInvitation(inv): return String(describing: inv) case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } - } - - var noDetails: String { "\(eventType): no details" } - - static func chatEvent(_ s: String) -> NSEChatEvent { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return NSEChatEvent.event(type: type ?? "invalid", json: json ?? s) - } - - var chatError: ChatError? { - switch self { - case let .chatError(_, error): error - default: nil - } - } - - var chatErrorType: ChatErrorType? { - switch self { - case let .chatError(_, .error(error)): error - default: nil - } - } + } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index e8dd21f23c..bc783784cb 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -774,12 +774,18 @@ func receiveMessages() async { } func receiveMsg() async { - if let msg = await chatRecvMsg() { + switch await chatRecvMsg() { + case let .result(msg): logger.debug("NotificationService receiveMsg: message") if let (id, ntf) = await receivedMsgNtf(msg) { logger.debug("NotificationService receiveMsg: notification") await NSEThreads.shared.processNotification(id, ntf) } + case let .error(err): + logger.error("NotificationService receivedMsgNtf error: \(String(describing: err))") + case let .invalid(type, _): + logger.error("NotificationService receivedMsgNtf invalid: \(type)") + case .none: () } } @@ -789,9 +795,9 @@ func receiveMessages() async { } } -func chatRecvMsg() async -> NSEChatEvent? { +func chatRecvMsg() async -> APIResult? { await withCheckedContinuation { cont in - let resp: NSEChatEvent? = recvSimpleXMsg() + let resp: APIResult? = recvSimpleXMsg() cont.resume(returning: resp) } } @@ -800,7 +806,7 @@ private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? { - logger.debug("NotificationService receivedMsgNtf: \(res.eventType)") + logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") switch res { case let .contactConnected(user, contact, _): return (contact.id, .contactConnected(user, contact)) @@ -845,11 +851,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? case .chatSuspended: chatSuspended() return nil - case let .chatError(_, err): - logger.error("NotificationService receivedMsgNtf error: \(String(describing: err))") - return nil default: - logger.debug("NotificationService receivedMsgNtf ignored event: \(res.eventType)") + logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)") return nil } } @@ -868,14 +871,14 @@ func updateNetCfg() { } func apiGetActiveUser() -> User? { - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.showActiveUser) + let r: APIResult = sendSimpleXCmd(NSEChatCommand.showActiveUser) logger.debug("apiGetActiveUser sendSimpleXCmd response: \(r.responseType)") switch r { - case let .activeUser(user): return user - case .chatCmdError(_, .error(.noActiveUser)): + case let .result(.activeUser(user)): return user + case .error(.error(.noActiveUser)): logger.debug("apiGetActiveUser sendSimpleXCmd no active user") return nil - case let .chatCmdError(_, err): + case let .error(err): logger.debug("apiGetActiveUser sendSimpleXCmd error: \(String(describing: err))") return nil default: @@ -885,39 +888,39 @@ func apiGetActiveUser() -> User? { } func apiStartChat() throws -> Bool { - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.startChat(mainApp: false, enableSndFiles: false)) + let r: APIResult = sendSimpleXCmd(NSEChatCommand.startChat(mainApp: false, enableSndFiles: false)) switch r { - case .chatStarted: return true - case .chatRunning: return false - default: throw r + case .result(.chatStarted): return true + case .result(.chatRunning): return false + default: throw r.unexpected } } func apiActivateChat() -> Bool { chatReopenStore() - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiActivateChat(restoreChat: false)) - if case .cmdOk = r { return true } + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiActivateChat(restoreChat: false)) + if case .result(.cmdOk) = r { return true } logger.error("NotificationService apiActivateChat error: \(String(describing: r))") return false } func apiSuspendChat(timeoutMicroseconds: Int) -> Bool { - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) - if case .cmdOk = r { return true } + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + if case .result(.cmdOk) = r { return true } logger.error("NotificationService apiSuspendChat error: \(String(describing: r))") return false } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws { - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetEncryptLocalFiles(enable: enable)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSetEncryptLocalFiles(enable: enable)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [NtfConn]? { @@ -925,11 +928,11 @@ func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [NtfConn]? { logger.debug("no active user") return nil } - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfConns(ntfConns) = r { + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .result(.ntfConns(ntfConns)) = r { logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)") return ntfConns - } else if case let .chatCmdError(_, error) = r { + } else if case let .error(error) = r { logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") } else { logger.debug("apiGetNtfMessage ignored response: \(r.responseType) \(String.init(describing: r))") @@ -943,12 +946,12 @@ func apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) -> [NtfMsgInfo?]? { return nil } logger.debug("apiGetConnNtfMessages command: \(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)") - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs)) - if case let .connNtfMessages(receivedMsgs) = r { + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs)) + if case let .result(.connNtfMessages(receivedMsgs)) = r { logger.debug("apiGetConnNtfMessages response receivedMsgs: total \(receivedMsgs.count), expecting messages \(receivedMsgs.count { $0 != nil })") return receivedMsgs } - logger.debug("apiGetConnNtfMessages error: \(responseError(r))") + logger.debug("apiGetConnNtfMessages error: \(responseError(r.unexpected))") return nil } @@ -962,17 +965,17 @@ func getConnNtfMessage(connMsgReq: ConnMsgReq) -> NtfMsgInfo? { func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? { let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get() - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) - if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } - logger.error("receiveFile error: \(responseError(r))") + let r: APIResult = sendSimpleXCmd(NSEChatCommand.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) + if case let .result(.rcvFileAccepted(_, chatItem)) = r { return chatItem } + logger.error("receiveFile error: \(responseError(r.unexpected))") return nil } func apiSetFileToReceive(fileId: Int64, encrypted: Bool) { let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get() - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted)) - if case .cmdOk = r { return } - logger.error("setFileToReceive error: \(responseError(r))") + let r: APIResult = sendSimpleXCmd(NSEChatCommand.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted)) + if case .result(.cmdOk) = r { return } + logger.error("setFileToReceive error: \(responseError(r.unexpected))") } func autoReceiveFile(_ file: CIFile) -> ChatItem? { @@ -989,9 +992,9 @@ func autoReceiveFile(_ file: CIFile) -> ChatItem? { } func setNetworkConfig(_ cfg: NetCfg) throws { - let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData { diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 0f12b002f7..3e901c73eb 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -13,52 +13,52 @@ import SimpleXChat let logger = Logger() func apiGetActiveUser() throws -> User? { - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.showActiveUser) + let r: APIResult = sendSimpleXCmd(SEChatCommand.showActiveUser) switch r { - case let .activeUser(user): return user - case .chatCmdError(_, .error(.noActiveUser)): return nil - default: throw r + case let .result(.activeUser(user)): return user + case .error(.error(.noActiveUser)): return nil + default: throw r.unexpected } } func apiStartChat() throws -> Bool { - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.startChat(mainApp: false, enableSndFiles: true)) + let r: APIResult = sendSimpleXCmd(SEChatCommand.startChat(mainApp: false, enableSndFiles: true)) switch r { - case .chatStarted: return true - case .chatRunning: return false - default: throw r + case .result(.chatStarted): return true + case .result(.chatRunning): return false + default: throw r.unexpected } } func apiSetNetworkConfig(_ cfg: NetCfg) throws { - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSetNetworkConfig(networkConfig: cfg)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws { - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSetEncryptLocalFiles(_ enable: Bool) throws { - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetEncryptLocalFiles(enable: enable)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSetEncryptLocalFiles(enable: enable)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiGetChats(userId: User.ID) throws -> Array { - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiGetChats(userId: userId)) - if case let .apiChats(user: _, chats: chats) = r { return chats } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiGetChats(userId: userId)) + if case let .result(.apiChats(user: _, chats: chats)) = r { return chats } + throw r.unexpected } func apiSendMessages( chatInfo: ChatInfo, composedMessages: [ComposedMessage] ) throws -> [AChatItem] { - let r: SEChatResponse = sendSimpleXCmd( + let r: APIResult = sendSimpleXCmd( chatInfo.chatType == .local ? SEChatCommand.apiCreateChatItems( noteFolderId: chatInfo.apiId, @@ -72,33 +72,33 @@ func apiSendMessages( composedMessages: composedMessages ) ) - if case let .newChatItems(_, chatItems) = r { + if case let .result(.newChatItems(_, chatItems)) = r { return chatItems } else { for composedMessage in composedMessages { if let filePath = composedMessage.fileSource?.filePath { removeFile(filePath) } } - throw r + throw r.unexpected } } func apiActivateChat() throws { chatReopenStore() - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiActivateChat(restoreChat: false)) - if case .cmdOk = r { return } - throw r + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiActivateChat(restoreChat: false)) + if case .result(.cmdOk) = r { return } + throw r.unexpected } func apiSuspendChat(expired: Bool) { - let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000)) + let r: APIResult = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000)) // Block until `chatSuspended` received or 3 seconds has passed var suspended = false - if case .cmdOk = r, !expired { + if case .result(.cmdOk) = r, !expired { let startTime = CFAbsoluteTimeGetCurrent() while CFAbsoluteTimeGetCurrent() - startTime < 3 { - let msg: SEChatEvent? = recvSimpleXMsg(messageTimeout: 3_500000) + let msg: APIResult? = recvSimpleXMsg(messageTimeout: 3_500000) switch msg { - case .chatSuspended: + case .result(.chatSuspended): suspended = false break default: continue @@ -106,7 +106,7 @@ func apiSuspendChat(expired: Bool) { } } if !suspended { - let _r1: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: 0)) + let _r1: APIResult = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: 0)) } logger.debug("close store") chatCloseStore() @@ -151,32 +151,27 @@ enum SEChatCommand: ChatCmdProtocol { } } -enum SEChatResponse: Decodable, Error, ChatRespProtocol { - case response(type: String, json: String) +enum SEChatResponse: Decodable, ChatAPIResult { case activeUser(user: User) case chatStarted case chatRunning case apiChats(user: UserRef, chats: [ChatData]) case newChatItems(user: UserRef, chatItems: [AChatItem]) case cmdOk(user_: UserRef?) - case chatCmdError(user_: UserRef?, chatError: ChatError) var responseType: String { switch self { - case let .response(type, _): "* \(type)" case .activeUser: "activeUser" case .chatStarted: "chatStarted" case .chatRunning: "chatRunning" case .apiChats: "apiChats" case .newChatItems: "newChatItems" case .cmdOk: "cmdOk" - case .chatCmdError: "chatCmdError" } } var details: String { switch self { - case let .response(_, json): return json case let .activeUser(user): return String(describing: user) case .chatStarted: return noDetails case .chatRunning: return noDetails @@ -185,88 +180,39 @@ enum SEChatResponse: Decodable, Error, ChatRespProtocol { let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) case .cmdOk: return noDetails - case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) } } - var noDetails: String { "\(responseType): no details" } - - static func chatResponse(_ s: String) -> SEChatResponse { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "apiChats" { - if let r = parseApiChats(jResp) { - return .apiChats(user: r.user, chats: r.chats) - } - } else if type == "chatCmdError" { - if let jError = jResp["chatCmdError"] as? NSDictionary { - return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return SEChatResponse.response(type: type ?? "invalid", json: json ?? s) - } - - var chatError: ChatError? { - switch self { - case let .chatCmdError(_, error): error - default: nil - } - } - - var chatErrorType: ChatErrorType? { - switch self { - case let .chatCmdError(_, .error(error)): error - default: nil + static func fallbackResult(_ type: String, _ json: NSDictionary) -> SEChatResponse? { + if type == "apiChats", let r = parseApiChats(json) { + .apiChats(user: r.user, chats: r.chats) + } else { + nil } } } -enum SEChatEvent: Decodable, Error, ChatEventProtocol { - case event(type: String, json: String) +enum SEChatEvent: Decodable, ChatAPIResult { case chatSuspended case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) - case chatError(user_: UserRef?, chatError: ChatError) - var eventType: String { + var responseType: String { switch self { - case let .event(type, _): "* \(type)" case .chatSuspended: "chatSuspended" case .sndFileProgressXFTP: "sndFileProgressXFTP" case .sndFileCompleteXFTP: "sndFileCompleteXFTP" case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" case .sndFileError: "sndFileError" case .sndFileWarning: "sndFileWarning" - case .chatError: "chatError" } } var details: String { switch self { - case let .event(_, json): return json case .chatSuspended: return noDetails case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) @@ -275,53 +221,6 @@ enum SEChatEvent: Decodable, Error, ChatEventProtocol { return withUser(u, itemsString) case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") - case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } - } - - var noDetails: String { "\(eventType): no details" } - - static func chatEvent(_ s: String) -> SEChatEvent { - let d = s.data(using: .utf8)! - // TODO is there a way to do it without copying the data? e.g: - // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) - // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { - type = jResp.allKeys[0] as? String - if jResp.count == 2 && type == "_owsf" { - type = jResp.allKeys[1] as? String - } - if type == "chatError" { - if let jError = jResp["chatError"] as? NSDictionary { - return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? "")) - } - } - } - json = serializeJSON(j, options: .prettyPrinted) - } - return SEChatEvent.event(type: type ?? "invalid", json: json ?? s) - } - var chatError: ChatError? { - switch self { - case let .chatError(_, error): error - default: nil - } - } - - var chatErrorType: ChatErrorType? { - switch self { - case let .chatError(_, .error(error)): error - default: nil - } - } + } } diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index b4d26b6d54..12a775f85c 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -303,9 +303,9 @@ class ShareModel: ObservableObject { } } } - let r: SEChatEvent? = recvSimpleXMsg(messageTimeout: 1_000_000) + let r: APIResult? = recvSimpleXMsg(messageTimeout: 1_000_000) switch r { - case let .sndFileProgressXFTP(_, ci, _, sentSize, totalSize): + case let .result(.sndFileProgressXFTP(_, ci, _, sentSize, totalSize)): guard isMessage(for: ci) else { continue } networkTimeout = CFAbsoluteTimeGetCurrent() await MainActor.run { @@ -314,14 +314,14 @@ class ShareModel: ObservableObject { bottomBar = .loadingBar(progress: progress) } } - case let .sndFileCompleteXFTP(_, ci, _): + case let .result(.sndFileCompleteXFTP(_, ci, _)): guard isMessage(for: ci) else { continue } if isGroupChat { await MainActor.run { bottomBar = .loadingSpinner } } await ch.completeFile() if await !ch.isRunning { break } - case let .chatItemsStatusesUpdated(_, chatItems): + case let .result(.chatItemsStatusesUpdated(_, chatItems)): guard let ci = chatItems.last else { continue } guard isMessage(for: ci) else { continue } if let (title, message) = ci.chatItem.meta.itemStatus.statusInfo { @@ -343,15 +343,15 @@ class ShareModel: ObservableObject { } } } - case let .sndFileError(_, ci, _, errorMessage): + case let .result(.sndFileError(_, ci, _, errorMessage)): guard isMessage(for: ci) else { continue } if let ci { cleanupFile(ci) } return ErrorAlert(title: "File error", message: "\(fileErrorInfo(ci) ?? errorMessage)") - case let .sndFileWarning(_, ci, _, errorMessage): + case let .result(.sndFileWarning(_, ci, _, errorMessage)): guard isMessage(for: ci) else { continue } if let ci { cleanupFile(ci) } return ErrorAlert(title: "File error", message: "\(fileErrorInfo(ci) ?? errorMessage)") - case let .chatError(_, chatError): + case let .error(chatError): return ErrorAlert(chatError) default: continue } diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 0baf52b26c..0dd3483fd7 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -46,7 +46,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio var cConfirm = confirm.rawValue.cString(using: .utf8)! // the last parameter of chat_migrate_init is used to return the pointer to chat controller let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, backgroundMode ? 1 : 0, &chatController)! - let dbRes = dbMigrationResult(fromCString(cjson)) + let dbRes = dbMigrationResult(dataFromCString(cjson)) let encrypted = dbKey != "" let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey) let result = (encrypted, keychainErr ? .errorKeychain : dbRes) @@ -63,7 +63,7 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation var cKey = dbKey.cString(using: .utf8)! var cConfirm = confirmation.rawValue.cString(using: .utf8)! let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)! - return (dbMigrationResult(fromCString(cjson)), temporaryController) + return (dbMigrationResult(dataFromCString(cjson)), temporaryController) } public func chatInitControllerRemovingDatabases() { @@ -110,27 +110,42 @@ public func resetChatCtrl() { migrationResult = nil } -public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil) -> CR { - var c = cmd.cmdString.cString(using: .utf8)! - let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)! - return CR.chatResponse(fromCString(cjson)) +@inline(__always) +public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil) -> APIResult { + if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl) { + decodeAPIResult(d) + } else { + APIResult.error(.invalidJSON(json: nil)) + } +} + +@inline(__always) +public func sendSimpleXCmdStr(_ cmd: String, _ ctrl: chat_ctrl? = nil) -> Data? { + var c = cmd.cString(using: .utf8)! + return if let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c) { + dataFromCString(cjson) + } else { + nil + } } // in microseconds public let MESSAGE_TIMEOUT: Int32 = 15_000_000 -public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> CEvt? { - if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout) { - let s = fromCString(cjson) - return s == "" ? nil : CEvt.chatEvent(s) +@inline(__always) +public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> APIResult? { + if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout), + let d = dataFromCString(cjson) { + decodeAPIResult(d) + } else { + nil } - return nil } public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_markdown(&c) { - if let d = fromCString(cjson).data(using: .utf8) { + if let d = dataFromCString(cjson) { do { let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) return r.formattedText @@ -154,7 +169,7 @@ struct ParsedMarkdown: Decodable { public func parseServerAddress(_ s: String) -> ServerAddress? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_server(&c) { - if let d = fromCString(cjson).data(using: .utf8) { + if let d = dataFromCString(cjson) { do { let r = try jsonDecoder.decode(ParsedServerAddress.self, from: d) return r.serverAddress @@ -171,12 +186,33 @@ struct ParsedServerAddress: Decodable { var parseError: String } +@inline(__always) public func fromCString(_ c: UnsafeMutablePointer) -> String { let s = String.init(cString: c) free(c) return s } +@inline(__always) +public func dataFromCString(_ c: UnsafeMutablePointer) -> Data? { + let len = strlen(c) + if len > 0 { + return Data(bytesNoCopy: c, count: len, deallocator: .free) + } else { + free(c) + return nil + } +} + +@inline(__always) +public func dataToString(_ d: Data?) -> String { + if let d { + String(data: d, encoding: .utf8) ?? "invalid string" + } else { + "no data" + } +} + public func decodeUser_(_ jDict: NSDictionary) -> UserRef? { if let user_ = jDict["user_"] { try? decodeObject(user_ as Any) @@ -185,7 +221,7 @@ public func decodeUser_(_ jDict: NSDictionary) -> UserRef? { } } -public func errorJson(_ jDict: NSDictionary) -> String? { +public func errorJson(_ jDict: NSDictionary) -> Data? { if let chatError = jDict["chatError"] { serializeJSON(chatError) } else { @@ -197,7 +233,11 @@ public func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatD let jChatDict = jChat as! NSDictionary let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!) let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!) - let navInfo: NavigationInfo = jNavInfo == nil ? NavigationInfo() : try decodeObject((jNavInfo as! NSDictionary)["navInfo"]!) + let navInfo: NavigationInfo = if let jNavInfo = jNavInfo as? NSDictionary, let jNav = jNavInfo["navInfo"] { + try decodeObject(jNav) + } else { + NavigationInfo() + } let jChatItems = jChatDict["chatItems"] as! NSArray let chatItems = jChatItems.map { jCI in if let ci: ChatItem = try? decodeObject(jCI) { @@ -206,16 +246,18 @@ public func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatD return ChatItem.invalidJSON( chatDir: decodeProperty(jCI, "chatDir"), meta: decodeProperty(jCI, "meta"), - json: serializeJSON(jCI, options: .prettyPrinted) ?? "" + json: serializeJSON(jCI, options: .prettyPrinted) ) } return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo) } +@inline(__always) public func decodeObject(_ obj: Any) throws -> T { try jsonDecoder.decode(T.self, from: JSONSerialization.data(withJSONObject: obj)) } +@inline(__always) func decodeProperty(_ obj: Any, _ prop: NSString) -> T? { if let jProp = (obj as? NSDictionary)?[prop] { return try? decodeObject(jProp) @@ -223,28 +265,52 @@ func decodeProperty(_ obj: Any, _ prop: NSString) -> T? { return nil } -public func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> String? { - if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) { - return String(decoding: d, as: UTF8.self) +@inline(__always) +func getOWSF(_ obj: NSDictionary, _ prop: NSString) -> (type: String, object: NSDictionary)? { + if let j = obj[prop] as? NSDictionary, j.count == 1 || j.count == 2 { + var type = j.allKeys[0] as? String + if j.count == 2 && type == "_owsf" { + type = j.allKeys[1] as? String + } + if let type { + return (type, j) + } } return nil } +@inline(__always) +public func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> Data? { + if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) { + dataPrefix(d) + } else { + nil + } +} + +let MAX_JSON_VIEW_LENGTH = 2048 + +@inline(__always) +public func dataPrefix(_ d: Data) -> Data { + d.count > MAX_JSON_VIEW_LENGTH + ? Data(d.prefix(MAX_JSON_VIEW_LENGTH)) + : d +} + public func responseError(_ err: Error) -> String { - if let r = err as? ChatRespProtocol { - if let e = r.chatError { - chatErrorString(e) - } else { - "\(String(describing: r.responseType)), details: \(String(describing: r.details))" - } + if let e = err as? ChatError { + chatErrorString(e) } else { String(describing: err) } } public func chatErrorString(_ err: ChatError) -> String { - if case let .invalidJSON(json) = err { return json } - return String(describing: err) + switch err { + case let .invalidJSON(json): dataToString(json) + case let .unexpectedResult(type): "unexpected result: \(type)" + default: String(describing: err) + } } public enum DBMigrationResult: Decodable, Equatable { @@ -283,15 +349,15 @@ public enum MTRError: Decodable, Equatable { case different(appMigration: String, dbMigration: String) } -func dbMigrationResult(_ s: String) -> DBMigrationResult { - let d = s.data(using: .utf8)! -// TODO is there a way to do it without copying the data? e.g: -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - do { - return try jsonDecoder.decode(DBMigrationResult.self, from: d) - } catch let error { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - return .unknown(json: s) +func dbMigrationResult(_ d: Data?) -> DBMigrationResult { + if let d { + do { + return try jsonDecoder.decode(DBMigrationResult.self, from: d) + } catch let error { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + return .unknown(json: dataToString(d)) + } + } else { + return .unknown(json: "no data") } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index f635cfb7bb..b8d2361ac8 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -17,30 +17,117 @@ public protocol ChatCmdProtocol { var cmdString: String { get } } +@inline(__always) public func onOff(_ b: Bool) -> String { b ? "on" : "off" } -public struct APIResponse: Decodable { - public var resp: ChatRespProtocol +public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { + case result(R) + case error(ChatError) + case invalid(type: String, json: Data) + + public var responseType: String { + switch self { + case let .result(r): r.responseType + case let .error(e): "error \(e.errorType)" + case let .invalid(type, _): "* \(type)" + } + } + + public var unexpected: ChatError { + switch self { + case let .result(r): .unexpectedResult(type: r.responseType) + case let .error(e): e + case let .invalid(type, _): .unexpectedResult(type: "* \(type)") + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.result) { + let result = try container.decode(R.self, forKey: .result) + self = .result(result) + } else { + let error = try container.decode(ChatError.self, forKey: .error) + self = .error(error) + } + } + + private enum CodingKeys: String, CodingKey { + case result, error + } } -public protocol ChatRespProtocol: Decodable, Error { +public protocol ChatAPIResult: Decodable { var responseType: String { get } var details: String { get } - static func chatResponse(_ s: String) -> Self - var chatError: ChatError? { get } - var chatErrorType: ChatErrorType? { get } + static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? } -public protocol ChatEventProtocol: Decodable, Error { - var eventType: String { get } - var details: String { get } - static func chatEvent(_ s: String) -> Self - var chatError: ChatError? { get } - var chatErrorType: ChatErrorType? { get } +extension ChatAPIResult { + public var noDetails: String { "\(self.responseType): no details" } + + @inline(__always) + public static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? { + nil + } + + @inline(__always) + public var unexpected: ChatError { + .unexpectedResult(type: self.responseType) + } } +public func decodeAPIResult(_ d: Data) -> APIResult { +// print("decodeAPIResult \(String(describing: R.self))") + do { +// return try withStackSizeLimit { try jsonDecoder.decode(APIResult.self, from: d) } + return try jsonDecoder.decode(APIResult.self, from: d) + } catch {} + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let (_, jErr) = getOWSF(j, "error") { + return APIResult.error(.invalidJSON(json: errorJson(jErr))) as APIResult + } else if let (type, jRes) = getOWSF(j, "result") { + return if let r = R.fallbackResult(type, jRes) { + APIResult.result(r) + } else { + APIResult.invalid(type: type, json: dataPrefix(d)) + } + } + } + return APIResult.invalid(type: "invalid", json: dataPrefix(d)) +} + +// Default stack size for the main thread is 1mb, for secondary threads - 512 kb. +// This function can be used to test what size is used (or to increase available stack size). +// Stack size must be a multiple of system page size (16kb). +//private let stackSizeLimit: Int = 256 * 1024 +// +//private func withStackSizeLimit(_ f: @escaping () throws -> T) throws -> T { +// let semaphore = DispatchSemaphore(value: 0) +// var result: Result? +// let thread = Thread { +// do { +// result = .success(try f()) +// } catch { +// result = .failure(error) +// } +// semaphore.signal() +// } +// +// thread.stackSize = stackSizeLimit +// thread.qualityOfService = Thread.current.qualityOfService +// thread.start() +// +// semaphore.wait() +// +// switch result! { +// case let .success(r): return r +// case let .failure(e): throw e +// } +//} + public func parseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [ChatData])? { if let jApiChats = jResp["apiChats"] as? NSDictionary, let user: UserRef = try? decodeObject(jApiChats["user"] as Any), @@ -49,7 +136,7 @@ public func parseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [Chat if let chatData = try? parseChatData(jChat) { return chatData.0 } - return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "") + return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted)) } return (user, chats) } else { @@ -553,13 +640,26 @@ private func encodeCJSON(_ value: T) -> [CChar] { encodeJSON(value).cString(using: .utf8)! } -public enum ChatError: Decodable, Hashable { +public enum ChatError: Decodable, Hashable, Error { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) case errorDatabase(databaseError: DatabaseError) case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) - case invalidJSON(json: String) + case invalidJSON(json: Data?) // additional case used to pass errors that failed to parse + case unexpectedResult(type: String) // additional case used to pass unexpected responses + + public var errorType: String { + switch self { + case .error: "chat" + case .errorAgent: "agent" + case .errorStore: "store" + case .errorDatabase: "database" + case .errorRemoteCtrl: "remoteCtrl" + case .invalidJSON: "invalid" + case let .unexpectedResult(type): "! \(type)" + } + } } public enum ChatErrorType: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 35c276b2f4..bff1ebb4fd 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1201,7 +1201,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case local(noteFolder: NoteFolder) case contactRequest(contactRequest: UserContactRequest) case contactConnection(contactConnection: PendingContactConnection) - case invalidJSON(json: String) + case invalidJSON(json: Data?) private static let invalidChatName = NSLocalizedString("invalid chat", comment: "invalid chat data") @@ -1589,7 +1589,7 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { self.chatStats = chatStats } - public static func invalidJSON(_ json: String) -> ChatData { + public static func invalidJSON(_ json: Data?) -> ChatData { ChatData( chatInfo: .invalidJSON(json: json), chatItems: [], @@ -2905,7 +2905,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { return item } - public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem { + public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: Data?) -> ChatItem { ChatItem( chatDir: chatDir ?? .directSnd, meta: meta ?? .invalidJSON, @@ -3352,7 +3352,7 @@ public enum CIContent: Decodable, ItemContent, Hashable { case rcvDirectE2EEInfo(e2eeInfo: E2EEInfo) case sndGroupE2EEInfo(e2eeInfo: E2EEInfo) case rcvGroupE2EEInfo(e2eeInfo: E2EEInfo) - case invalidJSON(json: String) + case invalidJSON(json: Data?) public var text: String { get { diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index 0e539ba97c..dfe833f832 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -18,10 +18,10 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { memcpy(ptr, (data as NSData).bytes, data.count) var cPath = path.cString(using: .utf8)! let cjson = chat_write_file(getChatCtrl(), &cPath, ptr, Int32(data.count))! - let d = fromCString(cjson).data(using: .utf8)! + let d = dataFromCString(cjson)! // TODO [unsafe] switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs - case let .error(err): throw RuntimeError(err) + case let .error(err): throw RuntimeError(err) // TODO [unsafe] } } @@ -51,10 +51,10 @@ public func encryptCryptoFile(fromPath: String, toPath: String) throws -> Crypto var cFromPath = fromPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)! let cjson = chat_encrypt_file(getChatCtrl(), &cFromPath, &cToPath)! - let d = fromCString(cjson).data(using: .utf8)! + let d = dataFromCString(cjson)! // TODO [unsafe] switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs - case let .error(err): throw RuntimeError(err) + case let .error(err): throw RuntimeError(err) // TODO [unsafe] } } diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift index c99e004d92..a433d2313b 100644 --- a/apps/ios/SimpleXChat/ErrorAlert.swift +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -37,22 +37,18 @@ public struct ErrorAlert: Error { } public init(_ error: any Error) { - self = if let chatResponse = error as? ChatRespProtocol { - ErrorAlert(chatResponse) + self = if let e = error as? ChatError { + ErrorAlert(e) } else { ErrorAlert("\(error.localizedDescription)") } } public init(_ chatError: ChatError) { - self = ErrorAlert("\(chatErrorString(chatError))") - } - - public init(_ chatResponse: ChatRespProtocol) { - self = if let networkErrorAlert = getNetworkErrorAlert(chatResponse) { + self = if let networkErrorAlert = getNetworkErrorAlert(chatError) { networkErrorAlert } else { - ErrorAlert("\(responseError(chatResponse))") + ErrorAlert("\(chatErrorString(chatError))") } } } @@ -94,8 +90,8 @@ extension View { } } -public func getNetworkErrorAlert(_ r: ChatRespProtocol) -> ErrorAlert? { - switch r.chatError { +public func getNetworkErrorAlert(_ e: ChatError) -> ErrorAlert? { + switch e { case let .errorAgent(.BROKER(addr, .TIMEOUT)): ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") case let .errorAgent(.BROKER(addr, .NETWORK)): diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 2d2829f1f2..bacdfe70af 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -229,5 +229,5 @@ fun isMediaIntent(intent: Intent): Boolean = // val str: String = """ // """.trimIndent() // -// println(json.decodeFromString(str)) +// println(json.decodeFromString(str)) //} 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 47d9563ee3..6660cbbb93 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 @@ -38,6 +38,7 @@ import java.net.URI import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* +import java.util.concurrent.atomic.AtomicLong import kotlin.collections.ArrayList import kotlin.random.Random import kotlin.time.* @@ -1396,19 +1397,21 @@ sealed class ChatInfo: SomeChat, NamedChat { } @Serializable @SerialName("invalidJSON") - class InvalidJSON(val json: String): ChatInfo() { + class InvalidJSON( + val json: String, + override val apiId: Long = -idGenerator.getAndIncrement(), + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = Clock.System.now() + ): ChatInfo() { override val chatType get() = ChatType.Direct override val localDisplayName get() = invalidChatName - override val id get() = "" - override val apiId get() = 0L + override val id get() = "?$apiId" override val ready get() = false override val chatDeleted get() = false override val sendMsgEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null - override val createdAt get() = Clock.System.now() - override val updatedAt get() = Clock.System.now() override val displayName get() = invalidChatName override val fullName get() = invalidChatName override val image get() = null @@ -1416,6 +1419,7 @@ sealed class ChatInfo: SomeChat, NamedChat { companion object { private val invalidChatName = generalGetString(MR.strings.invalid_chat) + private val idGenerator = AtomicLong(0) } } 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 8c1166dccd..7025e4caf3 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 @@ -47,6 +47,9 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* import kotlinx.serialization.builtins.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.util.Date @@ -465,7 +468,7 @@ object ChatController { var ctrl: ChatCtrl? = -1 val appPrefs: AppPreferences by lazy { AppPreferences() } - val messagesChannel: Channel = Channel() + val messagesChannel: Channel = Channel() val chatModel = ChatModel private var receiverStarted = false @@ -478,8 +481,7 @@ object ChatController { val userId = currentUserId("getAgentSubsTotal") val r = sendCmd(rh, CC.GetAgentSubsTotal(userId), log = false) - - if (r is CR.AgentSubsTotal) return r.subsTotal to r.hasSession + if (r is API.Result && r.res is CR.AgentSubsTotal) return r.res.subsTotal to r.res.hasSession Log.e(TAG, "getAgentSubsTotal bad response: ${r.responseType} ${r.details}") return null } @@ -488,8 +490,7 @@ object ChatController { val userId = currentUserId("getAgentServersSummary") val r = sendCmd(rh, CC.GetAgentServersSummary(userId), log = false) - - if (r is CR.AgentServersSummary) return r.serversSummary + if (r is API.Result && r.res is CR.AgentServersSummary) return r.res.serversSummary Log.e(TAG, "getAgentServersSummary bad response: ${r.responseType} ${r.details}") return null } @@ -641,11 +642,11 @@ object ChatController { messagesChannel.trySend(msg) } if (finishedWithoutTimeout == null) { - Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + Log.e(TAG, "Timeout reached while processing received message: " + msg.responseType) if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.possible_slow_function_title), - text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.responseType + "\n" + Exception().stackTraceToString()), shareText = true ) } @@ -661,7 +662,7 @@ object ChatController { } } - suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, log: Boolean = true): CR { + suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, log: Boolean = true): API { val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { @@ -670,37 +671,36 @@ object ChatController { chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") } - val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) + val rStr = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) // coroutine was cancelled already, no need to process response (helps with apiListMembers - very heavy query in large groups) interruptIfCancelled() - val r = APIResponse.decodeStr(json) + val r = json.decodeFromString(rStr) if (log) { - Log.d(TAG, "sendCmd response type ${r.resp.responseType}") - if (r.resp is CR.Response || r.resp is CR.Invalid) { - Log.d(TAG, "sendCmd response json $json") + Log.d(TAG, "sendCmd response type ${r.responseType}") + if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) { + Log.d(TAG, "sendCmd response json $rStr") } - chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) } - r.resp + r } } - fun recvMsg(ctrl: ChatCtrl): APIResponse? { - val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) - return if (json == "") { + fun recvMsg(ctrl: ChatCtrl): API? { + val rStr = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) + return if (rStr == "") { null } else { - val apiResp = APIResponse.decodeStr(json) - val r = apiResp.resp + val r = json.decodeFromString(rStr) Log.d(TAG, "chatRecvMsg: ${r.responseType}") - if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json") - apiResp + if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr") + r } } suspend fun apiGetActiveUser(rh: Long?, ctrl: ChatCtrl? = null): User? { val r = sendCmd(rh, CC.ShowActiveUser(), ctrl) - if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") if (rh == null) { chatModel.localUserCreated.value = false @@ -710,14 +710,15 @@ object ChatController { suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? { val r = sendCmd(rh, CC.CreateActiveUser(p, pastTimestamp = pastTimestamp), ctrl) - if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) - else if ( - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.UserExists + if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) + val e = (r as? API.Error)?.err + if ( + e is ChatError.ChatErrorStore && e.storeError is StoreError.DuplicateName || + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UserExists ) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) } else if ( - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidDisplayName + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.InvalidDisplayName ) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_invalid_title), generalGetString(MR.strings.failed_to_create_user_invalid_desc)) } else { @@ -729,8 +730,8 @@ object ChatController { suspend fun listUsers(rh: Long?): List { val r = sendCmd(rh, CC.ListUsers()) - if (r is CR.UsersList) { - val users = if (rh == null) r.users else r.users.map { it.copy(user = it.user.copy(remoteHostId = rh)) } + if (r is API.Result && r.res is CR.UsersList) { + val users = if (rh == null) r.res.users else r.res.users.map { it.copy(user = it.user.copy(remoteHostId = rh)) } return users.sortedBy { it.user.chatViewName } } Log.d(TAG, "listUsers: ${r.responseType} ${r.details}") @@ -739,26 +740,26 @@ object ChatController { suspend fun apiSetActiveUser(rh: Long?, userId: Long, viewPwd: String?): User { val r = sendCmd(rh, CC.ApiSetActiveUser(userId, viewPwd)) - if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } suspend fun apiSetAllContactReceipts(rh: Long?, enable: Boolean) { val r = sendCmd(rh, CC.SetAllContactReceipts(enable)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}") } suspend fun apiSetUserContactReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { val r = sendCmd(u.remoteHostId, CC.ApiSetUserContactReceipts(u.userId, userMsgReceiptSettings)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}") } suspend fun apiSetUserGroupReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { val r = sendCmd(u.remoteHostId, CC.ApiSetUserGroupReceipts(u.userId, userMsgReceiptSettings)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}") } @@ -776,20 +777,20 @@ object ChatController { private suspend fun setUserPrivacy(rh: Long?, cmd: CC): User { val r = sendCmd(rh, cmd) - if (r is CR.UserPrivacy) return r.updatedUser.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.UserPrivacy) return r.res.updatedUser.updateRemoteHostId(rh) else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } suspend fun apiDeleteUser(u: User, delSMPQueues: Boolean, viewPwd: String?) { val r = sendCmd(u.remoteHostId, CC.ApiDeleteUser(u.userId, delSMPQueues, viewPwd)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}") throw Exception("failed to delete the user ${r.responseType} ${r.details}") } suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(null, CC.StartChat(mainApp = true), ctrl) - when (r) { + when (r.result) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false else -> throw Exception("failed starting chat: ${r.responseType} ${r.details}") @@ -798,7 +799,7 @@ object ChatController { private suspend fun apiCheckChatRunning(): Boolean { val r = sendCmd(null, CC.CheckChatRunning()) - when (r) { + when (r.result) { is CR.ChatRunning -> return true is CR.ChatStopped -> return false else -> throw Exception("failed check chat running: ${r.responseType} ${r.details}") @@ -807,15 +808,13 @@ object ChatController { suspend fun apiStopChat(): Boolean { val r = sendCmd(null, CC.ApiStopChat()) - when (r) { - is CR.ChatStopped -> return true - else -> throw Exception("failed stopping chat: ${r.responseType} ${r.details}") - } + if (r.result is CR.ChatStopped) return true + throw Exception("failed stopping chat: ${r.responseType} ${r.details}") } suspend fun apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, remoteHostsFolder: String, ctrl: ChatCtrl? = null) { val r = sendCmd(null, CC.ApiSetAppFilePaths(filesFolder, tempFolder, assetsFolder, remoteHostsFolder), ctrl) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set app file paths: ${r.responseType} ${r.details}") } @@ -823,52 +822,52 @@ object ChatController { suspend fun apiSaveAppSettings(settings: AppSettings) { val r = sendCmd(null, CC.ApiSaveSettings(settings)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set app settings: ${r.responseType} ${r.details}") } suspend fun apiGetAppSettings(settings: AppSettings): AppSettings { val r = sendCmd(null, CC.ApiGetSettings(settings)) - if (r is CR.AppSettingsR) return r.appSettings + if (r is API.Result && r.res is CR.AppSettingsR) return r.res.appSettings throw Exception("failed to get app settings: ${r.responseType} ${r.details}") } suspend fun apiExportArchive(config: ArchiveConfig): List { val r = sendCmd(null, CC.ApiExportArchive(config)) - if (r is CR.ArchiveExported) return r.archiveErrors + if (r is API.Result && r.res is CR.ArchiveExported) return r.res.archiveErrors throw Exception("failed to export archive: ${r.responseType} ${r.details}") } suspend fun apiImportArchive(config: ArchiveConfig): List { val r = sendCmd(null, CC.ApiImportArchive(config)) - if (r is CR.ArchiveImported) return r.archiveErrors + if (r is API.Result && r.res is CR.ArchiveImported) return r.res.archiveErrors throw Exception("failed to import archive: ${r.responseType} ${r.details}") } suspend fun apiDeleteStorage() { val r = sendCmd(null, CC.ApiDeleteStorage()) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to delete storage: ${r.responseType} ${r.details}") } - suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? { + suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): ChatError? { val r = sendCmd(null, CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) - if (r is CR.CmdOk) return null - else if (r is CR.ChatCmdError) return r + if (r.result is CR.CmdOk) return null + else if (r is API.Error) return r.err throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): CR.ChatCmdError? { + suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): ChatError? { val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl) - if (r is CR.CmdOk) return null - else if (r is CR.ChatCmdError) return r + if (r.result is CR.CmdOk) return null + else if (r is API.Error) return r.err throw Exception("failed to test storage encryption: ${r.responseType} ${r.details}") } suspend fun apiGetChats(rh: Long?): List { val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() } val r = sendCmd(rh, CC.ApiGetChats(userId)) - if (r is CR.ApiChats) return if (rh == null) r.chats else r.chats.map { it.copy(remoteHostId = rh) } + if (r is API.Result && r.res is CR.ApiChats) return if (rh == null) r.res.chats else r.res.chats.map { it.copy(remoteHostId = rh) } Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chats_title), generalGetString(MR.strings.contact_developers)) return emptyList() @@ -877,8 +876,7 @@ object ChatController { private suspend fun apiGetChatTags(rh: Long?): List?{ val userId = currentUserId("apiGetChatTags") val r = sendCmd(rh, CC.ApiGetChatTags(userId)) - - if (r is CR.ChatTags) return r.userTags + if (r is API.Result && r.res is CR.ChatTags) return r.res.userTags Log.e(TAG, "apiGetChatTags bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_chat_tags), "${r.responseType}: ${r.details}") return null @@ -886,9 +884,10 @@ object ChatController { 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)) - if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo + if (r is API.Result && r.res is CR.ApiChat) return if (rh == null) r.res.chat to r.res.navInfo else r.res.chat.copy(remoteHostId = rh) to r.res.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) { + val e = (r as? API.Error)?.err + if (pagination is ChatPagination.Around && e is ChatError.ChatErrorStore && e.storeError is StoreError.ChatItemNotFound) { showQuotedItemDoesNotExistAlert() } else { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) @@ -898,7 +897,7 @@ object ChatController { suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List? { val r = sendCmd(rh, CC.ApiCreateChatTag(tag)) - if (r is CR.ChatTags) return r.userTags + if (r is API.Result && r.res is CR.ChatTags) return r.res.userTags Log.e(TAG, "apiCreateChatTag bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_creating_chat_tags), "${r.responseType}: ${r.details}") return null @@ -906,7 +905,7 @@ object ChatController { suspend fun apiSetChatTags(rh: Long?, type: ChatType, id: Long, tagIds: List): Pair, List>? { val r = sendCmd(rh, CC.ApiSetChatTags(type, id, tagIds)) - if (r is CR.TagsUpdated) return r.userTags to r.chatTags + if (r is API.Result && r.res is CR.TagsUpdated) return r.res.userTags to r.res.chatTags Log.e(TAG, "apiSetChatTags bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_updating_chat_tags), "${r.responseType}: ${r.details}") return null @@ -926,8 +925,8 @@ object ChatController { private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): List? { val r = sendCmd(rh, cmd) return when { - r is CR.NewChatItems -> r.chatItems - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg && cmd is CC.ApiSendMessages -> { + r is API.Result && r.res is CR.NewChatItems -> r.res.chatItems + r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.LargeMsg && cmd is CC.ApiSendMessages -> { val mc = cmd.composedMessages.last().msgContent AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), @@ -939,7 +938,7 @@ object ChatController { ) null } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg && cmd is CC.ApiForwardChatItems -> { + r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.LargeMsg && cmd is CC.ApiForwardChatItems -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), generalGetString(MR.strings.maximum_message_size_reached_forwarding) @@ -954,37 +953,27 @@ object ChatController { } } } - suspend fun apiCreateChatItems(rh: Long?, noteFolderId: Long, composedMessages: List): List? { + + suspend fun apiCreateChatItems(rh: Long?, noteFolderId: Long, composedMessages: List): List? { val cmd = CC.ApiCreateChatItems(noteFolderId, composedMessages) val r = sendCmd(rh, cmd) - return when (r) { - is CR.NewChatItems -> r.chatItems - else -> { - apiErrorAlert("apiCreateChatItems", generalGetString(MR.strings.error_creating_message), r) - null - } - } + if (r is API.Result && r.res is CR.NewChatItems) return r.res.chatItems + apiErrorAlert("apiCreateChatItems", generalGetString(MR.strings.error_creating_message), r) + return null } suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List? { val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText)) - return when (r) { - is CR.NewChatItems -> r.chatItems - else -> { - apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) - null - } - } + if (r is API.Result && r.res is CR.NewChatItems) r.res.chatItems + apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) + return null } suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { - return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { - is CR.ApiChatItemInfo -> r.chatItemInfo - else -> { - apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) - null - } - } + val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId)) + if (r is API.Result && r.res is CR.ApiChatItemInfo) return r.res.chatItemInfo + apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) + return null } suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemIds: List, ttl: Int?): List? { @@ -993,21 +982,18 @@ object ChatController { } suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, chatItemIds: List): CR.ForwardPlan? { - return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds))) { - is CR.ForwardPlan -> r - else -> { - apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r) - null - } - } + val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds)) + if (r is API.Result && r.res is CR.ForwardPlan) return r.res + apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r) + return null } 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)) when { - r is CR.ChatItemUpdated -> return r.chatItem - r is CR.ChatItemNotChanged -> return r.chatItem - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> { + r is API.Result && r.res is CR.ChatItemUpdated -> return r.res.chatItem + r is API.Result && r.res is CR.ChatItemNotChanged -> return r.res.chatItem + r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.LargeMsg -> { val mc = updatedMessage.msgContent AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), @@ -1027,7 +1013,7 @@ object ChatController { 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)) - if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem + if (r is API.Result && r.res is CR.ChatItemReaction) return r.res.reaction.chatReaction.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } @@ -1035,35 +1021,35 @@ object ChatController { suspend fun apiGetReactionMembers(rh: Long?, groupId: Long, itemId: Long, reaction: MsgReaction): List? { val userId = currentUserId("apiGetReactionMembers") val r = sendCmd(rh, CC.ApiGetReactionMembers(userId, groupId, itemId, reaction)) - if (r is CR.ReactionMembers) return r.memberReactions + if (r is API.Result && r.res is CR.ReactionMembers) return r.res.memberReactions Log.e(TAG, "apiGetReactionMembers bad response: ${r.responseType} ${r.details}") 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)) - if (r is CR.ChatItemsDeleted) return r.chatItemDeletions + if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null } suspend fun apiDeleteMemberChatItems(rh: Long?, groupId: Long, itemIds: List): List? { val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, itemIds)) - if (r is CR.ChatItemsDeleted) return r.chatItemDeletions + if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") return null } suspend fun apiArchiveReceivedReports(rh: Long?, groupId: Long): CR.GroupChatItemsDeleted? { val r = sendCmd(rh, CC.ApiArchiveReceivedReports(groupId)) - if (r is CR.GroupChatItemsDeleted) return r + if (r is API.Result && r.res is CR.GroupChatItemsDeleted) return r.res Log.e(TAG, "apiArchiveReceivedReports bad response: ${r.responseType} ${r.details}") return null } suspend fun apiDeleteReceivedReports(rh: Long?, groupId: Long, itemIds: List, mode: CIDeleteMode): List? { val r = sendCmd(rh, CC.ApiDeleteReceivedReports(groupId, itemIds, mode)) - if (r is CR.ChatItemsDeleted) return r.chatItemDeletions + if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions Log.e(TAG, "apiDeleteReceivedReports bad response: ${r.responseType} ${r.details}") return null } @@ -1071,121 +1057,84 @@ object ChatController { suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) - return when (r) { - is CR.ServerTestResult -> r.testFailure - else -> { - Log.e(TAG, "testProtoServer bad response: ${r.responseType} ${r.details}") - throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") - } - } + if (r is API.Result && r.res is CR.ServerTestResult) return r.res.testFailure + Log.e(TAG, "testProtoServer bad response: ${r.responseType} ${r.details}") + throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") } suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiGetServerOperators()) - - return when (r) { - is CR.ServerOperatorConditions -> r.conditions - else -> { - Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions + Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}") + return null } suspend fun setServerOperators(rh: Long?, operators: List): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiSetServerOperators(operators)) - return when (r) { - is CR.ServerOperatorConditions -> r.conditions - else -> { - Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions + Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}") + return null } suspend fun getUserServers(rh: Long?): List? { val userId = currentUserId("getUserServers") val r = sendCmd(rh, CC.ApiGetUserServers(userId)) - return when (r) { - is CR.UserServers -> r.userServers - else -> { - Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.UserServers) return r.res.userServers + Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}") + return null } suspend fun setUserServers(rh: Long?, userServers: List): Boolean { val userId = currentUserId("setUserServers") val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers)) - return when (r) { - is CR.CmdOk -> true - else -> { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.failed_to_save_servers), - "${r.responseType}: ${r.details}" - ) - Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}") - false - } - } + if (r.result is CR.CmdOk) return true + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.failed_to_save_servers), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}") + return false } suspend fun validateServers(rh: Long?, userServers: List): List? { val userId = currentUserId("validateServers") val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) - return when (r) { - is CR.UserServersValidation -> r.serverErrors - else -> { - Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.UserServersValidation) return r.res.serverErrors + Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") + return null } suspend fun getUsageConditions(rh: Long?): Triple? { val r = sendCmd(rh, CC.ApiGetUsageConditions()) - return when (r) { - is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions) - else -> { - Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.UsageConditions) return Triple(r.res.usageConditions, r.res.conditionsText, r.res.acceptedConditions) + Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}") + return null } suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean { val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId)) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}") - false - } - } + if (r.result is CR.CmdOk) return true + Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}") + return false } suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds)) - return when (r) { - is CR.ServerOperatorConditions -> r.conditions - else -> { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.error_accepting_operator_conditions), - "${r.responseType}: ${r.details}" - ) - Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}") - null - } - } + if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_accepting_operator_conditions), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}") + return null } suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) - if (r is CR.ChatItemTTL) { - return if (r.chatItemTTL != null) { - ChatItemTTL.fromSeconds(r.chatItemTTL) + if (r is API.Result && r.res is CR.ChatItemTTL) { + return if (r.res.chatItemTTL != null) { + ChatItemTTL.fromSeconds(r.res.chatItemTTL) } else { ChatItemTTL.None } @@ -1196,37 +1145,32 @@ object ChatController { suspend fun setChatItemTTL(rh: Long?, chatItemTTL: ChatItemTTL) { val userId = currentUserId("setChatItemTTL") val r = sendCmd(rh, CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } suspend fun setChatTTL(rh: Long?, chatType: ChatType, id: Long, chatItemTTL: ChatItemTTL?) { val userId = currentUserId("setChatTTL") val r = sendCmd(rh, CC.APISetChatTTL(userId, chatType, id, chatItemTTL?.seconds)) - if (r is CR.CmdOk) return + if (r.result is CR.CmdOk) return throw Exception("failed to set chat TTL: ${r.responseType} ${r.details}") } suspend fun apiSetNetworkConfig(cfg: NetCfg, showAlertOnError: Boolean = true, ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}") - if (showAlertOnError) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.error_setting_network_config), - "${r.responseType}: ${r.details}" - ) - } - false - } + if (r.result is CR.CmdOk) return true + Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}") + if (showAlertOnError) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_setting_network_config), + "${r.responseType}: ${r.details}" + ) } + return false } suspend fun reconnectServer(rh: Long?, server: String): Boolean { val userId = currentUserId("reconnectServer") - return sendCommandOkResp(rh, CC.ReconnectServer(userId, server)) } @@ -1234,13 +1178,9 @@ object ChatController { suspend fun apiSetSettings(rh: Long?, type: ChatType, id: Long, settings: ChatSettings): Boolean { val r = sendCmd(rh, CC.APISetChatSettings(type, id, settings)) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}") - false - } - } + if (r.result is CR.CmdOk) return true + Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}") + return false } suspend fun apiSetNetworkInfo(networkInfo: UserNetworkInfo): Boolean = @@ -1251,151 +1191,135 @@ object ChatController { suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIContactInfo(contactId)) - if (r is CR.ContactInfo) return r.connectionStats_ to r.customUserProfile + if (r is API.Result && r.res is CR.ContactInfo) return r.res.connectionStats_ to r.res.customUserProfile Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") return null } suspend fun apiGroupMemberInfo(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIGroupMemberInfo(groupId, groupMemberId)) - if (r is CR.GroupMemberInfo) return Pair(r.member, r.connectionStats_) + if (r is API.Result && r.res is CR.GroupMemberInfo) return r.res.member to r.res.connectionStats_ Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") return null } suspend fun apiContactQueueInfo(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIContactQueueInfo(contactId)) - if (r is CR.QueueInfoR) return Pair(r.rcvMsgInfo, r.queueInfo) + if (r is API.Result && r.res is CR.QueueInfoR) return r.res.rcvMsgInfo to r.res.queueInfo apiErrorAlert("apiContactQueueInfo", generalGetString(MR.strings.error), r) return null } suspend fun apiGroupMemberQueueInfo(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIGroupMemberQueueInfo(groupId, groupMemberId)) - if (r is CR.QueueInfoR) return Pair(r.rcvMsgInfo, r.queueInfo) + if (r is API.Result && r.res is CR.QueueInfoR) return r.res.rcvMsgInfo to r.res.queueInfo apiErrorAlert("apiGroupMemberQueueInfo", generalGetString(MR.strings.error), r) return null } suspend fun apiSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { val r = sendCmd(rh, CC.APISwitchContact(contactId)) - if (r is CR.ContactSwitchStarted) return r.connectionStats + if (r is API.Result && r.res is CR.ContactSwitchStarted) return r.res.connectionStats apiErrorAlert("apiSwitchContact", generalGetString(MR.strings.error_changing_address), r) return null } suspend fun apiSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APISwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchStarted) return Pair(r.member, r.connectionStats) + if (r is API.Result && r.res is CR.GroupMemberSwitchStarted) return r.res.member to r.res.connectionStats apiErrorAlert("apiSwitchGroupMember", generalGetString(MR.strings.error_changing_address), r) return null } suspend fun apiAbortSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { val r = sendCmd(rh, CC.APIAbortSwitchContact(contactId)) - if (r is CR.ContactSwitchAborted) return r.connectionStats + if (r is API.Result && r.res is CR.ContactSwitchAborted) return r.res.connectionStats apiErrorAlert("apiAbortSwitchContact", generalGetString(MR.strings.error_aborting_address_change), r) return null } suspend fun apiAbortSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchAborted) return Pair(r.member, r.connectionStats) + if (r is API.Result && r.res is CR.GroupMemberSwitchAborted) return r.res.member to r.res.connectionStats apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(MR.strings.error_aborting_address_change), r) return null } suspend fun apiSyncContactRatchet(rh: Long?, contactId: Long, force: Boolean): ConnectionStats? { val r = sendCmd(rh, CC.APISyncContactRatchet(contactId, force)) - if (r is CR.ContactRatchetSyncStarted) return r.connectionStats + if (r is API.Result && r.res is CR.ContactRatchetSyncStarted) return r.res.connectionStats apiErrorAlert("apiSyncContactRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } suspend fun apiSyncGroupMemberRatchet(rh: Long?, groupId: Long, groupMemberId: Long, force: Boolean): Pair? { val r = sendCmd(rh, CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) - if (r is CR.GroupMemberRatchetSyncStarted) return Pair(r.member, r.connectionStats) + if (r is API.Result && r.res is CR.GroupMemberRatchetSyncStarted) return r.res.member to r.res.connectionStats apiErrorAlert("apiSyncGroupMemberRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } suspend fun apiGetContactCode(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIGetContactCode(contactId)) - if (r is CR.ContactCode) return r.contact to r.connectionCode + if (r is API.Result && r.res is CR.ContactCode) return r.res.contact to r.res.connectionCode Log.e(TAG,"failed to get contact code: ${r.responseType} ${r.details}") return null } suspend fun apiGetGroupMemberCode(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(rh, CC.APIGetGroupMemberCode(groupId, groupMemberId)) - if (r is CR.GroupMemberCode) return r.member to r.connectionCode + if (r is API.Result && r.res is CR.GroupMemberCode) return r.res.member to r.res.connectionCode Log.e(TAG,"failed to get group member code: ${r.responseType} ${r.details}") return null } suspend fun apiVerifyContact(rh: Long?, contactId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(rh, CC.APIVerifyContact(contactId, connectionCode))) { - is CR.ConnectionVerified -> r.verified to r.expectedCode - else -> null - } + val r = sendCmd(rh, CC.APIVerifyContact(contactId, connectionCode)) + if (r is API.Result && r.res is CR.ConnectionVerified) return r.res.verified to r.res.expectedCode + Log.e(TAG, "apiVerifyContact bad response: ${r.responseType} ${r.details}") + return null } suspend fun apiVerifyGroupMember(rh: Long?, groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(rh, CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { - is CR.ConnectionVerified -> r.verified to r.expectedCode - else -> null - } + val r = sendCmd(rh, CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode)) + if (r is API.Result && r.res is CR.ConnectionVerified) return r.res.verified to r.res.expectedCode + Log.e(TAG, "apiVerifyGroupMember bad response: ${r.responseType} ${r.details}") + return null } - - suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> { val userId = try { currentUserId("apiAddContact") } catch (e: Exception) { return null to null } val short = appPrefs.privacyShortLinks.get() val r = sendCmd(rh, CC.APIAddContact(userId, short = short, incognito = incognito)) - return when (r) { - is CR.Invitation -> (r.connLinkInvitation to r.connection) to null - else -> { - if (!(networkErrorAlert(r))) { - return null to { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) } - } - null to null - } + return when { + r is API.Result && r.res is CR.Invitation -> (r.res.connLinkInvitation to r.res.connection) to null + !(networkErrorAlert(r)) -> null to { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) } + else -> null to null } } suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito)) - - return when (r) { - is CR.ConnectionIncognitoUpdated -> r.toConnection - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r) - } - null - } + if (r is API.Result && r.res is CR.ConnectionIncognitoUpdated) return r.res.toConnection + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r) } + return null } suspend fun apiChangeConnectionUser(rh: Long?, connId: Long, userId: Long): PendingContactConnection? { val r = sendCmd(rh, CC.ApiChangeConnectionUser(connId, userId)) - - return when (r) { - is CR.ConnectionUserChanged -> r.toConnection - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r) - } - null - } + if (r is API.Result && r.res is CR.ConnectionUserChanged) return r.res.toConnection + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r) } + return null } suspend fun apiConnectPlan(rh: Long?, connLink: String): Pair? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } val r = sendCmd(rh, CC.APIConnectPlan(userId, connLink)) - if (r is CR.CRConnectionPlan) return r.connLink to r.connectionPlan + if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan apiConnectResponseAlert(r) return null } @@ -1404,53 +1328,53 @@ object ChatController { val userId = try { currentUserId("apiConnect") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.APIConnect(userId, incognito, connLink)) when { - r is CR.SentConfirmation -> return r.connection - r is CR.SentInvitation -> return r.connection - r is CR.ContactAlreadyExists -> + r is API.Result && r.res is CR.SentConfirmation -> return r.res.connection + r is API.Result && r.res is CR.SentInvitation -> return r.res.connection + r is API.Result && r.res is CR.ContactAlreadyExists -> AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.res.contact.displayName) ) else -> apiConnectResponseAlert(r) } return null } - private fun apiConnectResponseAlert(r: CR) { + private fun apiConnectResponseAlert(r: API) { when { - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat - && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { + r is API.Error && r.err is ChatError.ChatErrorChat + && r.err.errorType is ChatErrorType.InvalidConnReq -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.invalid_connection_link), generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one) ) } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat - && r.chatError.errorType is ChatErrorType.UnsupportedConnReq -> { + r is API.Error && r.err is ChatError.ChatErrorChat + && r.err.errorType is ChatErrorType.UnsupportedConnReq -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.unsupported_connection_link), generalGetString(MR.strings.link_requires_newer_app_version_please_upgrade) ) } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> { + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.AUTH -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.connection_error_auth_desc) ) } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.BLOCKED -> { + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.BLOCKED -> { showContentBlockedAlert( generalGetString(MR.strings.connection_error_blocked), - generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text), + generalGetString(MR.strings.connection_error_blocked_desc).format(r.err.agentError.smpErr.blockInfo.reason.text), ) } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.QUOTA -> { + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.QUOTA -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error_quota), generalGetString(MR.strings.connection_error_quota_desc) @@ -1467,15 +1391,11 @@ object ChatController { suspend fun apiConnectContactViaAddress(rh: Long?, incognito: Boolean, contactId: Long): Contact? { val userId = try { currentUserId("apiConnectContactViaAddress") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.ApiConnectContactViaAddress(userId, incognito, contactId)) - when { - r is CR.SentInvitationToContact -> return r.contact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiConnectContactViaAddress", generalGetString(MR.strings.connection_error), r) - } - return null - } + if (r is API.Result && r.res is CR.SentInvitationToContact) return r.res.contact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiConnectContactViaAddress", generalGetString(MR.strings.connection_error), r) } + return null } suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { @@ -1490,10 +1410,11 @@ object ChatController { suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean { chatModel.deletedChats.value += rh to type.type + id val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) + val res = r.result val success = when { - r is CR.ContactDeleted && type == ChatType.Direct -> true - r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true - r is CR.GroupDeletedUser && type == ChatType.Group -> true + res is CR.ContactDeleted && type == ChatType.Direct -> true + res is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true + res is CR.GroupDeletedUser && type == ChatType.Group -> true else -> { val titleId = when (type) { ChatType.Direct -> MR.strings.error_deleting_contact @@ -1514,13 +1435,12 @@ object ChatController { val type = ChatType.Direct chatModel.deletedChats.value += rh to type.type + id val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) - val contact = when { - r is CR.ContactDeleted -> r.contact - else -> { - val titleId = MR.strings.error_deleting_contact - apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) - null - } + val contact = if (r is API.Result && r.res is CR.ContactDeleted) { + r.res.contact + } else { + val titleId = MR.strings.error_deleting_contact + apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) + null } chatModel.deletedChats.value -= rh to type.type + id return contact @@ -1544,7 +1464,7 @@ object ChatController { suspend fun apiClearChat(rh: Long?, type: ChatType, id: Long): ChatInfo? { val r = sendCmd(rh, CC.ApiClearChat(type, id)) - if (r is CR.ChatCleared) return r.chatInfo + if (r is API.Result && r.res is CR.ChatCleared) return r.res.chatInfo Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}") return null } @@ -1552,9 +1472,9 @@ object ChatController { suspend fun apiUpdateProfile(rh: Long?, profile: Profile): Pair>? { val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) - if (r is CR.UserProfileNoChange) return profile to emptyList() - if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { + if (r is API.Result && r.res is CR.UserProfileNoChange) return profile to emptyList() + if (r is API.Result && r.res is CR.UserProfileUpdated) return r.res.toProfile to r.res.updateSummary.changedContacts + if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") @@ -1563,51 +1483,52 @@ object ChatController { suspend fun apiSetProfileAddress(rh: Long?, on: Boolean): User? { val userId = try { currentUserId("apiSetProfileAddress") } catch (e: Exception) { return null } - return when (val r = sendCmd(rh, CC.ApiSetProfileAddress(userId, on))) { - is CR.UserProfileNoChange -> null - is CR.UserProfileUpdated -> r.user.updateRemoteHostId(rh) + val r = sendCmd(rh, CC.ApiSetProfileAddress(userId, on)) + return when { + r is API.Result && r.res is CR.UserProfileNoChange -> null + r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh) else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") } } suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? { val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs)) - if (r is CR.ContactPrefsUpdated) return r.toContact + if (r is API.Result && r.res is CR.ContactPrefsUpdated) return r.res.toContact Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetContactAlias(rh: Long?, contactId: Long, localAlias: String): Contact? { val r = sendCmd(rh, CC.ApiSetContactAlias(contactId, localAlias)) - if (r is CR.ContactAliasUpdated) return r.toContact + if (r is API.Result && r.res is CR.ContactAliasUpdated) return r.res.toContact Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetGroupAlias(rh: Long?, groupId: Long, localAlias: String): GroupInfo? { val r = sendCmd(rh, CC.ApiSetGroupAlias(groupId, localAlias)) - if (r is CR.GroupAliasUpdated) return r.toGroup + if (r is API.Result && r.res is CR.GroupAliasUpdated) return r.res.toGroup Log.e(TAG, "apiSetGroupAlias bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) - if (r is CR.ConnectionAliasUpdated) return r.toConnection + if (r is API.Result && r.res is CR.ConnectionAliasUpdated) return r.res.toConnection Log.e(TAG, "apiSetConnectionAlias bad response: ${r.responseType} ${r.details}") return null } suspend fun apiSetUserUIThemes(rh: Long?, userId: Long, themes: ThemeModeOverrides?): Boolean { val r = sendCmd(rh, CC.ApiSetUserUIThemes(userId, themes)) - if (r is CR.CmdOk) return true + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiSetUserUIThemes bad response: ${r.responseType} ${r.details}") return false } suspend fun apiSetChatUIThemes(rh: Long?, chatId: ChatId, themes: ThemeModeOverrides?): Boolean { val r = sendCmd(rh, CC.ApiSetChatUIThemes(chatId, themes)) - if (r is CR.CmdOk) return true + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiSetChatUIThemes bad response: ${r.responseType} ${r.details}") return false } @@ -1615,21 +1536,17 @@ object ChatController { suspend fun apiCreateUserAddress(rh: Long?, short: Boolean): CreatedConnLink? { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiCreateMyAddress(userId, short)) - return when (r) { - is CR.UserContactLinkCreated -> r.connLinkContact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) - } - null - } + if (r is API.Result && r.res is CR.UserContactLinkCreated) return r.res.connLinkContact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) } + return null } suspend fun apiDeleteUserAddress(rh: Long?): User? { val userId = try { currentUserId("apiDeleteUserAddress") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.ApiDeleteMyAddress(userId)) - if (r is CR.UserContactLinkDeleted) return r.user.updateRemoteHostId(rh) + if (r is API.Result && r.res is CR.UserContactLinkDeleted) return r.res.user.updateRemoteHostId(rh) Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}") return null } @@ -1637,9 +1554,9 @@ object ChatController { private suspend fun apiGetUserAddress(rh: Long?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("apiGetUserAddress") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiShowMyAddress(userId)) - if (r is CR.UserContactLink) return r.contactLink - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore - && r.chatError.storeError is StoreError.UserContactLinkNotFound + if (r is API.Result && r.res is CR.UserContactLink) return r.res.contactLink + if (r is API.Error && r.err is ChatError.ChatErrorStore + && r.err.storeError is StoreError.UserContactLinkNotFound ) { return null } @@ -1650,9 +1567,9 @@ object ChatController { suspend fun userAddressAutoAccept(rh: Long?, autoAccept: AutoAccept?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiAddressAutoAccept(userId, autoAccept)) - if (r is CR.UserContactLinkUpdated) return r.contactLink - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore - && r.chatError.storeError is StoreError.UserContactLinkNotFound + if (r is API.Result && r.res is CR.UserContactLinkUpdated) return r.res.contactLink + if (r is API.Error && r.err is ChatError.ChatErrorStore + && r.err.storeError is StoreError.UserContactLinkNotFound ) { return null } @@ -1663,10 +1580,10 @@ object ChatController { suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? { val r = sendCmd(rh, CC.ApiAcceptContact(incognito, contactReqId)) return when { - r is CR.AcceptingContactRequest -> r.contact - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> { + r is API.Result && r.res is CR.AcceptingContactRequest -> r.res.contact + r is API.Error && r.err is ChatError.ChatErrorAgent + && r.err.agentError is AgentErrorType.SMP + && r.err.agentError.smpErr is SMPErrorType.AUTH -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.sender_may_have_deleted_the_connection_request) @@ -1684,89 +1601,89 @@ object ChatController { suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Boolean { val r = sendCmd(rh, CC.ApiRejectContact(contactReqId)) - if (r is CR.ContactRequestRejected) return true + if (r is API.Result && r.res is CR.ContactRequestRejected) return true Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}") return false } suspend fun apiGetCallInvitations(rh: Long?): List { val r = sendCmd(rh, CC.ApiGetCallInvitations()) - if (r is CR.CallInvitations) return r.callInvitations + if (r is API.Result && r.res is CR.CallInvitations) return r.res.callInvitations Log.e(TAG, "apiGetCallInvitations bad response: ${r.responseType} ${r.details}") return emptyList() } suspend fun apiSendCallInvitation(rh: Long?, contact: Contact, callType: CallType): Boolean { val r = sendCmd(rh, CC.ApiSendCallInvitation(contact, callType)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiRejectCall(rh: Long?, contact: Contact): Boolean { val r = sendCmd(rh, CC.ApiRejectCall(contact)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiSendCallOffer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) val r = sendCmd(rh, CC.ApiSendCallOffer(contact, callOffer)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiSendCallAnswer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { val answer = WebRTCSession(rtcSession, rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallAnswer(contact, answer)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { val extraInfo = WebRTCExtraInfo(rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiEndCall(rh: Long?, contact: Contact): Boolean { val r = sendCmd(rh, CC.ApiEndCall(contact)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiCallStatus(rh: Long?, contact: Contact, status: WebRTCCallStatus): Boolean { val r = sendCmd(rh, CC.ApiCallStatus(contact, status)) - return r is CR.CmdOk + return r.result is CR.CmdOk } suspend fun apiGetNetworkStatuses(rh: Long?): List? { val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) - if (r is CR.NetworkStatuses) return r.networkStatuses + if (r is API.Result && r.res is CR.NetworkStatuses) return r.res.networkStatuses Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") return null } suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { val r = sendCmd(rh, CC.ApiChatRead(type, id)) - if (r is CR.CmdOk) return true + if (r.result 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)) - if (r is CR.CmdOk) return true + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}") return false } suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat)) - if (r is CR.CmdOk) return true + if (r.result is CR.CmdOk) return true Log.e(TAG, "apiChatUnread bad response: ${r.responseType} ${r.details}") return false } suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { val r = sendCmd(null, CC.ApiUploadStandaloneFile(user.userId, file), ctrl) - return if (r is CR.SndStandaloneFileCreated) { - r.fileTransferMeta to null + return if (r is API.Result && r.res is CR.SndStandaloneFileCreated) { + r.res.fileTransferMeta to null } else { Log.e(TAG, "uploadStandaloneFile error: $r") null to r.toString() @@ -1775,8 +1692,8 @@ object ChatController { suspend fun downloadStandaloneFile(user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { val r = sendCmd(null, CC.ApiDownloadStandaloneFile(user.userId, url, file), ctrl) - return if (r is CR.RcvStandaloneFileCreated) { - r.rcvFileTransfer to null + return if (r is API.Result && r.res is CR.RcvStandaloneFileCreated) { + r.res.rcvFileTransfer to null } else { Log.e(TAG, "downloadStandaloneFile error: $r") null to r.toString() @@ -1785,8 +1702,8 @@ object ChatController { suspend fun standaloneFileInfo(url: String, ctrl: ChatCtrl? = null): MigrationFileLinkData? { val r = sendCmd(null, CC.ApiStandaloneFileInfo(url), ctrl) - return if (r is CR.StandaloneFileInfo) { - r.fileMeta + return if (r is API.Result && r.res is CR.StandaloneFileInfo) { + r.res.fileMeta } else { Log.e(TAG, "standaloneFileInfo error: $r") null @@ -1796,7 +1713,7 @@ object ChatController { suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, userApprovedRelays: Boolean = false, auto: Boolean = false) { val fileIdsToApprove = mutableListOf() val srvsToApprove = mutableSetOf() - val otherFileErrs = mutableListOf() + val otherFileErrs = mutableListOf() for (fileId in fileIds) { val r = sendCmd( @@ -1807,10 +1724,10 @@ object ChatController { inline = null ) ) - if (r is CR.RcvFileAccepted) { - chatItemSimpleUpdate(rhId, user, r.chatItem) + if (r is API.Result && r.res is CR.RcvFileAccepted) { + chatItemSimpleUpdate(rhId, user, r.res.chatItem) } else { - val maybeChatError = chatError(r) + val maybeChatError = apiChatErrorType(r) if (maybeChatError is ChatErrorType.FileNotApproved) { fileIdsToApprove.add(maybeChatError.fileId) srvsToApprove.addAll(maybeChatError.unknownServers.map { serverHostname(it) }) @@ -1838,21 +1755,19 @@ object ChatController { } ) } else if (otherFileErrs.size == 1) { // If there is a single other error, we differentiate on it - when (val errCR = otherFileErrs.first()) { - is CR.RcvFileAcceptedSndCancelled -> { - Log.d(TAG, "receiveFiles error: sender cancelled file transfer") - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.cannot_receive_file), - generalGetString(MR.strings.sender_cancelled_file_transfer) - ) - } - else -> { - val maybeChatError = chatError(errCR) - if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) { - Log.d(TAG, "receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") - } else { - apiErrorAlert("receiveFiles", generalGetString(MR.strings.error_receiving_file), errCR) - } + val errCR = otherFileErrs.first() + if (errCR is API.Result && errCR.res is CR.RcvFileAcceptedSndCancelled) { + Log.d(TAG, "receiveFiles error: sender cancelled file transfer") + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.cannot_receive_file), + generalGetString(MR.strings.sender_cancelled_file_transfer) + ) + } else { + val maybeChatError = apiChatErrorType(errCR) + if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) { + Log.d(TAG, "receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") + } else { + apiErrorAlert("receiveFiles", generalGetString(MR.strings.error_receiving_file), errCR) } } } else if (otherFileErrs.size > 1) { // If there are multiple other errors, we show general alert @@ -1868,7 +1783,7 @@ object ChatController { private fun showFilesToApproveAlert( srvsToApprove: Set, - otherFileErrs: List, + otherFileErrs: List, approveFiles: (() -> Unit) ) { val srvsToApproveStr = srvsToApprove.sorted().joinToString(separator = ", ") @@ -1931,9 +1846,9 @@ object ChatController { suspend fun apiCancelFile(rh: Long?, fileId: Long, ctrl: ChatCtrl? = null): AChatItem? { val r = sendCmd(rh, CC.CancelFile(fileId), ctrl) - return when (r) { - is CR.SndFileCancelled -> r.chatItem_ - is CR.RcvFileCancelled -> r.chatItem_ + return when { + r is API.Result && r.res is CR.SndFileCancelled -> r.res.chatItem_ + r is API.Result && r.res is CR.RcvFileCancelled -> r.res.chatItem_ else -> { Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}") null @@ -1944,33 +1859,29 @@ object ChatController { suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiNewGroup(userId, incognito, groupProfile)) - if (r is CR.GroupCreated) return r.groupInfo + if (r is API.Result && r.res is CR.GroupCreated) return r.res.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null } suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) - return when (r) { - is CR.SentGroupInvitation -> r.member - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiAddMember", generalGetString(MR.strings.error_adding_members), r) - } - null - } + if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAddMember", generalGetString(MR.strings.error_adding_members), r) } + return null } suspend fun apiJoinGroup(rh: Long?, groupId: Long) { val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) - when (r) { - is CR.UserAcceptedGroupSent -> + when { + r is API.Result && r.res is CR.UserAcceptedGroupSent -> withContext(Dispatchers.Main) { - chatModel.chatsContext.updateGroup(rh, r.groupInfo) + chatModel.chatsContext.updateGroup(rh, r.res.groupInfo) } - is CR.ChatCmdError -> { - val e = r.chatError + r is API.Error -> { + val e = r.err suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { withContext(Dispatchers.Main) { chatModel.chatsContext.removeChat(rh, "#$groupId") } } } @@ -1988,58 +1899,53 @@ object ChatController { } } - 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 - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r) - } - null - } + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): List? { + val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) + if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.members + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r) } + return null + } - suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List = - when (val r = sendCmd(rh, CC.ApiMembersRole(groupId, memberIds, memberRole))) { - is CR.MembersRoleUser -> r.members - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiMembersRole", generalGetString(MR.strings.error_changing_role), r) - } - throw Exception("failed to change member role: ${r.responseType} ${r.details}") - } + suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List { + val r = sendCmd(rh, CC.ApiMembersRole(groupId, memberIds, memberRole)) + if (r is API.Result && r.res is CR.MembersRoleUser) return r.res.members + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiMembersRole", generalGetString(MR.strings.error_changing_role), r) } + throw Exception("failed to change member role: ${r.responseType} ${r.details}") + } - suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List = - when (val r = sendCmd(rh, CC.ApiBlockMembersForAll(groupId, memberIds, blocked))) { - is CR.MembersBlockedForAllUser -> r.members - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiBlockMembersForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) - } - throw Exception("failed to block member for all: ${r.responseType} ${r.details}") - } + suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List { + val r = sendCmd(rh, CC.ApiBlockMembersForAll(groupId, memberIds, blocked)) + if (r is API.Result && r.res is CR.MembersBlockedForAllUser) return r.res.members + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiBlockMembersForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) } + throw Exception("failed to block member for all: ${r.responseType} ${r.details}") + } suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? { val r = sendCmd(rh, CC.ApiLeaveGroup(groupId)) - if (r is CR.LeftMemberUser) return r.groupInfo + if (r is API.Result && r.res is CR.LeftMemberUser) return r.res.groupInfo Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}") return null } suspend fun apiListMembers(rh: Long?, groupId: Long): List { val r = sendCmd(rh, CC.ApiListMembers(groupId)) - if (r is CR.GroupMembers) return r.group.members + if (r is API.Result && r.res is CR.GroupMembers) return r.res.group.members Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}") return emptyList() } suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? { - return when (val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile))) { - is CR.GroupUpdated -> r.toGroup - is CR.ChatCmdError -> { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.chatError") + val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile)) + return when { + r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup + r is API.Error -> { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.err") null } else -> { @@ -2055,73 +1961,55 @@ object ChatController { suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { val short = appPrefs.privacyShortLinks.get() - return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole, short))) { - is CR.GroupLinkCreated -> r.connLinkContact to r.memberRole - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) - } - null - } + val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole, short)) + if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.connLinkContact to r.res.memberRole + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) } + return null } suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole))) { - is CR.GroupLink -> r.connLinkContact to r.memberRole - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiGroupLinkMemberRole", generalGetString(MR.strings.error_updating_link_for_group), r) - } - null - } + val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole)) + if (r is API.Result && r.res is CR.GroupLink) return r.res.connLinkContact to r.res.memberRole + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiGroupLinkMemberRole", generalGetString(MR.strings.error_updating_link_for_group), r) } + return null } suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean { - return when (val r = sendCmd(rh, CC.APIDeleteGroupLink(groupId))) { - is CR.GroupLinkDeleted -> true - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiDeleteGroupLink", generalGetString(MR.strings.error_deleting_link_for_group), r) - } - false - } + val r = sendCmd(rh, CC.APIDeleteGroupLink(groupId)) + if (r is API.Result && r.res is CR.GroupLinkDeleted) return true + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiDeleteGroupLink", generalGetString(MR.strings.error_deleting_link_for_group), r) } + return false } suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { - return when (val r = sendCmd(rh, CC.APIGetGroupLink(groupId))) { - is CR.GroupLink -> r.connLinkContact to r.memberRole - else -> { - Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") - null - } - } + val r = sendCmd(rh, CC.APIGetGroupLink(groupId)) + if (r is API.Result && r.res is CR.GroupLink) return r.res.connLinkContact to r.res.memberRole + Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") + return null } suspend fun apiCreateMemberContact(rh: Long?, groupId: Long, groupMemberId: Long): Contact? { - return when (val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId))) { - is CR.NewMemberContact -> r.contact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiCreateMemberContact", generalGetString(MR.strings.error_creating_member_contact), r) - } - null - } + val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId)) + if (r is API.Result && r.res is CR.NewMemberContact) return r.res.contact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiCreateMemberContact", generalGetString(MR.strings.error_creating_member_contact), r) } + return null } suspend fun apiSendMemberContactInvitation(rh: Long?, contactId: Long, mc: MsgContent): Contact? { - return when (val r = sendCmd(rh, CC.APISendMemberContactInvitation(contactId, mc))) { - is CR.NewMemberContactSentInv -> r.contact - else -> { - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiSendMemberContactInvitation", generalGetString(MR.strings.error_sending_message_contact_invitation), r) - } - null - } + val r = sendCmd(rh, CC.APISendMemberContactInvitation(contactId, mc)) + if (r is API.Result && r.res is CR.NewMemberContactSentInv) return r.res.contact + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSendMemberContactInvitation", generalGetString(MR.strings.error_sending_message_contact_invitation), r) } + return null } suspend fun allowFeatureToContact(rh: Long?, contact: Contact, feature: ChatFeature, param: Int? = null) { @@ -2138,7 +2026,7 @@ object ChatController { suspend fun listRemoteHosts(): List? { val r = sendCmd(null, CC.ListRemoteHosts()) - if (r is CR.RemoteHostList) return r.remoteHosts + if (r is API.Result && r.res is CR.RemoteHostList) return r.res.remoteHosts apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } @@ -2151,14 +2039,14 @@ object ChatController { suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true, address: RemoteCtrlAddress?, port: Int?): CR.RemoteHostStarted? { val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast, address, port)) - if (r is CR.RemoteHostStarted) return r + if (r is API.Result && r.res is CR.RemoteHostStarted) return r.res apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun switchRemoteHost (rhId: Long?): RemoteHostInfo? { val r = sendCmd(null, CC.SwitchRemoteHost(rhId)) - if (r is CR.CurrentRemoteHost) return r.remoteHost_ + if (r is API.Result && r.res is CR.CurrentRemoteHost) return r.res.remoteHost_ apiErrorAlert("switchRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } @@ -2180,45 +2068,49 @@ object ChatController { suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? { val r = sendCmd(null, CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) - if (r is CR.RemoteFileStored) return r.remoteFileSource + if (r is API.Result && r.res is CR.RemoteFileStored) return r.res.remoteFileSource apiErrorAlert("storeRemoteFile", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCmd(null, CC.GetRemoteFile(rhId, file)) is CR.CmdOk + suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCmd(null, CC.GetRemoteFile(rhId, file)).result is CR.CmdOk - suspend fun connectRemoteCtrl(desktopAddress: String): Pair { + suspend fun connectRemoteCtrl(desktopAddress: String): Pair { val r = sendCmd(null, CC.ConnectRemoteCtrl(desktopAddress)) - return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null - else if (r is CR.ChatCmdError) null to r - else { - apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) - null to null + return when { + r is API.Result && r.res is CR.RemoteCtrlConnecting -> SomeRemoteCtrl(r.res.remoteCtrl_, r.res.ctrlAppInfo, r.res.appVersion) to null + r is API.Error -> null to r.err + else -> { + apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } } } suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rcId: Long): Pair { + suspend fun confirmRemoteCtrl(rcId: Long): Pair { val r = sendCmd(null, CC.ConfirmRemoteCtrl(remoteCtrlId = rcId)) - return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null - else if (r is CR.ChatCmdError) null to r - else { - apiErrorAlert("confirmRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) - null to null + return when { + r is API.Result && r.res is CR.RemoteCtrlConnecting -> SomeRemoteCtrl(r.res.remoteCtrl_, r.res.ctrlAppInfo, r.res.appVersion) to null + r is API.Error -> null to r.err + else -> { + apiErrorAlert("confirmRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } } } suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode)) - if (r is CR.RemoteCtrlConnected) return r.remoteCtrl + if (r is API.Result && r.res is CR.RemoteCtrlConnected) return r.res.remoteCtrl apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun listRemoteCtrls(): List? { val r = sendCmd(null, CC.ListRemoteCtrls()) - if (r is CR.RemoteCtrlList) return r.remoteCtrls + if (r is API.Result && r.res is CR.RemoteCtrlList) return r.res.remoteCtrls apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r) return null } @@ -2229,72 +2121,71 @@ object ChatController { private suspend fun sendCommandOkResp(rh: Long?, cmd: CC, ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(rh, cmd, ctrl) - val ok = r is CR.CmdOk + val ok = r is API.Result && r.res is CR.CmdOk if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok } suspend fun apiGetVersion(): CoreVersionInfo? { val r = sendCmd(null, CC.ShowVersion()) - return if (r is CR.VersionInfo) { - r.versionInfo - } else { - Log.e(TAG, "apiGetVersion bad response: ${r.responseType} ${r.details}") - null - } + if (r is API.Result && r.res is CR.VersionInfo) return r.res.versionInfo + Log.e(TAG, "apiGetVersion bad response: ${r.responseType} ${r.details}") + return null } - private fun networkErrorAlert(r: CR): Boolean { + private fun networkErrorAlert(r: API): Boolean { + if (r !is API.Error) return false + val e = r.err return when { - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.TIMEOUT -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.TIMEOUT -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_timeout), - String.format(generalGetString(MR.strings.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.NETWORK -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.NETWORK -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error), - String.format(generalGetString(MR.strings.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.HOST -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.HOST -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error), - String.format(generalGetString(MR.strings.network_error_broker_host_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_broker_host_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.BROKER - && r.chatError.agentError.brokerErr is BrokerErrorType.TRANSPORT - && r.chatError.agentError.brokerErr.transportErr is SMPTransportError.Version -> { + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.BROKER + && e.agentError.brokerErr is BrokerErrorType.TRANSPORT + && e.agentError.brokerErr.transportErr is SMPTransportError.Version -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error), - String.format(generalGetString(MR.strings.network_error_broker_version_desc), serverHostname(r.chatError.agentError.brokerAddress)) + String.format(generalGetString(MR.strings.network_error_broker_version_desc), serverHostname(e.agentError.brokerAddress)) ) true } - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.SMP - && r.chatError.agentError.smpErr is SMPErrorType.PROXY -> - smpProxyErrorAlert(r.chatError.agentError.smpErr.proxyErr, r.chatError.agentError.serverAddress) - r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent - && r.chatError.agentError is AgentErrorType.PROXY - && r.chatError.agentError.proxyErr is ProxyClientError.ProxyProtocolError - && r.chatError.agentError.proxyErr.protocolErr is SMPErrorType.PROXY -> + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.SMP + && e.agentError.smpErr is SMPErrorType.PROXY -> + smpProxyErrorAlert(e.agentError.smpErr.proxyErr, e.agentError.serverAddress) + e is ChatError.ChatErrorAgent + && e.agentError is AgentErrorType.PROXY + && e.agentError.proxyErr is ProxyClientError.ProxyProtocolError + && e.agentError.proxyErr.protocolErr is SMPErrorType.PROXY -> proxyDestinationErrorAlert( - r.chatError.agentError.proxyErr.protocolErr.proxyErr, - r.chatError.agentError.proxyServer, - r.chatError.agentError.relayServer + e.agentError.proxyErr.protocolErr.proxyErr, + e.agentError.proxyServer, + e.agentError.relayServer ) else -> false } @@ -2385,18 +2276,18 @@ object ChatController { } } - private fun apiErrorAlert(method: String, title: String, r: CR) { + private fun apiErrorAlert(method: String, title: String, r: API) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") AlertManager.shared.showAlertMsg(title, errMsg) } - private suspend fun processReceivedMsg(apiResp: APIResponse) { + private suspend fun processReceivedMsg(msg: API) { lastMsgReceivedTimestamp = System.currentTimeMillis() - val r = apiResp.resp - val rhId = apiResp.remoteHostId + val rhId = msg.rhId fun active(user: UserLike): Boolean = activeUser(rhId, user) - chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, msg)) + val r = msg.result when (r) { is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { @@ -3053,16 +2944,17 @@ object ChatController { chatModel.chatsContext.updateContact(rhId, r.contact) } } - is CR.ChatRespError -> when { - r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> { - chatModel.processedCriticalError.newError(r.chatError.agentError, r.chatError.agentError.offerRestart) - } - r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.INTERNAL && appPrefs.developerTools.get() && appPrefs.showInternalErrors.get() -> { - chatModel.processedInternalError.newError(r.chatError.agentError, false) - } - } else -> - Log.d(TAG , "unsupported event: ${r.responseType}") + Log.d(TAG , "unsupported event: ${msg.responseType}") + } + val e = (msg as? API.Error)?.err + when { + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.CRITICAL -> + chatModel.processedCriticalError.newError(e.agentError, e.agentError.offerRestart) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.INTERNAL && appPrefs.developerTools.get() && appPrefs.showInternalErrors.get() -> + chatModel.processedInternalError.newError(e.agentError, false) + else -> + Log.d(TAG , "unsupported event: ${msg.responseType}") } } @@ -3693,8 +3585,8 @@ sealed class CC { is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId" + - (" approved_relays=${onOff(userApprovedRelays)}") + - (if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") + + " approved_relays=${onOff(userApprovedRelays)}" + + " encrypt=${onOff(encrypt)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is SetLocalDeviceName -> "/set device name $displayName" @@ -5688,56 +5580,122 @@ val yaml = Yaml(configuration = YamlConfiguration( codePointLimit = 5500000, )) -@Serializable -class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = null) { - companion object { - fun decodeStr(str: String): APIResponse { - return try { - json.decodeFromString(str) - } catch(e: Throwable) { - try { - Log.d(TAG, e.localizedMessage ?: "") - val data = json.parseToJsonElement(str).jsonObject - val resp = data["resp"]!!.jsonObject - val type = resp["type"]?.jsonPrimitive?.contentOrNull ?: "invalid" - val corr = data["corr"]?.toString() - val remoteHostId = data["remoteHostId"]?.jsonPrimitive?.longOrNull - try { - if (type == "apiChats") { - val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) - val chats: List = resp["chats"]!!.jsonArray.map { - parseChatData(it) - } - return APIResponse(CR.ApiChats(user, chats), remoteHostId, corr) - } else if (type == "apiChat") { - val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) - val chat = parseChatData(resp["chat"]!!) - return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr) - } else if (type == "chatCmdError") { - val userObject = resp["user_"]?.jsonObject - val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse(CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) - } else if (type == "chatError") { - val userObject = resp["user_"]?.jsonObject - val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) - } - } catch (e: Exception) { - Log.e(TAG, "Exception while parsing chat(s): " + e.stackTraceToString()) - } catch (e: Throwable) { - Log.e(TAG, "Throwable while parsing chat(s): " + e.stackTraceToString()) - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) +@Suppress("SERIALIZER_TYPE_INCOMPATIBLE") +@Serializable(with = APISerializer::class) +sealed class API { + @Serializable(with = APISerializer::class) class Result(val remoteHostId: Long?, val res: CR) : API() + @Serializable(with = APISerializer::class) class Error(val remoteHostId: Long?, val err: ChatError) : API() + + val ok: Boolean get() = this is API.Result && this.res is CR.CmdOk + val result: CR? get() = (this as? API.Result)?.res + val rhId: Long? get() = when (this) { + is Result -> remoteHostId + is Error -> remoteHostId + } + + val pair: Pair get() = when (this) { + is Result -> res to null + is Error -> null to err + } + + val responseType: String get() = when (this) { + is Result -> res.responseType + is Error -> "error ${err.resultType}" + } + + val details: String get() = when (this) { + is Result -> res.details + is Error -> "error ${err.string}" + } +} + +object APISerializer : KSerializer { + override val descriptor: SerialDescriptor = buildSerialDescriptor("API", PolymorphicKind.SEALED) { + element("Result", buildClassSerialDescriptor("Result") { + element("remoteHostId") + element("result") + }) + element("Error", buildClassSerialDescriptor("Error") { + element("remoteHostId") + element("error") + }) + } + + override fun deserialize(decoder: Decoder): API { + require(decoder is JsonDecoder) + val j = try { decoder.decodeJsonElement() } catch(e: Exception) { null } catch(e: Throwable) { null } + if (j == null) return API.Error(remoteHostId = null, ChatError.ChatErrorInvalidJSON("")) + if (j !is JsonObject) return API.Error(remoteHostId = null, ChatError.ChatErrorInvalidJSON(json.encodeToString(j))) + val remoteHostId = j["remoteHostId"]?.jsonPrimitive?.longOrNull + val jRes = j["result"] + if (jRes != null) { + val result = try { + decoder.json.decodeFromJsonElement(jRes) + } catch (e: Exception) { + fallbackResult(jRes) + } catch (e: Throwable) { + fallbackResult(jRes) + } + return API.Result(remoteHostId, result) + } + val jErr = j["error"] + if (jErr != null) { + val error = try { + decoder.json.decodeFromJsonElement(jErr) + } catch (e: Exception) { + fallbackChatError(jErr) + } catch (e: Throwable) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + fallbackChatError(jErr) + } + return API.Error(remoteHostId, error) + } + return API.Error(remoteHostId, fallbackChatError(j)) + } + + private fun fallbackResult(jRes: JsonElement): CR { + if (jRes is JsonObject) { + val type = jRes["type"]?.jsonPrimitive?.contentOrNull ?: "invalid" + try { + if (type == "apiChats") { + val user: UserRef = json.decodeFromJsonElement(jRes["user"]!!.jsonObject) + val chats: List = jRes["chats"]!!.jsonArray.map { + parseChatData(it) } - APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr) - } catch(e: Exception) { - APIResponse(CR.Invalid(str), remoteHostId = null) - } catch(e: Throwable) { - Log.e(TAG, "Throwable2 while parsing chat(s): " + e.stackTraceToString()) - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) - APIResponse(CR.Invalid(str), remoteHostId = null) + return CR.ApiChats(user, chats) + } else if (type == "apiChat") { + val user: UserRef = json.decodeFromJsonElement(jRes["user"]!!.jsonObject) + val chat = parseChatData(jRes["chat"]!!) + return CR.ApiChat(user, chat) } + } catch (e: Exception) { + Log.e(TAG, "Exception while parsing chat(s): " + e.stackTraceToString()) + } catch (e: Throwable) { + Log.e(TAG, "Throwable while parsing chat(s): " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } + return CR.Response(type, json.encodeToString(jRes)) + } + return CR.Response(type = "invalid", json.encodeToString(jRes)) + } + + private fun fallbackChatError(jErr: JsonElement): ChatError { + return ChatError.ChatErrorInvalidJSON(json.encodeToString(jErr)) + } + + override fun serialize(encoder: Encoder, value: API) { + require(encoder is JsonEncoder) + val json = when (value) { + is API.Result -> buildJsonObject { + value.remoteHostId?.let { put("remoteHostId", it) } + put("result", encoder.json.encodeToJsonElement(value.res)) + } + is API.Error -> buildJsonObject { + value.remoteHostId?.let { put("remoteHostId", it) } + put("error", encoder.json.encodeToJsonElement(value.err)) } } + encoder.encodeJsonElement(json) } } @@ -5931,8 +5889,6 @@ sealed class CR { // misc @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() - @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() - @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveExported") class ArchiveExported(val archiveErrors: List): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() @Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR() @@ -6103,8 +6059,6 @@ sealed class CR { is AgentSubsTotal -> "agentSubsTotal" is AgentServersSummary -> "agentServersSummary" is CmdOk -> "cmdOk" - is ChatCmdError -> "chatCmdError" - is ChatRespError -> "chatError" is ArchiveExported -> "archiveExported" is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" @@ -6290,8 +6244,6 @@ sealed class CR { "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + "agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}" is CmdOk -> withUser(user, noDetails()) - is ChatCmdError -> withUser(user_, chatError.string) - is ChatRespError -> withUser(user_, chatError.string) is ArchiveExported -> "${archiveErrors.map { it.string } }" is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) @@ -6304,13 +6256,9 @@ sealed class CR { private fun withUser(u: UserLike?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s } -fun chatError(r: CR): ChatErrorType? { - return ( - if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat) r.chatError.errorType - else if (r is CR.ChatRespError && r.chatError is ChatError.ChatErrorChat) r.chatError.errorType - else null - ) -} +fun apiChatErrorType(r: API): ChatErrorType? = + if (r is API.Error && r.err is ChatError.ChatErrorChat) r.err.errorType + else null @Serializable sealed class ChatDeleteMode { @@ -6395,7 +6343,7 @@ abstract class TerminalItem { override val details get() = cmd.cmdString } - class Resp(override val id: Long, override val remoteHostId: Long?, val resp: CR): TerminalItem() { + class Resp(override val id: Long, override val remoteHostId: Long?, val resp: API): TerminalItem() { override val label get() = "< ${resp.responseType}" override val details get() = resp.details } @@ -6403,11 +6351,11 @@ abstract class TerminalItem { companion object { val sampleData = listOf( Cmd(0, null, CC.ShowActiveUser()), - Resp(1, null, CR.ActiveUser(User.sampleData)) + Resp(1, null, API.Result(null, CR.ActiveUser(User.sampleData))) ) fun cmd(rhId: Long?, c: CC) = Cmd(System.currentTimeMillis(), rhId, c) - fun resp(rhId: Long?, r: CR) = Resp(System.currentTimeMillis(), rhId, r) + fun resp(rhId: Long?, r: API) = Resp(System.currentTimeMillis(), rhId, r) } } @@ -6551,6 +6499,16 @@ sealed class ChatError { @Serializable @SerialName("errorRemoteHost") class ChatErrorRemoteHost(val remoteHostError: RemoteHostError): ChatError() @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() @Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError() + + val resultType: String get() = when (this) { + is ChatErrorChat -> "chat" + is ChatErrorAgent -> "agent" + is ChatErrorStore -> "store" + is ChatErrorDatabase -> "database" + is ChatErrorRemoteHost -> "remoteHost" + is ChatErrorRemoteCtrl -> "remoteCtrl" + is ChatErrorInvalidJSON -> "invalid json" + } } @Serializable 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..ca4d4fc0da 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 @@ -48,7 +48,7 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState CallState.InvitationSent) break - val msg = apiResp.resp - if (apiResp.remoteHostId == call.remoteHostId && - msg is CR.ChatItemsStatusesUpdated && - msg.chatItems.any { + if (msg.rhId == call.remoteHostId && + msg is API.Result && + msg.res is CR.ChatItemsStatusesUpdated && + msg.res.chatItems.any { it.chatInfo.id == call.contact.id && it.chatItem.content is CIContent.SndCall && it.chatItem.meta.itemStatus is CIStatus.SndRcvd } ) { 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..dc1c0b71a5 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 @@ -554,11 +554,11 @@ fun ChatView( ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) LaunchedEffect(cItem.id) { withContext(Dispatchers.Default) { - for (apiResp in controller.messagesChannel) { - val msg = apiResp.resp - if (apiResp.remoteHostId == chatRh && - msg is CR.ChatItemsStatusesUpdated && - msg.chatItems.any { it.chatItem.id == cItem.id } + for (msg in controller.messagesChannel) { + if (msg.rhId == chatRh && + msg is API.Result && + msg.res is CR.ChatItemsStatusesUpdated && + msg.res.chatItems.any { it.chatItem.id == cItem.id } ) { ciInfo = loadChatItemInfo() ?: return@withContext initialCiInfo = ciInfo diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index c2e1d67d50..1c1c37b7ac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -435,7 +435,7 @@ suspend fun encryptDatabase( } val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) appPrefs.encryptionStartedAt.set(null) - val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError + val sqliteError = ((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError when { sqliteError is SQLiteError.ErrorNotADatabase -> { operationEnded(m, progressIndicator) { @@ -449,7 +449,7 @@ suspend fun encryptDatabase( error != null -> { operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), - "failed to set storage encryption: ${error.responseType} ${error.details}" + "failed to set storage encryption: error ${error.string}" ) } false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 8588e0e981..03542ca8af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -468,12 +468,12 @@ private suspend fun MutableState.verifyDatabasePassphrase(db val error = controller.testStorageEncryption(dbKey) if (error == null) { state = MigrationFromState.UploadConfirmation - } else if (((error.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorOpen)?.sqliteError is SQLiteError.ErrorNotADatabase) { + } else if (((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorOpen)?.sqliteError is SQLiteError.ErrorNotADatabase) { showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase("")) } else { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.error), - text = generalGetString(MR.strings.migrate_from_device_error_verifying_passphrase) + " " + error.details + text = generalGetString(MR.strings.migrate_from_device_error_verifying_passphrase) + " " + error.string ) } } @@ -556,11 +556,12 @@ private fun MutableState.startUploading( ) { withBGApi { chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> - when (msg) { + val r = msg.result + when (r) { is CR.SndFileProgressXFTP -> { val s = state if (s is MigrationFromState.UploadProgress && s.uploadedBytes != s.totalBytes) { - state = MigrationFromState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) + state = MigrationFromState.UploadProgress(r.sentSize, r.totalSize, r.fileTransferMeta.fileId, archivePath, ctrl, user) } } is CR.SndFileRedirectStartXFTP -> { @@ -578,7 +579,7 @@ private fun MutableState.startUploading( requiredHostMode = cfg.requiredHostMode ) ) - state = MigrationFromState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + state = MigrationFromState.LinkShown(r.fileTransferMeta.fileId, data.addToLink(r.rcvURIs[0]), ctrl) } is CR.SndFileError -> { AlertManager.shared.showAlertMsg( @@ -692,7 +693,7 @@ private class MigrationFromChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, - val processReceivedMsg: suspend (CR) -> Unit + val processReceivedMsg: suspend (API) -> Unit ) { fun start() { Log.d(TAG, "MigrationChatReceiver startReceiver") @@ -701,19 +702,18 @@ private class MigrationFromChatReceiver( try { val msg = ChatController.recvMsg(ctrl) if (msg != null && receiveMessages) { - val r = msg.resp - val rhId = msg.remoteHostId - Log.d(TAG, "processReceivedMsg: ${r.responseType}") - chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val rhId = msg.rhId + Log.d(TAG, "processReceivedMsg: ${msg.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, msg)) val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { - processReceivedMsg(r) + processReceivedMsg(msg) } if (finishedWithoutTimeout == null) { - Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + Log.e(TAG, "Timeout reached while processing received message: " + msg.responseType) if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.possible_slow_function_title), - text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.responseType + "\n" + Exception().stackTraceToString()), shareText = true ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 1a28bbf589..d74846f8a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -580,12 +580,13 @@ private fun MutableState.startDownloading( ) { withBGApi { chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> - when (msg) { - is CR.RcvFileProgressXFTP -> { - state = MigrationToState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, networkProxy, ctrl) + val r = msg.result + when { + r is CR.RcvFileProgressXFTP -> { + state = MigrationToState.DownloadProgress(r.receivedSize, r.totalSize, r.rcvFileTransfer.fileId, link, archivePath, netCfg, networkProxy, ctrl) MigrationToDeviceState.save(MigrationToDeviceState.DownloadProgress(link, File(archivePath).name, netCfg, networkProxy)) } - is CR.RcvStandaloneFileComplete -> { + r is CR.RcvStandaloneFileComplete -> { delay(500) // User closed the whole screen before new state was saved if (state == null) { @@ -595,22 +596,22 @@ private fun MutableState.startDownloading( MigrationToDeviceState.save(MigrationToDeviceState.ArchiveImport(File(archivePath).name, netCfg, networkProxy)) } } - is CR.RcvFileError -> { + r is CR.RcvFileError -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.migrate_to_device_download_failed), generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg, networkProxy) } - is CR.ChatRespError -> { - if (msg.chatError is ChatError.ChatErrorChat && msg.chatError.errorType is ChatErrorType.NoRcvFileUser) { + msg is API.Error -> { + if (msg.err is ChatError.ChatErrorChat && msg.err.errorType is ChatErrorType.NoRcvFileUser) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.migrate_to_device_download_failed), generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg, networkProxy) } else { - Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.chatError)}") + Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.err)}") } } else -> Log.d(TAG, "unsupported event: ${msg.responseType}") @@ -739,7 +740,7 @@ private class MigrationToChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, - val processReceivedMsg: suspend (CR) -> Unit + val processReceivedMsg: suspend (API) -> Unit ) { fun start() { Log.d(TAG, "MigrationChatReceiver startReceiver") @@ -748,19 +749,18 @@ private class MigrationToChatReceiver( try { val msg = ChatController.recvMsg(ctrl) if (msg != null && receiveMessages) { - val r = msg.resp - val rhId = msg.remoteHostId - Log.d(TAG, "processReceivedMsg: ${r.responseType}") - chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val rhId = msg.rhId + Log.d(TAG, "processReceivedMsg: ${msg.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, msg)) val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { - processReceivedMsg(r) + processReceivedMsg(msg) } if (finishedWithoutTimeout == null) { - Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + Log.e(TAG, "Timeout reached while processing received message: " + msg.responseType) if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.possible_slow_function_title), - text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.responseType + "\n" + Exception().stackTraceToString()), shareText = true ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 3b6e176ca3..8bb84060c2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -492,7 +492,7 @@ private suspend fun connectDesktopAddress(sessionAddress: MutableState, } } -private suspend fun connectDesktop(sessionAddress: MutableState, connect: suspend () -> Pair): Boolean { +private suspend fun connectDesktop(sessionAddress: MutableState, connect: suspend () -> Pair): Boolean { val res = connect() if (res.first != null) { val (rc_, ctrlAppInfo, v) = res.first!! @@ -505,13 +505,13 @@ private suspend fun connectDesktop(sessionAddress: MutableState, connect } else { val e = res.second ?: return false when { - e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() - e.chatError is ChatError.ChatErrorChat && e.chatError.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() - e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.chatError.remoteCtrlError.appVersion) - e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) - e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() + e is ChatError.ChatErrorRemoteCtrl && e.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() + e is ChatError.ChatErrorRemoteCtrl && e.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.remoteCtrlError.appVersion) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.RCP && e.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.RCP && e.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() else -> { - val errMsg = "${e.responseType}: ${e.details}" + val errMsg = "error: ${e.string}" Log.e(TAG, "bad response: $errMsg") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), errMsg) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 24978ecf7c..569f4ff5f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -291,7 +291,7 @@ private fun DeliveryReceiptsSection( SectionView(stringResource(MR.strings.settings_section_title_delivery_receipts)) { SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.receipts_section_contacts)) { DefaultSwitch( - checked = currentUser.sendRcptsContacts ?: false, + checked = currentUser.sendRcptsContacts, onCheckedChange = { enable -> setOrAskSendReceiptsContacts(enable) } @@ -299,7 +299,7 @@ private fun DeliveryReceiptsSection( } SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.receipts_section_groups)) { DefaultSwitch( - checked = currentUser.sendRcptsSmallGroups ?: false, + checked = currentUser.sendRcptsSmallGroups, onCheckedChange = { enable -> setOrAskSendReceiptsGroups(enable) } diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index b6ad9eea96..40e6686065 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -45,10 +45,10 @@ mySquaringBot _user cc = do race_ (forever $ void getLine) . forever $ do (_, evt) <- atomically . readTBQueue $ outputQ cc case evt of - CEvtContactConnected _ contact _ -> do + Right (CEvtContactConnected _ contact _) -> do contactConnected contact sendMessage cc contact welcomeMessage - CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do + Right CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do let msg = ciContentToText mc number_ = readMaybe (T.unpack msg) :: Maybe Integer sendMessage cc contact $ case number_ of diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 913f6a732a..86f89f86e8 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -39,17 +39,17 @@ broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _u race_ (forever $ void getLine) . forever $ do (_, evt) <- atomically . readTBQueue $ outputQ cc case evt of - CEvtContactConnected _ ct _ -> do + Right (CEvtContactConnected _ ct _) -> do contactConnected ct sendMessage cc ct welcomeMessage - CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) : _} + Right CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) : _} | sender `notElem` publishers -> do sendReply prohibitedMessage deleteMessage cc ct $ chatItemId' ci | allowContent mc -> void $ forkIO $ sendChatCmd cc (SendMessageBroadcast mc) >>= \case - CRBroadcastSent {successes, failures} -> + Right CRBroadcastSent {successes, failures} -> sendReply $ "Forwarded to " <> tshow successes <> " contact(s), " <> tshow failures <> " errors" r -> putStrLn $ "Error broadcasting message: " <> show r | otherwise -> diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index d087df0bb5..0906d14536 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -2,20 +2,23 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE UndecidableInstances #-} module Server where import Control.Monad import Control.Monad.Except import Control.Monad.Reader -import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson (FromJSON, ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import Data.Bifunctor (first) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import GHC.Generics (Generic) @@ -26,7 +29,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options -import Simplex.Messaging.Parsers (defaultJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) import Simplex.Messaging.Transport.Server (runLocalTCPServer) import Simplex.Messaging.Util (raceAny_) import UnliftIO.Exception @@ -35,13 +38,32 @@ import UnliftIO.STM data ChatSrvRequest = ChatSrvRequest {corrId :: Text, cmd :: Text} deriving (Generic, FromJSON) -data ChatSrvResponse r = ChatSrvResponse {corrId :: Maybe Text, resp :: r} +data ChatSrvResponse r = ChatSrvResponse {corrId :: Maybe Text, resp :: CSRBody r} + +data CSRBody r = CSRBody {csrBody :: Either ChatError r} + +-- backwards compatible encoding, to avoid breaking any chat bots +data ObjChatCmdError = ObjChatCmdError {chatError :: ChatError} + +data ObjChatError = ObjChatError {chatError :: ChatError} + +$(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "Obj") ''ObjChatCmdError) + +$(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "Obj") ''ObjChatError) + +instance ToJSON (CSRBody ChatResponse) where + toJSON = toJSON . first ObjChatCmdError . csrBody + toEncoding = toEncoding . first ObjChatCmdError . csrBody + +instance ToJSON (CSRBody ChatEvent) where + toJSON = toJSON . first ObjChatError . csrBody + toEncoding = toEncoding . first ObjChatError . csrBody data AChatSrvResponse = forall r. ToJSON (ChatSrvResponse r) => ACR (ChatSrvResponse r) $(pure []) -instance ToJSON r => ToJSON (ChatSrvResponse r) where +instance ToJSON (CSRBody r) => ToJSON (ChatSrvResponse r) where toEncoding = $(JQ.mkToEncoding defaultJSON ''ChatSrvResponse) toJSON = $(JQ.mkToJSON defaultJSON ''ChatSrvResponse) @@ -91,8 +113,8 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do >>= processCommand >>= atomically . writeTBQueue sndQ . ACR output ChatClient {sndQ} = forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc - atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId = Nothing, resp} + (_, r) <- atomically . readTBQueue $ outputQ cc + atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId = Nothing, resp = CSRBody r} receive ws ChatClient {rcvQ, sndQ} = forever $ do s <- WS.receiveData ws case J.decodeStrict' s of @@ -103,11 +125,9 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do Left e -> sendError (Just corrId) e Nothing -> sendError Nothing "invalid request" where - sendError corrId e = atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId, resp = chatCmdError Nothing e} + sendError corrId e = atomically $ writeTBQueue sndQ $ ACR ChatSrvResponse {corrId, resp = CSRBody $ chatCmdError e} processCommand (corrId, cmd) = - runReaderT (runExceptT $ processChatCommand cmd) cc >>= \case - Right resp -> response resp - Left e -> response $ CRChatCmdError Nothing e + response <$> runReaderT (runExceptT $ processChatCommand cmd) cc where - response resp = pure ChatSrvResponse {corrId = Just corrId, resp} + response r = ChatSrvResponse {corrId = Just corrId, resp = CSRBody r} clientDisconnected _ = pure () diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 412f87889c..37d2b63d2f 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -63,8 +63,16 @@ data DirectoryEvent | DELogChatResponse Text deriving (Show) -crDirectoryEvent :: ChatEvent -> Maybe DirectoryEvent +crDirectoryEvent :: Either ChatError ChatEvent -> Maybe DirectoryEvent crDirectoryEvent = \case + Right evt -> crDirectoryEvent_ evt + Left e -> case e of + ChatErrorAgent {agentError = BROKER _ NETWORK} -> Nothing + ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing + _ -> Just $ DELogChatResponse $ "chat error: " <> tshow e + +crDirectoryEvent_ :: ChatEvent -> Maybe DirectoryEvent +crDirectoryEvent_ = \case CEvtContactConnected {contact} -> Just $ DEContactConnected contact CEvtReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} CEvtUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember @@ -92,10 +100,6 @@ crDirectoryEvent = \case ciId = chatItemId' ci err = ADC SDRUser DCUnknownCommand CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage - CEvtChatError {chatError} -> case chatError of - ChatErrorAgent {agentError = BROKER _ NETWORK} -> Nothing - ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing - _ -> Just $ DELogChatResponse $ "chat error: " <> tshow chatError CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) _ -> Nothing where diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 89fb9c30d8..4b02e0b294 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -60,7 +60,7 @@ import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types import Simplex.Chat.Types.Shared -import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) +import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..)) import Simplex.Messaging.Agent.Store.Common (withTransaction) import Simplex.Messaging.Agent.Protocol (SConnectionMode (..), sameConnReqContact, sameShortLinkContact) @@ -197,7 +197,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} -directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatEvent -> IO () +directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct @@ -249,7 +249,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getGroups_ :: Maybe Text -> IO (Maybe [(GroupInfo, GroupSummary)]) getGroups_ search_ = sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case - CRGroupsList {groups} -> pure $ Just groups + Right CRGroupsList {groups} -> pure $ Just groups _ -> pure Nothing getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup) @@ -281,7 +281,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName void $ addGroupReg st ct g GRSProposed r <- sendChatCmd cc $ APIJoinGroup groupId MFNone sendMessage cc ct $ case r of - CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" + Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" deContactConnected :: Contact -> IO () @@ -337,7 +337,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName $>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId) where resp = \case - CRGroupMemberInfo {member} -> Just member + Right CRGroupMemberInfo {member} -> Just member _ -> Nothing deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () @@ -349,7 +349,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" sendChatCmd cc (APICreateGroupLink groupId GRMember False) >>= \case - CRGroupLinkCreated {connLinkContact = CCLink gLink _} -> do + Right CRGroupLinkCreated {connLinkContact = CCLink gLink _} -> do setGroupStatus st gr GRSPendingUpdate notifyOwner gr @@ -357,7 +357,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName \Please add it to the group welcome message.\n\ \For example, add:" notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> strEncodeTxt (simplexChatContact gLink) - CRChatCmdError _ (ChatError e) -> case e of + Left (ChatError e) -> case e of CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" @@ -446,7 +446,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where profileUpdate = \case - CRGroupLink {connLinkContact = CCLink cr sl_} -> + Right CRGroupLink {connLinkContact = CCLink cr sl_} -> let hadLinkBefore = profileHasGroupLink fromGroup hasLinkNow = profileHasGroupLink toGroup profileHasGroupLink GroupInfo {groupProfile = gp} = @@ -503,7 +503,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 {} -> do + Right CRJoinedGroupMember {} -> do atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected accept member response: " <> tshow r @@ -528,7 +528,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let gmId = groupMemberId' m sendComposedMessages cc (SRGroup groupId $ Just gmId) [MCText rjctNotice] sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case - CRUserDeletedMembers _ _ (_ : _) _ -> do + Right (CRUserDeletedMembers _ _ (_ : _) _) -> do atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected remove member response: " <> tshow r @@ -891,18 +891,21 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ _ -> sendChatCmd cc (APIGetGroupLink groupId) >>= \case - CRGroupLink {connLinkContact = CCLink cReq _, memberRole} -> + Right CRGroupLink {connLinkContact = CCLink cReq _, memberRole} -> sendReply $ T.unlines [ "The link to join the group " <> groupRef <> ":", strEncodeTxt $ simplexChatContact cReq, "New member role: " <> strEncodeTxt memberRole ] - CRChatCmdError _ (ChatErrorStore (SEGroupLinkNotFound _)) -> + Left (ChatErrorStore (SEGroupLinkNotFound _)) -> sendReply $ "The group " <> groupRef <> " has no public link." - r -> do + Right r -> do ts <- getCurrentTime tz <- getCurrentTimeZone - let resp = T.pack $ serializeChatResponse (Nothing, Just user) ts tz Nothing r + let resp = T.pack $ serializeChatResponse (Nothing, Just user) (config cc) ts tz Nothing r + sendReply $ "Unexpected error:\n" <> resp + Left e -> do + let resp = T.pack $ serializeChatError True (config cc) e sendReply $ "Unexpected error:\n" <> resp DCSendToGroupOwner groupId gName msg -> do let groupRef = groupReference' groupId gName @@ -944,11 +947,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = sendChatCmd cc (APIListMembers ogId) >>= \case - CRGroupMembers _ (Group _ ms) + Right (CRGroupMembers _ (Group _ ms)) | alreadyMember ms -> cont $ Left "Owner is already a member of owners' group" | otherwise -> do sendChatCmd cc (APIAddMember ogId ctId GRMember) >>= \case - CRSentGroupInvitation {} -> do + Right CRSentGroupInvitation {} -> do printLog cc CLLInfo $ "invited contact ID " <> show ctId <> " to owners' group" cont $ Right () r -> contErr r @@ -969,10 +972,13 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deSuperUserCommand ct ciId cmd | knownContact ct `elem` superUsers = case cmd of DCExecuteCommand cmdStr -> - sendChatCmdStr cc cmdStr >>= \r -> do - ts <- getCurrentTime - tz <- getCurrentTimeZone - sendReply $ T.pack $ serializeChatResponse (Nothing, Just user) ts tz Nothing r + sendChatCmdStr cc cmdStr >>= \case + Right r -> do + ts <- getCurrentTime + tz <- getCurrentTimeZone + sendReply $ T.pack $ serializeChatResponse (Nothing, Just user) (config cc) ts tz Nothing r + Left e -> + sendReply $ T.pack $ serializeChatError True (config cc) e DCCommandError tag -> sendReply $ "Command error: " <> tshow tag | otherwise = sendReply "You are not allowed to use this command" where @@ -1045,7 +1051,7 @@ setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where resp = \case - CRGroupLink _ _ (CCLink gLink _) _ -> Just gLink + Right (CRGroupLink _ _ (CCLink gLink _) _) -> Just gLink _ -> Nothing unexpectedError :: Text -> Text diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 291083368e..6469f24244 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -467,6 +467,8 @@ executable simplex-directory-service , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test + if flag(swift) + cpp-options: -DswiftJSON if flag(client_library) buildable: False type: exitcode-stdio-1.0 diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 5acf60556e..73a2970c61 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -35,10 +35,10 @@ chatBotRepl welcome answer _user cc = do race_ (forever $ void getLine) . forever $ do (_, event) <- atomically . readTBQueue $ outputQ cc case event of - CEvtContactConnected _ contact _ -> do + Right (CEvtContactConnected _ contact _) -> do contactConnected contact void $ sendMessage cc contact $ T.pack welcome - CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do + Right CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do let msg = T.unpack $ ciContentToText mc void $ sendMessage cc contact . T.pack =<< answer contact msg _ -> pure () @@ -51,12 +51,12 @@ initializeBotAddress = initializeBotAddress' True initializeBotAddress' :: Bool -> ChatController -> IO () initializeBotAddress' logAddress cc = do sendChatCmd cc ShowMyAddress >>= \case - CRUserContactLink _ UserContactLink {connLinkContact} -> showBotAddress connLinkContact - CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do + Right (CRUserContactLink _ UserContactLink {connLinkContact}) -> showBotAddress connLinkContact + Left (ChatErrorStore SEUserContactLinkNotFound) -> do when logAddress $ putStrLn "No bot address, creating..." -- TODO [short links] create short link by default sendChatCmd cc (CreateMyAddress False) >>= \case - CRUserContactLinkCreated _ ccLink -> showBotAddress ccLink + Right (CRUserContactLinkCreated _ ccLink) -> showBotAddress ccLink _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where @@ -84,14 +84,14 @@ sendComposedMessages_ :: ChatController -> SendRef -> NonEmpty (Maybe ChatItemId sendComposedMessages_ cc sendRef qmcs = do let cms = L.map (\(qiId, mc) -> ComposedMessage {fileSource = Nothing, quotedItemId = qiId, msgContent = mc, mentions = M.empty}) qmcs sendChatCmd cc (APISendMessages sendRef False Nothing cms) >>= \case - CRNewChatItems {} -> printLog cc CLLInfo $ "sent " <> show (length cms) <> " messages to " <> show sendRef + Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent " <> show (length cms) <> " messages to " <> show sendRef r -> putStrLn $ "unexpected send message response: " <> show r deleteMessage :: ChatController -> Contact -> ChatItemId -> IO () deleteMessage cc ct chatItemId = do let cmd = APIDeleteChatItem (contactRef ct) [chatItemId] CIDMInternal sendChatCmd cc cmd >>= \case - CRChatItemsDeleted {} -> printLog cc CLLInfo $ "deleted message(s) from " <> contactInfo ct + Right (CRChatItemsDeleted {}) -> printLog cc CLLInfo $ "deleted message(s) from " <> contactInfo ct r -> putStrLn $ "unexpected delete message response: " <> show r contactRef :: Contact -> ChatRef diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a3b9f34346..d1bdeba341 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -172,10 +172,10 @@ data ChatHooks = ChatHooks { -- preCmdHook can be used to process or modify the commands before they are processed. -- This hook should be used to process CustomChatCommand. -- if this hook returns ChatResponse, the command processing will be skipped. - preCmdHook :: Maybe (ChatController -> ChatCommand -> IO (Either ChatResponse ChatCommand)), + preCmdHook :: Maybe (ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand)), -- eventHook can be used to additionally process or modify events, -- it is called before the event is sent to the user (or to the UI). - eventHook :: Maybe (ChatController -> ChatEvent -> IO ChatEvent), + eventHook :: Maybe (ChatController -> Either ChatError ChatEvent -> IO (Either ChatError ChatEvent)), -- acceptMember hook can be used to accept or reject member connecting via group link without API calls acceptMember :: Maybe (GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole))) } @@ -223,7 +223,7 @@ data ChatController = ChatController random :: TVar ChaChaDRG, eventSeq :: TVar Int, inputQ :: TBQueue String, - outputQ :: TBQueue (Maybe RemoteHostId, ChatEvent), + outputQ :: TBQueue (Maybe RemoteHostId, Either ChatError ChatEvent), connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, @@ -731,7 +731,6 @@ data ChatResponse | CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]} | CRAgentSubsDetails {agentSubs :: SubscriptionsInfo} | CRAgentQueuesInfo {agentQueuesInfo :: AgentQueuesInfo} - | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRAppSettings {appSettings :: AppSettings} | CRCustomChatResponse {user_ :: Maybe User, response :: Text} deriving (Show) @@ -839,8 +838,7 @@ data ChatEvent | CEvtAgentConnsDeleted {agentConnIds :: NonEmpty AgentConnId} | CEvtAgentUserDeleted {agentUserId :: Int64} | CEvtMessageError {user :: User, severity :: Text, errorMessage :: Text} - | CEvtChatError {user_ :: Maybe User, chatError :: ChatError} - | CEvtChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} + | CEvtChatErrors {chatErrors :: [ChatError]} | CEvtTimedAction {action :: String, durationMilliseconds :: Int64} | CEvtTerminalEvent TerminalEvent deriving (Show) @@ -869,7 +867,6 @@ data DeletedRcvQueue = DeletedRcvQueue } deriving (Show) --- some of these can only be used as command responses allowRemoteEvent :: ChatEvent -> Bool allowRemoteEvent = \case CEvtChatSuspended -> False @@ -893,8 +890,7 @@ logEventToFile = \case CEvtAgentRcvQueuesDeleted {} -> True CEvtAgentConnsDeleted {} -> True CEvtAgentUserDeleted {} -> True - -- CEvtChatCmdError {} -> True -- TODO this should be separately logged to file - CEvtChatError {} -> True + -- CRChatCmdError {} -> True -- TODO this should be separately logged to file as command error CEvtMessageError {} -> True CEvtTerminalEvent te -> case te of TEMemberSubError {} -> True @@ -1408,7 +1404,7 @@ data RemoteCtrlSession tls :: TLS, rcsSession :: RCCtrlSession, http2Server :: Async (), - remoteOutputQ :: TBQueue ChatEvent + remoteOutputQ :: TBQueue (Either ChatError ChatEvent) } data RemoteCtrlSessionState @@ -1507,11 +1503,17 @@ mkStoreError :: SomeException -> StoreError mkStoreError = SEInternalError . show {-# INLINE mkStoreError #-} -chatCmdError :: Maybe User -> String -> ChatResponse -chatCmdError user = CRChatCmdError user . ChatError . CECommandError +throwCmdError :: String -> CM a +throwCmdError = throwError . ChatError . CECommandError +{-# INLINE throwCmdError #-} + +chatCmdError :: String -> Either ChatError ChatResponse +chatCmdError = Left . ChatError . CECommandError +{-# INLINE chatCmdError #-} throwChatError :: ChatErrorType -> CM a throwChatError = throwError . ChatError +{-# INLINE throwChatError #-} toViewTE :: TerminalEvent -> CM () toViewTE = toView . CEvtTerminalEvent @@ -1523,7 +1525,19 @@ toView = lift . toView' {-# INLINE toView #-} toView' :: ChatEvent -> CM' () -toView' ev = do +toView' = toView_ . Right +{-# INLINE toView' #-} + +eToView :: ChatError -> CM () +eToView = lift . eToView' +{-# INLINE eToView #-} + +eToView' :: ChatError -> CM' () +eToView' = toView_ . Left +{-# INLINE eToView' #-} + +toView_ :: Either ChatError ChatEvent -> CM' () +toView_ ev = do cc@ChatController {outputQ = localQ, remoteCtrlSession = session, config = ChatConfig {chatHooks}} <- ask event <- case eventHook chatHooks of Just hook -> liftIO $ hook cc ev @@ -1531,7 +1545,7 @@ toView' ev = do atomically $ readTVar session >>= \case Just (_, RCSessionConnected {remoteOutputQ}) - | allowRemoteEvent event -> writeTBQueue remoteOutputQ event + | either (const True) allowRemoteEvent event -> writeTBQueue remoteOutputQ event -- TODO potentially, it should hold some events while connecting _ -> writeTBQueue localQ (Nothing, event) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 34fc0423fb..865eb6a760 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -8,6 +8,7 @@ module Simplex.Chat.Core runSimplexChat, sendChatCmdStr, sendChatCmd, + printResponseEvent, ) where @@ -23,9 +24,10 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) +import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Store.Profiles import Simplex.Chat.Types -import Simplex.Chat.View (serializeChatResponse) +import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import System.Exit (exitFailure) @@ -62,10 +64,10 @@ runSimplexChat ChatOpts {maintenance} u cc chat a2 <- async $ chat u cc waitEither_ a1 a2 -sendChatCmdStr :: ChatController -> String -> IO ChatResponse +sendChatCmdStr :: ChatController -> String -> IO (Either ChatError ChatResponse) sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc -sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse +sendChatCmd :: ChatController -> ChatCommand -> IO (Either ChatError ChatResponse) sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc getSelectActiveUser :: DBStore -> IO (Maybe User) @@ -107,12 +109,17 @@ createActiveUser cc = do displayName <- T.pack <$> getWithPrompt "display name" let profile = Just Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} execChatCommand' (CreateActiveUser NewUser {profile, pastTimestamp = False}) `runReaderT` cc >>= \case - CRActiveUser user -> pure user - r -> do - ts <- getCurrentTime - tz <- getCurrentTimeZone - putStrLn $ serializeChatResponse (Nothing, Nothing) ts tz Nothing r - loop + Right (CRActiveUser user) -> pure user + r -> printResponseEvent (Nothing, Nothing) (config cc) r >> loop + +printResponseEvent :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Either ChatError r -> IO () +printResponseEvent hu cfg = \case + Right r -> do + ts <- getCurrentTime + tz <- getCurrentTimeZone + putStrLn $ serializeChatResponse hu cfg ts tz (fst hu) r + Left e -> do + putStrLn $ serializeChatError True cfg e getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 77871ccc1b..8e7fba8255 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -225,7 +225,7 @@ startReceiveUserFiles :: User -> CM () startReceiveUserFiles user = do filesToReceive <- withStore' (`getRcvFilesToReceive` user) forM_ filesToReceive $ \ft -> - flip catchChatError (toView . CEvtChatError (Just user)) $ + flip catchChatError eToView $ toView =<< receiveFileEvt' user ft False Nothing Nothing restoreCalls :: CM' () @@ -267,32 +267,28 @@ useServers as opDomains uss = xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss in (smp', xftp') -execChatCommand :: Maybe RemoteHostId -> ByteString -> CM' ChatResponse -execChatCommand rh s = do - u <- readTVarIO =<< asks currentUser +execChatCommand :: Maybe RemoteHostId -> ByteString -> CM' (Either ChatError ChatResponse) +execChatCommand rh s = case parseChatCommand s of - Left e -> pure $ chatCmdError u e + Left e -> pure $ chatCmdError e Right cmd -> case rh of Just rhId - | allowRemoteCommand cmd -> execRemoteCommand u rhId cmd s - | otherwise -> pure $ CRChatCmdError u $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand + | allowRemoteCommand cmd -> execRemoteCommand rhId cmd s + | otherwise -> pure $ Left $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand _ -> do cc@ChatController {config = ChatConfig {chatHooks}} <- ask case preCmdHook chatHooks of - Just hook -> liftIO (hook cc cmd) >>= either pure (execChatCommand_ u) - Nothing -> execChatCommand_ u cmd + Just hook -> liftIO (hook cc cmd) >>= either pure execChatCommand' + Nothing -> execChatCommand' cmd -execChatCommand' :: ChatCommand -> CM' ChatResponse -execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) +execChatCommand' :: ChatCommand -> CM' (Either ChatError ChatResponse) +execChatCommand' cmd = handleCommandError $ processChatCommand cmd -execChatCommand_ :: Maybe User -> ChatCommand -> CM' ChatResponse -execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd +execRemoteCommand :: RemoteHostId -> ChatCommand -> ByteString -> CM' (Either ChatError ChatResponse) +execRemoteCommand rhId cmd s = handleCommandError $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s -execRemoteCommand :: Maybe User -> RemoteHostId -> ChatCommand -> ByteString -> CM' ChatResponse -execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s - -handleCommandError :: Maybe User -> CM ChatResponse -> CM' ChatResponse -handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catches` ioErrors) +handleCommandError :: CM ChatResponse -> CM' (Either ChatError ChatResponse) +handleCommandError a = runExceptT a `E.catches` ioErrors where ioErrors = [ E.Handler $ \(e :: ExitCode) -> E.throwIO e, @@ -502,7 +498,7 @@ processChatCommand' vr = \case pure $ CRChatTags user tags APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) - unless (null errs) $ toView $ CEvtChatErrors (Just user) (map ChatErrorStore errs) + unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRApiChats user previews APIGetChat (ChatRef cType cId) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled @@ -517,8 +513,8 @@ processChatCommand' vr = \case when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" (localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) pure $ CRApiChat user (AChat SCTLocal localChat) navInfo - CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not implemented" + CTContactConnection -> throwCmdError "not supported" APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search pure $ CRChatItems user Nothing chatItems @@ -553,14 +549,14 @@ processChatCommand' vr = \case 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 - CTDirect -> do + APISetChatTags (ChatRef cType chatId) tagIds -> withUser $ \user -> case cType of + CTDirect -> withFastStore' $ \db -> do updateDirectChatTags db chatId (maybe [] L.toList tagIds) CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId - CTGroup -> do + CTGroup -> withFastStore' $ \db -> do updateGroupChatTags db chatId (maybe [] L.toList tagIds) CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId - _ -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "not supported" APIDeleteChatTag tagId -> withUser $ \user -> do withFastStore' $ \db -> deleteChatTag db user tagId ok user @@ -622,7 +618,7 @@ processChatCommand' vr = \case assertUserGroupRole gInfo GRAuthor let (_, ft_) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership ft_ - then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) + then throwCmdError ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of @@ -660,8 +656,8 @@ processChatCommand' vr = \case ci' <- updateLocalChatItem' db user noteFolderId ci (CISndMsgContent mc) True pure $ CRChatItemUpdated user (AChatItem SCTLocal SMDSnd (LocalChat nf) ci') _ -> throwChatError CEInvalidChatItemUpdate - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of CTDirect -> withContactLock "deleteChatItem" chatId $ do (ct, items) <- getCommandDirectChatItems user chatId itemIds @@ -697,8 +693,8 @@ processChatCommand' vr = \case CTLocal -> do (nf, items) <- getCommandLocalChatItems user chatId itemIds deleteLocalCIs user nf items True False - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where assertDeletable :: forall c. ChatTypeI c => [CChatItem c] -> CM () assertDeletable items = do @@ -781,9 +777,9 @@ processChatCommand' vr = \case r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction pure $ CRChatItemReaction user add r _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" - CTLocal -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTLocal -> throwCmdError "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where checkReactionAllowed rs = do when ((reaction `elem` rs) == add) $ @@ -799,8 +795,8 @@ processChatCommand' vr = \case CTDirect -> planForward user . snd =<< getCommandDirectChatItems user fromChatId itemIds CTGroup -> planForward user . snd =<< getCommandGroupChatItems user fromChatId itemIds CTLocal -> planForward user . snd =<< getCommandLocalChatItems user fromChatId itemIds - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where planForward :: User -> [CChatItem c] -> CM ChatResponse planForward user items = do @@ -863,8 +859,8 @@ processChatCommand' vr = \case Just cmrs' -> createNoteFolderContentItems user toChatId cmrs' Nothing -> pure $ CRNewChatItems user [] - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" where prepareForward :: User -> CM [ComposedMessageReq] prepareForward user = case fromCType of @@ -1004,8 +1000,8 @@ processChatCommand' vr = \case user <- withFastStore $ \db -> getUserByNoteFolderId db chatId withFastStore' $ \db -> updateLocalChatItemsRead db user chatId ok user - CTContactRequest -> pure $ chatCmdError Nothing "not supported" - CTContactConnection -> pure $ chatCmdError Nothing "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" APIChatItemsRead chatRef@(ChatRef cType chatId) itemIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId @@ -1021,9 +1017,9 @@ processChatCommand' vr = \case setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user - CTLocal -> pure $ chatCmdError Nothing "not supported" - CTContactRequest -> pure $ chatCmdError Nothing "not supported" - CTContactConnection -> pure $ chatCmdError Nothing "not supported" + CTLocal -> throwCmdError "not supported" + CTContactRequest -> throwCmdError "not supported" + CTContactConnection -> throwCmdError "not supported" APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do @@ -1040,7 +1036,7 @@ processChatCommand' vr = \case nf <- getNoteFolder db user chatId liftIO $ updateNoteFolderUnreadChat db user nf unreadChat ok user - _ -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "not supported" APIDeleteChat cRef@(ChatRef cType chatId) cdm -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withFastStore $ \db -> getContact db vr user chatId @@ -1074,10 +1070,10 @@ processChatCommand' vr = \case let doSendDel = contactReady ct && contactActive ct && notify when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db vr userId ct) - deleteAgentConnectionsAsync' user contactConnIds doSendDel + deleteAgentConnectionsAsync' contactConnIds doSendDel CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withFastStore $ \db -> getPendingContactConnection db userId chatId - deleteAgentConnectionAsync user acId + deleteAgentConnectionAsync acId withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup -> do @@ -1100,8 +1096,8 @@ 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" + CTLocal -> throwCmdError "not supported" + CTContactRequest -> throwCmdError "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withFastStore $ \db -> getContact db vr user chatId @@ -1124,8 +1120,8 @@ 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" + CTContactConnection -> throwCmdError "not supported" + CTContactRequest -> throwCmdError "not supported" APIAcceptContact incognito connReqId -> withUser $ \_ -> do userContactLinkId <- withFastStore $ \db -> getUserContactLinkIdByCReq db connReqId withUserContactLock "acceptContact" userContactLinkId $ do @@ -1172,7 +1168,7 @@ processChatCommand' vr = \case forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] ok user - else pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFCalls)) + else throwCmdError ("feature not allowed " <> T.unpack (chatFeatureNameText CFCalls)) SendCallInvitation cName callType -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName processChatCommand $ APISendCallInvitation contactId callType @@ -1286,7 +1282,7 @@ processChatCommand' vr = \case g <- getGroupInfo db vr user chatId liftIO $ setGroupUIThemes db user g uiThemes ok user - _ -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "not supported" APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken APIRegisterToken token mode -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) @@ -1294,10 +1290,10 @@ processChatCommand' vr = \case APICheckToken token -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (`checkNtfToken` token) APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ - APIGetNtfConns nonce encNtfInfo -> withUser $ \user -> do + APIGetNtfConns nonce encNtfInfo -> withUser $ \_ -> do ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo (errs, ntfMsgs) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (getMsgConn db) (L.toList ntfInfos)) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRNtfConns $ catMaybes ntfMsgs where getMsgConn :: DB.Connection -> NotificationInfo -> IO (Maybe NtfConn) @@ -1408,7 +1404,7 @@ processChatCommand' vr = \case oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do lift $ setExpireCIFlag user False - expireChat user globalTTL `catchChatError` (toView . CEvtChatError (Just user)) + expireChat user globalTTL `catchChatError` eToView lift $ setChatItemsExpiration user globalTTL ttlCount ok user where @@ -1478,15 +1474,15 @@ processChatCommand' vr = \case liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` (toView . CEvtChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` eToView ok user - _ -> pure $ chatCmdError (Just user) "not supported" + _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withFastStore $ \db -> do liftIO $ updateGroupMemberSettings db user gId gMemberId settings getGroupMember db vr user gId gMemberId let ntfOn = showMessages $ memberSettings m - toggleNtf user m ntfOn + toggleNtf m ntfOn ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact @@ -1704,7 +1700,7 @@ processChatCommand' vr = \case forM_ customUserProfileId $ \profileId -> deletePCCIncognitoProfile db user profileId createDirectConnection db newUser agConnId ccLink' ConnNew Nothing subMode initialChatVersion PQSupportOn - deleteAgentConnectionAsync user (aConnId' conn) + deleteAgentConnectionAsync (aConnId' conn) pure conn' APIConnectPlan userId cLink -> withUserId userId $ \user -> uncurry (CRConnectionPlan user) <$> connectPlan user cLink @@ -1779,7 +1775,7 @@ processChatCommand' vr = \case APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do conns <- withFastStore $ \db -> getUserAddressConnections db vr user withChatLock "deleteMyAddress" $ do - deleteAgentConnectionsAsync user $ map aConnId conns + deleteAgentConnectionsAsync $ map aConnId conns withFastStore' (`deleteUserAddress` user) let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} r <- updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing @@ -2019,7 +2015,7 @@ processChatCommand' vr = \case updateGroupMemberStatus db userId fromMember GSMemInvited updateGroupMemberStatus db userId membership GSMemInvited throwError e - updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` (toView . CEvtChatError (Just user)) + updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` eToView pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user -> do @@ -2048,7 +2044,7 @@ processChatCommand' vr = \case (errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems unless (null acis) $ toView $ CEvtNewChatItems user acis let errs = errs1 <> errs2 - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds @@ -2136,9 +2132,9 @@ processChatCommand' vr = \case let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_ unless (null acis) $ toView $ CEvtNewChatItems user acis (errs, blocked) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updateGroupMemberBlocked db user gInfo mrs) blockMems) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs -- TODO not batched - requires agent batch api - forM_ blocked $ \m -> toggleNtf user m (not blockFlag) + forM_ blocked $ \m -> toggleNtf m (not blockFlag) pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag} where sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c @@ -2161,7 +2157,7 @@ processChatCommand' vr = \case acis = acis2 <> acis3 errs = errs1 <> errs2 <> errs3 unless (null acis) $ toView $ CEvtNewChatItems user acis - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs when withMessages $ deleteMessages user gInfo $ currentMems <> pendingMems pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2 <> deleted3) withMessages -- same order is not guaranteed where @@ -2357,7 +2353,7 @@ processChatCommand' vr = \case LastChats count_ -> withUser' $ \user -> do let count = fromMaybe 5000 count_ (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user False (PTLast count) clqNoFilters) - unless (null errs) $ toView $ CEvtChatErrors (Just user) (map ChatErrorStore errs) + unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName @@ -2403,7 +2399,7 @@ processChatCommand' vr = \case processChatCommand $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage - SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" + SendFileDescription _chatName _f -> throwCmdError "TODO" -- TODO to use priority transactions we need a parameter that differentiates manual and automatic acceptance ReceiveFile fileId userApprovedRelays encrypted_ rcvInline_ filePath_ -> withUser $ \_ -> withFileLock "receiveFile" fileId . procCmd $ do @@ -2426,7 +2422,7 @@ processChatCommand' vr = \case throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do fileAgentConnIds <- cancelSndFile user ftm fts True - deleteAgentConnectionsAsync user fileAgentConnIds + deleteAgentConnectionsAsync fileAgentConnIds withFastStore (\db -> liftIO $ lookupChatRefByFileId db user fileId) >>= \case Nothing -> pure () Just (ChatRef CTDirect contactId) -> do @@ -2447,7 +2443,7 @@ processChatCommand' vr = \case | rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> case xftpRcvFile of Nothing -> do - cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ftr >>= mapM_ deleteAgentConnectionAsync ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId pure $ CRRcvFileCancelled user ci ftr Just XFTPRcvFile {agentRcvFileId} -> do @@ -2595,8 +2591,9 @@ processChatCommand' vr = \case GetAgentSubsDetails -> lift $ CRAgentSubsDetails <$> withAgent' getAgentSubscriptions GetAgentQueuesInfo -> lift $ CRAgentQueuesInfo <$> withAgent' getAgentQueuesInfo -- CustomChatCommand is unsupported, it can be processed in preCmdHook - -- in a modified CLI app or core - the hook should return Either ChatResponse ChatCommand - CustomChatCommand _cmd -> withUser $ \user -> pure $ chatCmdError (Just user) "not supported" + -- in a modified CLI app or core - the hook should return Either (Either ChatError ChatResponse) ChatCommand, + -- where Left means command result, and Right – some other command to be processed by this function. + CustomChatCommand _cmd -> withUser $ \_ -> throwCmdError "not supported" where procCmd :: CM ChatResponse -> CM ChatResponse procCmd = id @@ -2762,7 +2759,7 @@ processChatCommand' vr = \case let idsEvts = L.map ctSndEvent changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts lift $ createContactsSndFeatureItems user' changedCts' pure @@ -2802,7 +2799,7 @@ processChatCommand' vr = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateProfile" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` (toView . CEvtChatError (Just user)) + void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> CM ChatResponse @@ -3005,7 +3002,7 @@ processChatCommand' vr = \case deleteCIFiles user filesInfo withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) `catchChatError` \case - e@(ChatErrorAgent NO_USER _) -> toView $ CEvtChatError (Just user) e + e@(ChatErrorAgent NO_USER _) -> eToView e e -> throwError e withFastStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing @@ -3058,7 +3055,7 @@ processChatCommand' vr = \case connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse connectWithPlan user@User {userId} incognito ccLink plan | connectionPlanProceed plan = do - case plan of CPError e -> toView $ CEvtChatError (Just user) e; _ -> pure () + case plan of CPError e -> eToView e; _ -> pure () case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) -> processChatCommand $ APIConnectContactViaAddress userId incognito contactId @@ -3208,7 +3205,7 @@ processChatCommand' vr = \case 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 - processSendErrs user r + processSendErrs r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) deleteAt @@ -3288,7 +3285,7 @@ processChatCommand' vr = \case when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ - processSendErrs user r + processSendErrs r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) deleteAt @@ -3358,7 +3355,7 @@ processChatCommand' vr = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CEvtChatError (Just user)) + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` eToView where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -3377,23 +3374,23 @@ processChatCommand' vr = \case zipWith4 $ \(ComposedMessage {msgContent}, itemForwarded, ts, mm) f q -> \case Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) ts mm f q itemForwarded Left e -> Left e -- step over original error - processSendErrs :: User -> ([ChatError], [ChatItem c d]) -> CM () - processSendErrs user = \case + processSendErrs :: ([ChatError], [ChatItem c d]) -> CM () + processSendErrs = \case -- no errors ([], _) -> pure () -- at least one item is successfully created - (errs, _ci : _) -> toView $ CEvtChatErrors (Just user) errs + (errs, _ci : _) -> toView $ CEvtChatErrors errs -- single error ([err], []) -> throwError err -- multiple errors (errs@(err : _), []) -> do - toView $ CEvtChatErrors (Just user) errs + toView $ CEvtChatErrors errs throwError err getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect]) getCommandDirectChatItems user ctId itemIds = do ct <- withFastStore $ \db -> getContact db vr user ctId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure (ct, items) where getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) @@ -3402,7 +3399,7 @@ processChatCommand' vr = \case getCommandGroupChatItems user gId itemIds = do gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure (gInfo, items) where getGroupCI :: DB.Connection -> GroupInfo -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) @@ -3411,7 +3408,7 @@ processChatCommand' vr = \case getCommandLocalChatItems user nfId itemIds = do nf <- withStore $ \db -> getNoteFolder db user nfId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure (nf, items) where getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal)) @@ -3536,7 +3533,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' delay interval <- asks $ ciExpirationInterval . config forever $ do - flip catchChatError' (toView' . CEvtChatError (Just user)) $ do + flip catchChatError' (eToView') $ do expireFlags <- asks expireCIFlags atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated @@ -3568,7 +3565,7 @@ agentSubscriber = do q <- asks $ subQ . smpAgent forever (atomically (readTBQueue q) >>= process) `E.catchAny` \e -> do - toView' $ CEvtChatError Nothing $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing + eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing E.throwIO e where process :: (ACorrId, AEntityId, AEvt) -> CM' () @@ -3578,7 +3575,7 @@ agentSubscriber = do SAERcvFile -> processAgentMsgRcvFile corrId entId msg SAESndFile -> processAgentMsgSndFile corrId entId msg where - run action = action `catchChatError'` (toView' . CEvtChatError Nothing) + run action = action `catchChatError'` (eToView') type AgentBatchSubscribe = AgentClient -> [ConnId] -> ExceptT AgentErrorType IO (Map ConnId (Either AgentErrorType ())) @@ -3739,7 +3736,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> CM () pendingConnSubsToView rs = toViewTE . TEPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> CM [a] - withStore_ a = withStore' (`a` user) `catchChatError` \e -> toView (CEvtChatError (Just user) e) $> [] + withStore_ a = withStore' (`a` user) `catchChatError` \e -> eToView e $> [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -3761,28 +3758,28 @@ cleanupManager = do liftIO $ threadDelay' initialDelay stepDelay <- asks (cleanupManagerStepDelay . config) forever $ do - flip catchChatError (toView . CEvtChatError Nothing) $ do + flip catchChatError eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay - cleanupMessages `catchChatError` (toView . CEvtChatError Nothing) + cleanupMessages `catchChatError` eToView -- TODO possibly, also cleanup async commands - cleanupProbes `catchChatError` (toView . CEvtChatError Nothing) + cleanupProbes `catchChatError` eToView liftIO $ threadDelay' $ diffToMicroseconds interval where - runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CEvtChatError Nothing) $ do + runWithoutInitialDelay cleanupInterval = flip catchChatError eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users - forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CEvtChatError (Just u)) - forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CEvtChatError (Just u)) + forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView + forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView cleanupUser cleanupInterval stepDelay user = do - cleanupTimedItems cleanupInterval user `catchChatError` (toView . CEvtChatError (Just user)) + cleanupTimedItems cleanupInterval user `catchChatError` eToView liftIO $ threadDelay' stepDelay -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted - cleanupDeletedContacts user `catchChatError` (toView . CEvtChatError (Just user)) + cleanupDeletedContacts user `catchChatError` eToView liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime @@ -3794,7 +3791,7 @@ cleanupManager = do contacts <- withStore' $ \db -> getDeletedContacts db vr user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) - `catchChatError` (toView . CEvtChatError (Just user)) + `catchChatError` eToView cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts @@ -3820,7 +3817,7 @@ expireChatItems user@User {userId} globalTTL sync = do loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do - process a `catchChatError` (toView . CEvtChatError (Just user)) + process a `catchChatError` eToView loop as process continue :: CM () -> CM () continue a = diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index a2c8ae74b2..8158df5c94 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -184,11 +184,11 @@ callTimed ct aciContent = aciContentCallStatus (ACIContent _ (CIRcvCall st _)) = Just st aciContentCallStatus _ = Nothing -toggleNtf :: User -> GroupMember -> Bool -> CM () -toggleNtf user m ntfOn = +toggleNtf :: GroupMember -> Bool -> CM () +toggleNtf m ntfOn = when (memberActive m) $ forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CEvtChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` eToView 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 @@ -388,8 +388,8 @@ cancelFilesInProgress user filesInfo = do lift $ agentXFTPDeleteRcvFiles xrfIds let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs smpRFConnIds = mapMaybe smpRcvFileConnId rfs - deleteAgentConnectionsAsync user smpSFConnIds - deleteAgentConnectionsAsync user smpRFConnIds + deleteAgentConnectionsAsync smpSFConnIds + deleteAgentConnectionsAsync smpRFConnIds where fileEnded CIFileInfo {fileStatus} = case fileStatus of Just (AFS _ status) -> ciFileEnded status @@ -446,7 +446,7 @@ deleteDirectCIs user ct items = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteCIFiles user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure deletions where deleteItem db (CChatItem md ci) = do @@ -458,7 +458,7 @@ deleteGroupCIs user gInfo items 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) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure deletions where deleteItem :: DB.Connection -> CChatItem 'CTGroup -> IO ChatItemDeletion @@ -491,7 +491,7 @@ deleteLocalCIs user nf items byUser timed = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteFilesLocally ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRChatItemsDeleted user deletions byUser timed where deleteItem db (CChatItem md ci) = do @@ -510,7 +510,7 @@ markDirectCIsDeleted user ct items deletedTs = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items cancelFilesInProgress user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure deletions where markDeleted db (CChatItem md ci) = do @@ -522,7 +522,7 @@ markGroupCIsDeleted user gInfo items 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) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure deletions -- pure $ CRChatItemsDeleted user deletions byUser False where @@ -998,7 +998,7 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId else forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` (toView . CEvtChatError (Just user)) + processIntro intro `catchChatError` eToView memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = let mInfo = memberInfo reMember @@ -1021,7 +1021,7 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac (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 $ CEvtChatErrors (Just user) errors + unless (null errors) $ toView $ CEvtChatErrors errors let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_ forM_ (L.nonEmpty events') $ \events'' -> sendGroupMemberMessages user conn events'' groupId @@ -1121,7 +1121,7 @@ deleteGroupLinkIfExists user gInfo = do deleteGroupLink_ :: User -> GroupInfo -> Connection -> CM () deleteGroupLink_ user gInfo conn = do - deleteAgentConnectionAsync user $ aConnId conn + deleteAgentConnectionAsync $ aConnId conn withStore' $ \db -> deleteGroupLink db user gInfo startProximateTimedItemThread :: User -> (ChatRef, ChatItemId) -> UTCTime -> CM () @@ -1162,7 +1162,7 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do deletedTs <- liftIO getCurrentTime deletions <- deleteGroupCIs user gInfo [ci] Nothing deletedTs toView $ CEvtChatItemsDeleted user deletions True True - _ -> toView . CEvtChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" + _ -> eToView $ ChatError $ CEInternalError "bad deleteTimedItem cType" startUpdatedTimedItemThread :: User -> ChatRef -> ChatItem c d -> ChatItem c d -> CM () startUpdatedTimedItemThread user chatRef ci ci' = @@ -1289,7 +1289,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo updateDirectCIFileStatus db vr user fileId CIFSSndComplete toView $ CEvtSndFileComplete user ci ft lift $ closeFileHandle fileId sndFiles - deleteAgentConnectionAsync user acId + deleteAgentConnectionAsync acId sendFileChunkNo :: SndFileTransfer -> Integer -> CM () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do @@ -1337,7 +1337,7 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus, cryptoArgs, fileInvitati removeFile fsFilePath `catchChatError` \_ -> pure () renameFile tmpFile fsFilePath Left e -> do - toView $ CEvtChatError Nothing e + eToView e removeFile tmpFile `catchChatError` \_ -> pure () withStore' (`removeFileCryptoArgs` fileId) where @@ -1362,7 +1362,7 @@ isFileActive fileId files = do cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM (Maybe ConnId) cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInline} = - cancel' `catchChatError` (\e -> toView (CEvtChatError (Just user) e) $> fileConnId) + cancel' `catchChatError` (\e -> eToView e $> fileConnId) where cancel' = do lift $ closeFileHandle fileId rcvFiles @@ -1380,13 +1380,13 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInlin cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM [ConnId] cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) - `catchChatError` (toView . CEvtChatError (Just user)) + `catchChatError` eToView case xftpSndFile of Nothing -> catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) Just xsf -> do forM_ fts (\ft -> cancelSndFileTransfer user ft False) - lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` (toView . CEvtChatError (Just user)) + lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` eToView pure [] -- TODO v6.0 remove @@ -1394,7 +1394,7 @@ cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM (Maybe ConnId) cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = if fileStatus == FSCancelled || fileStatus == FSComplete then pure Nothing - else cancel' `catchChatError` (\e -> toView (CEvtChatError (Just user) e) $> fileConnId) + else cancel' `catchChatError` (\e -> eToView e $> fileConnId) where cancel' = do withStore' $ \db -> do @@ -1421,16 +1421,16 @@ deleteMembersConnections user members = deleteMembersConnections' user members F deleteMembersConnections' :: User -> [GroupMember] -> Bool -> CM () deleteMembersConnections' user members waitDelivery = do let memberConns = mapMaybe (\GroupMember {activeConn} -> activeConn) members - deleteAgentConnectionsAsync' user (map aConnId memberConns) waitDelivery + deleteAgentConnectionsAsync' (map aConnId memberConns) waitDelivery lift . void . withStoreBatch' $ \db -> map (\Connection {connId} -> deleteConnectionRecord db user connId) memberConns -deleteMemberConnection :: User -> GroupMember -> CM () -deleteMemberConnection user mem = deleteMemberConnection' user mem False +deleteMemberConnection :: GroupMember -> CM () +deleteMemberConnection mem = deleteMemberConnection' mem False -deleteMemberConnection' :: User -> GroupMember -> Bool -> CM () -deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do +deleteMemberConnection' :: GroupMember -> Bool -> CM () +deleteMemberConnection' GroupMember {activeConn} waitDelivery = do forM_ activeConn $ \conn -> do - deleteAgentConnectionAsync' user (aConnId conn) waitDelivery + deleteAgentConnectionAsync' (aConnId conn) waitDelivery withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: User -> GroupMember -> CM () @@ -1515,7 +1515,7 @@ sendGroupMemberMessages user conn events groupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) let idsEvts = L.map (GroupId groupId,) events (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs forM_ (L.nonEmpty msgs) $ \msgs' -> batchSendConnMessages user conn MsgFlags {notification = True} msgs' @@ -1644,7 +1644,7 @@ sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> Non 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? when shouldSendProfileUpdate $ - sendProfileUpdate `catchChatError` (toView . CEvtChatError (Just user)) + sendProfileUpdate `catchChatError` eToView sendGroupMessages_ user gInfo members events where User {profile = p, userMemberProfileUpdatedAt} = user @@ -1800,10 +1800,10 @@ memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = XGrpMsgForward {} -> True _ -> False -sendGroupMemberMessage :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () -sendGroupMemberMessage user gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do +sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () +sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) - messageMember msg `catchChatError` (toView . CEvtChatError (Just user)) + messageMember msg `catchChatError` eToView where messageMember :: SndMessage -> CM () messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case @@ -1986,20 +1986,22 @@ agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqSup subMode pure (cmdId, connId) -deleteAgentConnectionAsync :: User -> ConnId -> CM () -deleteAgentConnectionAsync user acId = deleteAgentConnectionAsync' user acId False +deleteAgentConnectionAsync :: ConnId -> CM () +deleteAgentConnectionAsync acId = deleteAgentConnectionAsync' acId False +{-# INLINE deleteAgentConnectionAsync #-} -deleteAgentConnectionAsync' :: User -> ConnId -> Bool -> CM () -deleteAgentConnectionAsync' user acId waitDelivery = do - withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` (toView . CEvtChatError (Just user)) +deleteAgentConnectionAsync' :: ConnId -> Bool -> CM () +deleteAgentConnectionAsync' acId waitDelivery = do + withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` eToView -deleteAgentConnectionsAsync :: User -> [ConnId] -> CM () -deleteAgentConnectionsAsync user acIds = deleteAgentConnectionsAsync' user acIds False +deleteAgentConnectionsAsync :: [ConnId] -> CM () +deleteAgentConnectionsAsync acIds = deleteAgentConnectionsAsync' acIds False +{-# INLINE deleteAgentConnectionsAsync #-} -deleteAgentConnectionsAsync' :: User -> [ConnId] -> Bool -> CM () -deleteAgentConnectionsAsync' _ [] _ = pure () -deleteAgentConnectionsAsync' user acIds waitDelivery = do - withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` (toView . CEvtChatError (Just user)) +deleteAgentConnectionsAsync' :: [ConnId] -> Bool -> CM () +deleteAgentConnectionsAsync' [] _ = pure () +deleteAgentConnectionsAsync' acIds waitDelivery = do + withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` eToView agentXFTPDeleteRcvFile :: RcvFileId -> FileTransferId -> CM () agentXFTPDeleteRcvFile aFileId fileId = do @@ -2100,7 +2102,7 @@ createContactsFeatureItems :: createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do let dirsCIContents = map contactChangedFeatures cts (errs, acis) <- partitionEithers <$> createInternalItemsForChats user Nothing dirsCIContents - unless (null errs) $ toView' $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView' $ CEvtChatErrors errs toView' $ CEvtNewChatItems user acis where contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, [CIContent d]) @@ -2182,7 +2184,7 @@ createLocalChatItems :: createLocalChatItems user cd itemsData createdAt = do withStore' $ \db -> updateChatTs db user cd createdAt (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) - unless (null errs) $ toView $ CEvtChatErrors (Just user) errs + unless (null errs) $ toView $ CEvtChatErrors errs pure items where createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) @@ -2195,9 +2197,7 @@ withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = asks currentUser >>= readTVarIO - >>= maybe (throwChatError CENoActiveUser) run - where - run u = action u `catchChatError` (pure . CRChatCmdError (Just u)) + >>= maybe (throwChatError CENoActiveUser) action withUser :: (User -> CM ChatResponse) -> CM ChatResponse withUser action = withUser' $ \user -> diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 53aee8938f..d60faef639 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -96,14 +96,14 @@ processAgentMessage _ _ (DEL_RCVQS delQs) = processAgentMessage _ _ (DEL_CONNS connIds) = toView $ CEvtAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = - toView $ CEvtChatError Nothing $ ChatErrorAgent e Nothing + eToView $ ChatErrorAgent e Nothing processAgentMessage corrId connId msg = do lockEntity <- critical (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do vr <- chatVersionRange -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CEvtChatError (Just user)) + Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. @@ -144,7 +144,7 @@ processAgentMessageNoConn = \case errsEvent cErrs = do vr <- chatVersionRange errs <- lift $ rights <$> withStoreBatch' (\db -> map (getChatErr vr db) cErrs) - toView $ CEvtChatErrors Nothing errs + toView $ CEvtChatErrors errs where getChatErr :: VersionRangeChat -> DB.Connection -> (ConnId, AgentErrorType) -> IO ChatError getChatErr vr db (connId, err) = @@ -156,7 +156,7 @@ processAgentMsgSndFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPSndFileDBIds` AgentSndFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgSndFile" fileId $ withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` (toView . CEvtChatError (Just user)) + Just user -> process user fileId `catchChatError` eToView _ -> do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId @@ -208,9 +208,9 @@ processAgentMsgSndFile _corrId aFileId msg = do Just rs -> case L.last rs of Right ([msgDeliveryId], _) -> withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId - Right (deliveryIds, _) -> toView $ CEvtChatError (Just user) $ ChatError $ CEInternalError $ "SFDONE, sendFileDescriptions: expected 1 delivery id, got " <> show (length deliveryIds) - Left e -> toView $ CEvtChatError (Just user) e - Nothing -> toView $ CEvtChatError (Just user) $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" + Right (deliveryIds, _) -> eToView $ ChatError $ CEInternalError $ "SFDONE, sendFileDescriptions: expected 1 delivery id, got " <> show (length deliveryIds) + Left e -> eToView e + Nothing -> eToView $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do ms <- withStore' $ \db -> getGroupMembers db vr user g @@ -259,7 +259,7 @@ processAgentMsgSndFile _corrId aFileId msg = do let (errs, msgReqs) = partitionEithers . L.toList $ L.zipWith (fmap . toMsgReq) connsIdsEvts sndMsgs_ delivered <- mapM deliverMessages (L.nonEmpty msgReqs) let errs' = errs <> maybe [] (lefts . L.toList) delivered - unless (null errs') $ toView $ CEvtChatErrors (Just user) errs' + unless (null errs') $ toView $ CEvtChatErrors errs' pure delivered where connDescrEvents :: Int -> NonEmpty (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) @@ -298,7 +298,7 @@ processAgentMsgRcvFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPRcvFileDBIds` AgentRcvFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgRcvFile" fileId $ withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` (toView . CEvtChatError (Just user)) + Just user -> process user fileId `catchChatError` eToView _ -> do lift $ withAgent' (`xftpDeleteRcvFile` aFileId) throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId @@ -438,13 +438,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MWARN _ err -> processConnMWARN connEntity conn err MERR _ err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS _ err -> do -- error cannot be AUTH error here - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -468,11 +468,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> toView $ CEvtChatError (Just user) e + processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> eToView e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "contact msg=error " <> eInfo <> " " <> tshow e - toView $ CEvtChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) + eToView (ChatError . CEException $ "error parsing chat message: " <> e) checkSendRcpt ct' $ rights aChatMsgs -- not crucial to use ct'' from processEvent where aChatMsgs = parseChatMessages msgBody @@ -655,14 +655,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processConnMWARN connEntity conn err MERR msgId err -> do updateDirectItemStatus ct conn msgId (CISSndError $ agentSndError err) - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do -- error cannot be AUTH error here updateDirectItemsStatusMsgs ct conn (L.toList msgIds) (CISSndError $ agentSndError err) - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -757,7 +757,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfo _ -> -- TODO Keep rejected member to allow them to appeal against rejection. when (memberStatus m == GSMemRejected) $ do - deleteMemberConnection' user m True + deleteMemberConnection' m True withStore' $ \db -> deleteGroupMember db user m XOk -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" @@ -831,12 +831,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent tags eInfo chatMsg `catchChatError` \e -> toView $ CEvtChatError (Just user) e + processEvent tags eInfo chatMsg `catchChatError` \e -> eToView e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e - toView $ CEvtChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) - forwardMsgs (rights aChatMsgs) `catchChatError` (toView . CEvtChatError (Just user)) + eToView (ChatError . CEException $ "error parsing chat message: " <> e) + forwardMsgs (rights aChatMsgs) `catchChatError` eToView checkSendRcpt $ rights aChatMsgs where aChatMsgs = parseChatMessages msgBody @@ -965,16 +965,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MERR msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSError $ agentSndError err) -- group errors are silenced to reduce load on UI event log - -- toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + -- eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do let newStatus = GSSError $ agentSndError err -- error cannot be AUTH error here withStore' $ \db -> forM_ msgIds $ \msgId -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure () - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) ERR err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1051,7 +1051,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> updateSndFileChunkSent db ft msgId unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do - cancelSndFileTransfer user ft True >>= mapM_ (deleteAgentConnectionAsync user) + cancelSndFileTransfer user ft True >>= mapM_ deleteAgentConnectionAsync case err of SMP _ SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> do @@ -1070,7 +1070,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () ERR err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1119,10 +1119,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () MERR _ err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err ERR err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1131,7 +1131,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do @@ -1157,7 +1157,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteRcvFileChunks db ft getChatItemByFileId db vr user fileId toView $ CEvtRcvFileComplete user ci - forM_ conn_ $ \conn -> deleteAgentConnectionAsync user (aConnId conn) + mapM_ (deleteAgentConnectionAsync . aConnId) conn_ RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo @@ -1171,10 +1171,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) processConnMERR connEntity conn err ERR err -> do - toView $ CEvtChatError (Just user) (ChatErrorAgent err $ Just connEntity) + eToView (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1349,7 +1349,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = badRcvFileChunk :: RcvFileTransfer -> String -> CM () badRcvFileChunk ft err = unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync throwChatError $ CEFileRcvChunk err memberConnectedChatItem :: GroupInfo -> GroupMember -> CM () @@ -1816,7 +1816,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CEvtRcvFileSndCancelled user ci ft @@ -1910,7 +1910,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CEvtRcvFileSndCancelled user ci ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id @@ -1997,7 +1997,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted contactConns <- withStore' $ \db -> getContactConnections db vr userId ct' - deleteAgentConnectionsAsync user $ map aConnId contactConns + deleteAgentConnectionsAsync $ map aConnId contactConns forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} let ct'' = ct' {activeConn = activeConn'} :: Contact @@ -2006,7 +2006,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtContactDeletedByContact user ct'' else do contactConns <- withStore' $ \db -> getContactConnections db vr userId c - deleteAgentConnectionsAsync user $ map aConnId contactConns + deleteAgentConnectionsAsync $ map aConnId contactConns withStore $ \db -> deleteContact db user c where brokerTs = metaBrokerTs msgMeta @@ -2447,7 +2447,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - sendGroupMemberMessage user gInfo reMember (XGrpMemFwd (memberInfo m) introInv) (Just introId) $ + sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo m) introInv) (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -2518,7 +2518,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do bm' <- setMemberBlocked bm - toggleNtf user bm' (not blocked) + toggleNtf bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs ciContent groupMsgToView gInfo ci @@ -2592,7 +2592,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right member@GroupMember {groupMemberId, memberProfile} -> checkRole member $ do -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave - deleteMemberConnection user member + deleteMemberConnection member -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user member when withMessages $ deleteMessages member SMDRcv @@ -2613,7 +2613,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpLeave gInfo m msg brokerTs = do - deleteMemberConnection user m + deleteMemberConnection 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) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 7f9f3eb505..194fc1bb06 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -13,6 +13,7 @@ import Control.Concurrent.STM import Control.Exception (SomeException, catch) import Control.Monad.Except import Control.Monad.Reader +import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) @@ -72,13 +73,19 @@ data DBMigrationResult $(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "DBM") ''DBMigrationResult) -data APIResponse = APIResponse {remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} +data APIResult r + = APIResult {remoteHostId :: Maybe RemoteHostId, result :: r} + | APIError {remoteHostId :: Maybe RemoteHostId, error :: ChatError} -data APIEvent = APIEvent {remoteHostId :: Maybe RemoteHostId, resp :: ChatEvent} +eitherToResult :: Maybe RemoteHostId -> Either ChatError r -> APIResult r +eitherToResult rhId = either (APIError rhId) (APIResult rhId) +{-# INLINE eitherToResult #-} -$(JQ.deriveToJSON defaultJSON ''APIResponse) +$(pure []) -$(JQ.deriveToJSON defaultJSON ''APIEvent) +instance ToJSON r => ToJSON (APIResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''APIResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''APIResult) foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString @@ -290,15 +297,14 @@ chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString chatSendCmd cc = chatSendRemoteCmd cc Nothing chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO JSONByteString -chatSendRemoteCmd cc rh s = J.encode . APIResponse rh <$> runReaderT (execChatCommand rh s) cc +chatSendRemoteCmd cc rh s = J.encode . eitherToResult rh <$> runReaderT (execChatCommand rh s) cc chatRecvMsg :: ChatController -> IO JSONByteString -chatRecvMsg ChatController {outputQ} = json <$> readChatResponse +chatRecvMsg ChatController {outputQ} = J.encode . uncurry eitherToResult <$> readChatResponse where - json (remoteHostId, resp) = J.encode APIEvent {remoteHostId, resp} readChatResponse = atomically (readTBQueue outputQ) >>= \case - (_, CEvtTerminalEvent {}) -> readChatResponse + (_, Right CEvtTerminalEvent {}) -> readChatResponse out -> pure out chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index bcdd60377f..105f872b75 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -370,16 +370,17 @@ processRemoteCommand :: RemoteHostId -> RemoteHostClient -> ChatCommand -> ByteS processRemoteCommand remoteHostId c cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f SendImage chatName f -> sendFile "/img" chatName f - _ -> liftRH remoteHostId $ remoteSend c s + _ -> chatRemoteSend s where sendFile cmdName chatName (CryptoFile path cfArgs) = do -- don't encrypt in host if already encrypted locally CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption - liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + chatRemoteSend $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] cryptoFileStr CryptoFile {filePath, cryptoArgs} = maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs <> encodeUtf8 (T.pack filePath) + chatRemoteSend = either throwError pure <=< liftRH remoteHostId . remoteSend c liftRH :: RemoteHostId -> ExceptT RemoteProtocolError IO a -> CM a liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) @@ -496,7 +497,7 @@ parseCtrlAppInfo :: JT.Value -> CM CtrlAppInfo parseCtrlAppInfo ctrlAppInfo = do liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo -handleRemoteCommand :: (ByteString -> CM' ChatResponse) -> RemoteCrypto -> TBQueue ChatEvent -> HTTP2Request -> CM' () +handleRemoteCommand :: (ByteString -> CM' (Either ChatError ChatResponse)) -> RemoteCrypto -> TBQueue (Either ChatError ChatEvent) -> HTTP2Request -> CM' () handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftIO (tryRemoteError' parseRequest) >>= \case @@ -510,7 +511,7 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque parseRequest = do (rfKN, header, getNext) <- parseDecryptHTTP2Body encryption request reqBody (rfKN,getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecode header) - replyError = reply . RRChatResponse . CRChatCmdError Nothing + replyError = reply . RRChatResponse . RRError processCommand :: User -> C.SbKeyNonce -> GetChunk -> RemoteCommand -> CM () processCommand user rfKN getNext = \case RCSend {command} -> lift $ handleSend execChatCommand command >>= reply @@ -527,7 +528,7 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque send resp attach sfKN send flush - Left e -> toView' . CEvtChatError Nothing . ChatErrorRemoteCtrl $ RCEProtocolError e + Left e -> eToView' $ ChatErrorRemoteCtrl $ RCEProtocolError e takeRCStep :: RCStepTMVar a -> CM a takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar @@ -549,17 +550,17 @@ tryRemoteError' :: ExceptT RemoteProtocolError IO a -> IO (Either RemoteProtocol tryRemoteError' = tryAllErrors' (RPEException . tshow) {-# INLINE tryRemoteError' #-} -handleSend :: (ByteString -> CM' ChatResponse) -> Text -> CM' RemoteResponse +handleSend :: (ByteString -> CM' (Either ChatError ChatResponse)) -> Text -> CM' RemoteResponse handleSend execChatCommand command = do logDebug $ "Send: " <> tshow command -- execChatCommand checks for remote-allowed commands -- convert errors thrown in execChatCommand into error responses to prevent aborting the protocol wrapper - RRChatResponse <$> execChatCommand (encodeUtf8 command) + RRChatResponse . eitherToResult <$> execChatCommand (encodeUtf8 command) -handleRecv :: Int -> TBQueue ChatEvent -> IO RemoteResponse +handleRecv :: Int -> TBQueue (Either ChatError ChatEvent) -> IO RemoteResponse handleRecv time events = do logDebug $ "Recv: " <> tshow time - RRChatEvent <$> (timeout time . atomically $ readTBQueue events) + RRChatEvent . fmap eitherToResult <$> (timeout time . atomically $ readTBQueue events) -- TODO this command could remember stored files and return IDs to allow removing files that are not needed. -- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files). @@ -614,7 +615,7 @@ remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} -- | Take a look at emoji of tlsunique, commit pairing, and start session server -verifyRemoteCtrlSession :: (ByteString -> CM' ChatResponse) -> Text -> CM RemoteCtrlInfo +verifyRemoteCtrlSession :: (ByteString -> CM' (Either ChatError ChatResponse)) -> Text -> CM RemoteCtrlInfo verifyRemoteCtrlSession execChatCommand sessCode' = do (sseq, client, ctrlName, sessionCode, vars) <- chatReadVar remoteCtrlSession >>= \case diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index b572780a1f..207eade665 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -16,11 +16,11 @@ import Control.Monad.Except import Control.Monad.Reader import Crypto.Hash (SHA512) import qualified Crypto.Hash as CH -import Data.Aeson ((.=)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Key as JK import qualified Data.Aeson.KeyMap as JM -import Data.Aeson.TH (deriveJSON) +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.ByteArray as BA import Data.ByteString (ByteString) @@ -42,7 +42,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Lazy (LazyByteString) import Simplex.Messaging.Encoding -import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TSbChainKeys) import Simplex.Messaging.Transport.Buffer (getBuffered) @@ -64,16 +64,40 @@ data RemoteCommand deriving (Show) data RemoteResponse - = RRChatResponse {chatResponse :: ChatResponse} - | RRChatEvent {chatEvent :: Maybe ChatEvent} -- 'Nothing' on poll timeout + = RRChatResponse {chatResponse :: RRResult ChatResponse} + | RRChatEvent {chatEvent :: Maybe (RRResult ChatEvent)} -- 'Nothing' on poll timeout | RRFileStored {filePath :: String} | RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- The protocol error happened on the server side deriving (Show) +data RRResult r + = RRResult {result :: r} + | RRError {error :: ChatError} + deriving (Show) + +resultToEither :: RRResult r -> Either ChatError r +resultToEither = \case + RRResult r -> Right r + RRError e -> Left e +{-# INLINE resultToEither #-} + +eitherToResult :: Either ChatError r -> RRResult r +eitherToResult = either RRError RRResult +{-# INLINE eitherToResult #-} + +$(pure []) + -- Force platform-independent encoding as the types aren't UI-visible -$(deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand) -$(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) +instance ToJSON r => ToJSON (RRResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''RRResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''RRResult) + +instance FromJSON r => FromJSON (RRResult r) where + parseJSON = $(JQ.mkParseJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''RRResult) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand) +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) -- * Client side / desktop @@ -109,16 +133,16 @@ closeRemoteHostClient RemoteHostClient {httpClient} = closeHTTP2Client httpClien -- ** Commands -remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO (Either ChatError ChatResponse) remoteSend c cmd = sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case - RRChatResponse cr -> pure cr + RRChatResponse cr -> pure $ resultToEither cr r -> badResponse r -remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatEvent) +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe (Either ChatError ChatEvent)) remoteRecv c ms = sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case - RRChatEvent cEvt_ -> pure cEvt_ + RRChatEvent cEvt_ -> pure $ resultToEither <$> cEvt_ r -> badResponse r remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath @@ -172,7 +196,7 @@ convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value convertJSON _remote@PEKotlin _local@PEKotlin = id convertJSON PESwift PESwift = id convertJSON PESwift PEKotlin = owsf2tagged -convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by handshake +convertJSON PEKotlin PESwift = Prelude.error "unsupported convertJSON: K/S" -- guarded by handshake -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 06a1e0f314..7c52f59a50 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -64,12 +64,14 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand rh' bs) cc - processResp s cmd rh r + case r of + Right r' -> processResp cmd rh r' + Left _ -> when (isMessage cmd) $ echo s printRespToTerminal ct cc False rh r - startLiveMessage cmd r + mapM_ (startLiveMessage cmd) r where echo s = printToTerminal ct [plain s] - processResp s cmd rh = \case + processResp cmd rh = \case CRActiveUser u -> case rh of Nothing -> setActive ct "" Just rhId -> updateRemoteUser ct u rhId @@ -80,7 +82,6 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g - CRChatCmdError _ _ -> when (isMessage cmd) $ echo s CRCmdOk _ -> case cmd of Right APIDeleteUser {} -> setActive ct "" _ -> pure () @@ -132,7 +133,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do updateLiveMessage typedMsg lm = case liveMessageToSend typedMsg lm of Just sentMsg -> sendUpdatedLiveMessage cc sentMsg lm True >>= \case - CRChatItemUpdated {} -> setLiveMessage lm {sentMsg, typedMsg} + Right CRChatItemUpdated {} -> setLiveMessage lm {sentMsg, typedMsg} _ -> do -- TODO print error setLiveMessage lm {typedMsg} @@ -146,10 +147,10 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do | otherwise = (s <> reverse (c : w), "") startLiveMessage _ _ = pure () -sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO ChatResponse +sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO (Either ChatError ChatResponse) sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg - either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) `runReaderT` cc + runExceptT (processChatCommand cmd) `runReaderT` cc runTerminalInput :: ChatTerminal -> ChatController -> IO () runTerminalInput ct cc = withChatTerm ct $ do diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index af90340cbc..38b0e91a8a 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} module Simplex.Chat.Terminal.Main where @@ -7,15 +8,13 @@ import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.STM import Control.Monad import Data.Maybe (fromMaybe) -import Data.Time.Clock (getCurrentTime) -import Data.Time.LocalTime (getCurrentTimeZone) import Network.Socket -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatEvent (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError, ChatEvent (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Options.DB import Simplex.Chat.Terminal -import Simplex.Chat.View (ChatResponseEvent, serializeChatResponse, smpProxyModeStr) +import Simplex.Chat.View (ChatResponseEvent, smpProxyModeStr) import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (..)) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) @@ -45,17 +44,15 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer when (chatCmdLog /= CCLNone) . void . forkIO . forever $ do (_, r) <- atomically . readTBQueue $ outputQ cc case r of - CEvtNewChatItems {} -> printResponse r + Right CEvtNewChatItems {} -> printResponse r _ -> when (chatCmdLog == CCLAll) $ printResponse r sendChatCmdStr cc chatCmd >>= printResponse threadDelay $ chatCmdDelay * 1000000 where - printResponse :: ChatResponseEvent r => r -> IO () + printResponse :: ChatResponseEvent r => Either ChatError r -> IO () printResponse r = do - ts <- getCurrentTime - tz <- getCurrentTimeZone rh <- readTVarIO $ currentRemoteHost cc - putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r + printResponseEvent (rh, Just user) cfg r welcome :: ChatConfig -> ChatOpts -> IO () welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbOptions, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 5134d0efc9..79fc08397c 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -7,6 +7,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Terminal.Output where @@ -146,19 +147,19 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> ChatOpts -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} ChatOpts {markRead} = do forever $ do - (outputRH, r) <- atomically $ readTBQueue outputQ - case r of + (outputRH, r_) <- atomically $ readTBQueue outputQ + forM_ r_ $ \case CEvtNewChatItems u (ci : _) -> when markRead $ markChatItemRead u ci -- At the moment of writing received items are created one at a time CEvtChatItemUpdated u ci -> when markRead $ markChatItemRead u ci CEvtRemoteHostConnected {remoteHost = RemoteHostInfo {remoteHostId}} -> getRemoteUser remoteHostId CEvtRemoteHostStopped {remoteHostId_} -> mapM_ removeRemoteUser remoteHostId_ _ -> pure () let printEvent = case logFilePath of - Just path -> if logEventToFile r then logResponse path else printToTerminal ct + Just path -> if either (const True) logEventToFile r_ then logResponse path else printToTerminal ct _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems - responseString ct cc liveItems outputRH r >>= printEvent - chatEventNotification ct cc r + responseString ct cc liveItems outputRH r_ >>= printEvent + mapM_ (chatEventNotification ct cc) r_ where markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = case (chatDirNtf u chat chatDir (isUserMention ci), itemStatus) of @@ -170,7 +171,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s getRemoteUser rhId = runReaderT (execChatCommand (Just rhId) "/user") cc >>= \case - CRActiveUser {user} -> updateRemoteUser ct user rhId + Right CRActiveUser {user} -> updateRemoteUser ct user rhId cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) @@ -271,15 +272,17 @@ whenCurrUser cc u a = do where sameUser User {userId = uId} = maybe False $ \User {userId} -> userId == uId -printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO () +printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> Either ChatError ChatResponse -> IO () printRespToTerminal ct cc liveItems outputRH r = responseString ct cc liveItems outputRH r >>= printToTerminal ct -responseString :: ChatResponseEvent r => ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> r -> IO [StyledString] -responseString ct cc liveItems outputRH r = do - cu <- getCurrentUser ct cc - ts <- getCurrentTime - tz <- getCurrentTimeZone - pure $ responseToView cu (config cc) liveItems ts tz outputRH r +responseString :: forall r. ChatResponseEvent r => ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> Either ChatError r -> IO [StyledString] +responseString ct cc liveItems outputRH = \case + Right r -> do + cu <- getCurrentUser ct cc + ts <- getCurrentTime + tz <- getCurrentTimeZone + pure $ responseToView cu (config cc) liveItems ts tz outputRH r + Left e -> pure $ chatErrorToView (isCommandResponse @r) (config cc) e updateRemoteUser :: ChatTerminal -> User -> RemoteHostId -> IO () updateRemoteUser ct user rhId = atomically $ TM.insert rhId user (currentRemoteUsers ct) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b87ba3a081..c07fcc952d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -38,7 +39,6 @@ import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.Version as V import qualified Network.HTTP.Types as Q import Numeric (showFFloat) -import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Help @@ -87,15 +87,26 @@ data WCallCommand $(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "WCCall") ''WCallCommand) -serializeChatResponse :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> r -> String -serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ +serializeChatError :: Bool -> ChatConfig -> ChatError -> String +serializeChatError isCmd cfg = unlines . map unStyle . chatErrorToView isCmd cfg + +serializeChatResponse :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> ChatConfig -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> r -> String +serializeChatResponse hu cfg ts tz remoteHost_ = unlines . map unStyle . responseToView hu cfg False ts tz remoteHost_ class ChatResponseEvent r where responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> r -> [StyledString] + isCommandResponse :: Bool -instance ChatResponseEvent ChatResponse where responseToView = chatResponseToView +instance ChatResponseEvent ChatResponse where + responseToView = chatResponseToView + isCommandResponse = True -instance ChatResponseEvent ChatEvent where responseToView = chatEventToView +instance ChatResponseEvent ChatEvent where + responseToView = chatEventToView + isCommandResponse = False + +chatErrorToView :: Bool -> ChatConfig -> ChatError -> [StyledString] +chatErrorToView isCmd ChatConfig {logLevel, testView} = viewChatError isCmd logLevel testView chatResponseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveItems ts tz outputRH = \case @@ -286,7 +297,6 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte [ "agent queues info:", plain . LB.unpack $ J.encode agentQueuesInfo ] - CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e CRAppSettings as -> ["app settings: " <> viewJSON as] CRCustomChatResponse u r -> ttyUser' u $ map plain $ T.lines r where @@ -296,8 +306,6 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte | otherwise = [] ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser - ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] - ttyUserPrefix' = maybe id $ ttyUserPrefix hu outputRH testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where @@ -499,8 +507,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtAgentConnsDeleted acIds -> ["completed deleting connections: " <> sShow (length acIds) | logLevel <= CLLInfo] CEvtAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] CEvtMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] - CEvtChatError u e -> ttyUser' u $ viewChatError False logLevel testView e - CEvtChatErrors u errs -> ttyUser' u $ concatMap (viewChatError False logLevel testView) errs + CEvtChatErrors errs -> concatMap (viewChatError False logLevel testView) errs CEvtTimedAction _ _ -> [] CEvtTerminalEvent te -> case te of TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index 32e90cf754..bd74f44022 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -5,55 +5,55 @@ module JSONFixtures where import qualified Data.ByteString.Lazy.Char8 as LB noActiveUserSwift :: LB.ByteString -noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" +noActiveUserSwift = "{\"error\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}" noActiveUserTagged :: LB.ByteString -noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" +noActiveUserTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}" activeUserExistsSwift :: LB.ByteString -activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" +activeUserExistsSwift = "{\"error\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}" activeUserExistsTagged :: LB.ByteString -activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" +activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" chatStartedSwift :: LB.ByteString -chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" +chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" chatStartedTagged :: LB.ByteString -chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" +chatStartedTagged = "{\"result\":{\"type\":\"chatStarted\"}}" networkStatusesSwift :: LB.ByteString -networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" +networkStatusesSwift = "{\"result\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" networkStatusesTagged :: LB.ByteString -networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" +networkStatusesTagged = "{\"result\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" userJSON :: LB.ByteString userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" +memberSubSummarySwift = "{\"result\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" +memberSubSummaryTagged = "{\"result\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" +userContactSubSummarySwift = "{\"result\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" +userContactSubSummaryTagged = "{\"result\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" +pendingSubSummarySwift = "{\"result\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" +pendingSubSummaryTagged = "{\"result\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 41d7280121..11a89bc62e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -29,7 +29,7 @@ import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures import Simplex.Chat.Controller (ChatController (..)) -import Simplex.Chat.Mobile +import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC 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 211/289] 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 05de019ecd97d7fef3a97670f00af83702ffc847 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 7 May 2025 07:33:00 +0100 Subject: [PATCH 212/289] ios: deliver notifications instantly when server has no more messages and better concurrency (#5872) * core: return error and message absence when getting notifications * ios: do not wait for notification messages when server says "no" * do not postpone some notification events, comments * refactor * simplexmq (mapM) * simplexmq (release lock) * ios: inline, more aggressive GHC RTC settings for garbage collection * simplexmq * corrections Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * refactor ntf delivery * ios: 6.3.4 (build 274) * simplexmq (fix updating last ts) * improve notification for multiple messages * simplexmq --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/ios/Shared/Model/AppAPITypes.swift | 2 +- apps/ios/SimpleX NSE/NSEAPITypes.swift | 2 +- .../ios/SimpleX NSE/NotificationService.swift | 641 +++++++++++------- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +- apps/ios/SimpleXChat/AppGroup.swift | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 28 + apps/ios/SimpleXChat/hs_init.c | 6 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 17 +- src/Simplex/Chat/Library/Commands.hs | 2 +- 11 files changed, 483 insertions(+), 263 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index d5a067a2b8..fd89026cfe 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -856,7 +856,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String) case ntfConns(ntfConns: [NtfConn]) - case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) + case connNtfMessages(receivedMsgs: [RcvNtfMsgInfo]) // remote desktop responses case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index 6ab4a779d1..35a838fff9 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -53,7 +53,7 @@ enum NSEChatResponse: Decodable, ChatAPIResult { case chatRunning case rcvFileAccepted(user: UserRef, chatItem: AChatItem) case ntfConns(ntfConns: [NtfConn]) - case connNtfMessages(receivedMsgs: [NtfMsgInfo?]) + case connNtfMessages(receivedMsgs: [RcvNtfMsgInfo]) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo) case cmdOk(user_: UserRef?) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index bc783784cb..176da2481e 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -22,12 +22,6 @@ let nseSuspendSchedule: SuspendSchedule = (2, 4) let fastNSESuspendSchedule: SuspendSchedule = (1, 1) -enum NSENotification { - case nse(UNMutableNotificationContent) - case callkit(RcvCallInvitation) - case empty -} - public enum NSENotificationData { case connectionEvent(_ user: User, _ connEntity: ConnectionEntity) case contactConnected(_ user: any UserLike, _ contact: Contact) @@ -37,6 +31,7 @@ public enum NSENotificationData { case msgInfo(NtfMsgAckInfo) case noNtf + @inline(__always) var callInvitation: RcvCallInvitation? { switch self { case let .callInvitation(invitation): invitation @@ -56,8 +51,9 @@ public enum NSENotificationData { } } + @inline(__always) var notificationEvent: NSENotificationData? { - return switch self { + switch self { case .connectionEvent: self case .contactConnected: self case .contactRequest: self @@ -68,9 +64,10 @@ public enum NSENotificationData { } } - var newMsgData: (any UserLike, ChatInfo)? { - return switch self { - case let .messageReceived(user, cInfo, _): (user, cInfo) + @inline(__always) + var newMsgNtf: NSENotificationData? { + switch self { + case .messageReceived: self default: nil } } @@ -81,20 +78,25 @@ public enum NSENotificationData { // or when background notification is received. class NSEThreads { static let shared = NSEThreads() - static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") + private let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") private var allThreads: Set = [] - var activeThreads: [(UUID, NotificationService)] = [] - var droppedNotifications: [(ChatId, NSENotificationData)] = [] + private var activeThreads: [(threadId: UUID, nse: NotificationService)] = [] + private var droppedNotifications: [(entityId: ChatId, ntf: NSENotificationData)] = [] + @inline(__always) + private init() {} // only shared instance can be used + + @inline(__always) func newThread() -> UUID { - NSEThreads.queue.sync { + queue.sync { let (_, t) = allThreads.insert(UUID()) return t } } + @inline(__always) func startThread(_ t: UUID, _ service: NotificationService) { - NSEThreads.queue.sync { + queue.sync { if allThreads.contains(t) { activeThreads.append((t, service)) } else { @@ -103,24 +105,111 @@ class NSEThreads { } } + // atomically: + // - checks that passed NSE instance can start processing passed notification entity, + // - adds it to the passed NSE instance, + // - marks as started, if no other NSE instance is processing it. + // Making all these steps atomic prevents a race condition between threads when both will be added and none will be started + @inline(__always) + func startEntity(_ nse: NotificationService, _ ntfEntity: NotificationEntity) -> Bool { + queue.sync { + // checking that none of activeThreads with another NSE instance processes the same entity and is not ready + let canStart = !activeThreads.contains(where: { (tId, otherNSE) in + tId != nse.threadId + && otherNSE.notificationEntities.contains(where: { (id, otherEntity) in + id == ntfEntity.entityId + && otherEntity.expectedMsg != nil + }) + }) + // atomically add entity to passed NSE instance + let id = ntfEntity.entityId + nse.notificationEntities[id] = ntfEntity + if canStart { + // and set as started, so it cannot be chosen to start by another NSE entity in nextThread + nse.notificationEntities[id]?.startedProcessingNewMsgs = true + } + return canStart + } + } + + @inline(__always) + func addDroppedNtf(_ id: ChatId, _ ntf: NSENotificationData) { + queue.sync { droppedNotifications.append((id, ntf)) } + } + + // atomically remove and return first dropped notification for the passed entity + @inline(__always) + func takeDroppedNtf(_ ntfEntity: NotificationEntity) -> (entityId: ChatId, ntf: NSENotificationData)? { + queue.sync { + if droppedNotifications.isEmpty { + nil + } else if let i = droppedNotifications.firstIndex(where: { (id, _) in id == ntfEntity.entityId }) { + droppedNotifications.remove(at: i) + } else { + nil + } + } + } + + // passes notification for processing to NSE instance chosen by rcvEntityThread + @inline(__always) func processNotification(_ id: ChatId, _ ntf: NSENotificationData) async -> Void { - if let (_, nse) = rcvEntityThread(id), - nse.expectedMessages[id]?.shouldProcessNtf ?? false { - nse.processReceivedNtf(id, ntf, signalReady: true) + if let (nse, ntfEntity, expectedMsg) = rcvEntityThread(id, ntf) { + logger.debug("NotificationService processNotification \(id): found nse thread expecting message") + if nse.processReceivedNtf(ntfEntity, expectedMsg, ntf) { + nse.finalizeEntity(id) + } } } - private func rcvEntityThread(_ id: ChatId) -> (UUID, NotificationService)? { - NSEThreads.queue.sync { + // atomically: + // - chooses active NSE instance that is ready to process notifications and expects message for passed entity ID + // - returns all dependencies for processing (notification entity and expected message) + // - adds notification to droppedNotifications if no ready NSE instance is found for the entity + @inline(__always) + private func rcvEntityThread(_ id: ChatId, _ ntf: NSENotificationData) -> (NotificationService, NotificationEntity, NtfMsgInfo)? { + queue.sync { // this selects the earliest thread that: - // 1) has this connection in nse.expectedMessages - // 2) has not completed processing messages for this connection (not ready) - activeThreads.first(where: { (_, nse) in nse.expectedMessages[id]?.ready == false }) + // 1) has this connection entity in nse.notificationEntitites + // 2) has not completed processing messages for this connection entity (not ready) + let r = activeThreads.lazy.compactMap({ (_, nse) in + let ntfEntity = nse.notificationEntities[id] + return if let ntfEntity, let expectedMsg = ntfEntity.expectedMsg, ntfEntity.shouldProcessNtf { + (nse, ntfEntity, expectedMsg) + } else { + nil + } + }).first + if r == nil { droppedNotifications.append((id, ntf)) } + return r } } + // Atomically mark entity in the passed NSE instance as not expecting messages, + // and signal the next NSE instance with this entity to start its processing. + @inline(__always) + func signalNextThread(_ nse: NotificationService, _ id: ChatId) { + queue.sync { + nse.notificationEntities[id]?.expectedMsg = nil + nse.notificationEntities[id]?.shouldProcessNtf = false + let next = activeThreads.first(where: { (_, nseNext) in + if let ntfEntity = nseNext.notificationEntities[id] { + ntfEntity.expectedMsg != nil && !ntfEntity.startedProcessingNewMsgs + } else { + false + } + }) + if let (tNext, nseNext) = next { + if let t = nse.threadId { logger.debug("NotificationService thread \(t): signalNextThread: signal next thread \(tNext) for entity \(id)") } + nseNext.notificationEntities[id]?.startedProcessingNewMsgs = true + nseNext.notificationEntities[id]?.semaphore.signal() + } + } + } + + @inline(__always) func endThread(_ t: UUID) -> Bool { - NSEThreads.queue.sync { + queue.sync { let tActive: UUID? = if let index = activeThreads.firstIndex(where: { $0.0 == t }) { activeThreads.remove(at: index).0 } else { @@ -137,24 +226,49 @@ class NSEThreads { } } + @inline(__always) var noThreads: Bool { allThreads.isEmpty } } -struct ExpectedMessage { +// NotificationEntity is a processing state for notifications from a single connection entity (message queue). +// Each NSE instance within NSE process can have more than one NotificationEntity. +// NotificationEntities of an NSE instance are processed concurrently, as messages arrive in any order. +// NotificationEntities for the same connection across multiple NSE instances (NSEThreads) are processed sequentially, so that the earliest NSE instance receives the earliest messages. +// The reason for this complexity is to process all required messages within allotted 30 seconds, +// accounting for the possibility that multiple notifications may be delivered concurrently. +struct NotificationEntity { var ntfConn: NtfConn - var expectedMsgId: String? - var allowedGetNextAttempts: Int - var msgBestAttemptNtf: NSENotificationData? - var ready: Bool - var shouldProcessNtf: Bool - var startedProcessingNewMsgs: Bool - var semaphore: DispatchSemaphore - + var entityId: ChatId + + // expectedMsg == nil means that entity already has the best attempt to deliver, and no more messages are expected. + // It happens when: + // - the user is muted (set to nil in mkNotificationEntity) + // - apiGetNtfConns returns that there are no new messages (msgId in notification matches previously received), + // - messaging server fails to respond or replies that there are no messages (apiGetConnNtfMessages / getConnNtfMessage), + // - the message is received with the correct ID or timestamp (set to nil in signalNextThread). + var expectedMsg: NtfMsgInfo? + var allowedGetNextAttempts: Int = 3 + var msgBestAttemptNtf: NSENotificationData + + // startedProcessingNewMsgs determines that the entity stared processing events once it processed dropped notifications. + // It remains true when shouldProcessNtf is set to false, to prevent NSE from being chosen as the next for the entity. + // It is atomically set to true by startThead or by nextThread + var startedProcessingNewMsgs: Bool = false + + // shouldProcessNtf determines that NSE should process events for this entity, + // it is atomically set: + // - to true in processDroppedNotifications in case dropped notification is not chosen for delivery, and more messages are needed. + // - to false in nextThread + var shouldProcessNtf: Bool = false + + // this semaphone is used to wait for another NSE instance processing events for the same entity + var semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + var connMsgReq: ConnMsgReq? { - if let expectedMsg_ = ntfConn.expectedMsg_ { - ConnMsgReq(msgConnId: ntfConn.agentConnId, msgDbQueueId: ntfConn.agentDbQueueId, msgTs: expectedMsg_.msgTs) + if let expectedMsg { + ConnMsgReq(msgConnId: ntfConn.agentConnId, msgDbQueueId: ntfConn.agentDbQueueId, msgTs: expectedMsg.msgTs) } else { nil } @@ -168,12 +282,12 @@ struct ExpectedMessage { class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? // served as notification if no message attempts (msgBestAttemptNtf) could be produced - var serviceBestAttemptNtf: NSENotification? + var serviceBestAttemptNtf: UNMutableNotificationContent? var badgeCount: Int = 0 // thread is added to allThreads here - if thread did not start chat, // chat does not need to be suspended but NSE state still needs to be set to "suspended". var threadId: UUID? = NSEThreads.shared.newThread() - var expectedMessages: Dictionary = [:] // key is receiveEntityId + var notificationEntities: Dictionary = [:] // key is entityId var appSubscriber: AppSubscriber? var returnedSuspension = false @@ -199,12 +313,15 @@ class NotificationService: UNNotificationServiceExtension { setExpirationTimer() receiveNtfMessages(request) case .suspending: + // while application is suspending, the current instance will be waiting setExpirationTimer() Task { let state: AppState = await withCheckedContinuation { cont in + // this subscriber uses message delivery via NSFileCoordinator to communicate between the app and NSE appSubscriber = appStateSubscriber { s in if s == .suspended { appSuspension(s) } } + // this is a fallback timeout, in case message from the app does not arrive DispatchQueue.global().asyncAfter(deadline: .now() + Double(appSuspendTimeout) + 1) { logger.debug("NotificationService: appSuspension timeout") appSuspension(appStateGroupDefault.get()) @@ -232,12 +349,18 @@ class NotificationService: UNNotificationServiceExtension { } } + // This timer compensates for the scenarios when serviceExtensionTimeWillExpire does not fire at all. + // It is not clear why in some cases it does not fire, possibly it is a bug, + // or it depends on what the current thread is doing at the moment. + // If notification is not delivered and not cancelled, no further notifications will be processed. + @inline(__always) private func setExpirationTimer() -> Void { DispatchQueue.main.asyncAfter(deadline: .now() + 30) { self.deliverBestAttemptNtf(urgent: true) } } + @inline(__always) private func ntfRequestData(_ request: UNNotificationRequest) -> (nonce: String, encNtfInfo: String)? { if let ntfData = request.content.userInfo["notificationData"] as? [AnyHashable : Any], let nonce = ntfData["nonce"] as? String, @@ -247,7 +370,30 @@ class NotificationService: UNNotificationServiceExtension { nil } } - + + // This function triggers notification message delivery for connection entities referenced in the notification. + // Notification may reference multiple connection entities (message queues) in order to compensate for Apple servers + // only delivering the latest notification, so it allows receiving messages from up to 6 contacts and groups from a + // single notification. This aggregation is handled by a notification server and is delivered via APNS servers in + // e2e encrypted envelope, and the app core prevents duplicate processing by keeping track of the last processed message. + + // The process steps: + // 0. apiGetConnNtfMessages or getConnNtfMessage get messages from the server for passed connection entities. + // We don't know in advance which chat events will be delivered from app core for a given notification, + // it may be a message, but it can also be contact request, various protocol confirmations, calls, etc., + // this function only returns metadata for the expected chat events. + // This metadata is correlated with .ntfMessage core event / .msgInfo notification marker - + // this marker allows determining when some message completed processing. + // 1. receiveMessages: singleton loop receiving events from core. + // 2. receivedMsgNtf: maps core events to notification events. + // 3. NSEThreads.shared.processNotification: chooses which notification service instance in the current process should process notification. + // While most of the time we observe that notifications are delivered sequentially, nothing in the documentation confirms it is sequential, + // and from various sources it follows that each instance executes in its own thread, so concurrency is expected. + // 4. processReceivedNtf: one of the instances of NSE processes notification event, deciding whether to request further messages + // for a given connection entity (via getConnNtfMessage) or that the correct message was received and notification can be delivered (deliverBestAttemptNtf). + // It is based on .msgInfo markers that indicate that message with a given timestamp was processed. + // 5. deliverBestAttemptNtf: is called multiple times, once each connection receives enough messages (based on .msgInfo marker). + // If further messages are expected, this function does nothing (unless it is called with urgent flag from timeout/expiration handlers). func receiveNtfMessages(_ request: UNNotificationRequest) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { @@ -255,95 +401,115 @@ class NotificationService: UNNotificationServiceExtension { return } if let nrData = ntfRequestData(request), - // check it here again + // Check that the app is still inactive before starting the core. appStateGroupDefault.get().inactive { // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended - if let t = threadId { NSEThreads.shared.startThread(t, self) } + guard let t = threadId else { return } + NSEThreads.shared.startThread(t, self) let dbStatus = startChat() + // If database is opened successfully, get the list of connection entities (group members, contacts) + // that are referenced in the encrypted notification metadata. if case .ok = dbStatus, let ntfConns = apiGetNtfConns(nonce: nrData.nonce, encNtfInfo: nrData.encNtfInfo) { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns count = \(ntfConns.count)") -// logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns \(String(describing: ntfConns.map { $0.connEntity.id }))") - for ntfConn in ntfConns { - addExpectedMessage(ntfConn: ntfConn) - } + // uncomment localDisplayName in ConnectionEntity + // logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns \(String(describing: ntfConns.map { $0.connEntity.localDisplayName }))") - let connMsgReqs = expectedMessages.compactMap { (id, _) in - let started = NSEThreads.queue.sync { - let canStart = checkCanStart(id) - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: can start: \(canStart)") } - if canStart { - processDroppedNotifications(id) - expectedMessages[id]?.startedProcessingNewMsgs = true - expectedMessages[id]?.shouldProcessNtf = true - } - return canStart - } - if started { - return expectedMessages[id]?.connMsgReq - } else { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") } - expectedMessages[id]?.semaphore.wait() - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) proceeding after semaphore") } - Task { - NSEThreads.queue.sync { - processDroppedNotifications(id) - expectedMessages[id]?.startedProcessingNewMsgs = true - expectedMessages[id]?.shouldProcessNtf = true + // Prepare expected messages - they will be delivered to the reception loop in this chain: + // They are atomically added to the instance notificationEntities inside msgReqs loop, to avoid any race conditions. + let ntfEntities = ntfConns.compactMap(mkNotificationEntity) + + // collect notification message requests for all connection entities + let msgReqs: [(chatId: String, connMsgReq: ConnMsgReq)] = ntfEntities.compactMap { ntfEntity -> (chatId: String, connMsgReq: ConnMsgReq)? in + // No need to request messages for connection entities that are "ready", + // e.g. for muted users or when the message is not expected based on notification. + let id = ntfEntity.entityId + if let expectedMsg = ntfEntity.expectedMsg { + if NSEThreads.shared.startEntity(self, ntfEntity) { // atomically checks and adds ntfEntity to NSE + // process any notifications "postponed" by the previous instance + let completed = processDroppedNotifications(ntfEntity, expectedMsg) + return if !completed, let connMsgReq = notificationEntities[id]?.connMsgReq { + (id, connMsgReq) + } else { + nil } - if let connMsgReq = expectedMessages[id]?.connMsgReq { - let _ = getConnNtfMessage(connMsgReq: connMsgReq) + } else { + // wait for another instance processing the same connection entity + logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") + // this semaphore will be released by signalNextThread function, that looks up the instance + // waiting for the connection entity via activeThreads in NSEThreads + notificationEntities[id]?.semaphore.wait() + logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) proceeding after semaphore") + Task { + // process any notifications "postponed" by the previous instance + let completed = processDroppedNotifications(ntfEntity, expectedMsg) + // Request messages from the server for this connection entity. + // It triggers event delivery to receiveMessages loop (see above). + if !completed, let connMsgReq = notificationEntities[id]?.connMsgReq, + let rcvMsg = getConnNtfMessage(connMsgReq: connMsgReq), + rcvMsg.noMsg { + // if server returns error or "no message", deliver what we have for this connection entity. + finalizeEntity(id) // also releases any waiting threads for this entity + } } + return nil } + } else { // no expected message + notificationEntities[id] = ntfEntity return nil } } - if !connMsgReqs.isEmpty { - if let r = apiGetConnNtfMessages(connMsgReqs: connMsgReqs) { - logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count), expecting messages \(r.count { $0 != nil })") + // Request messages for all connection entities that were not used by other instances. + // It triggers event delivery to receiveMessages loop (see above). + if !msgReqs.isEmpty, + let rcvMsgs = apiGetConnNtfMessages(connMsgReqs: msgReqs.map { $0.connMsgReq }) { + for i in 0 ..< min(msgReqs.count, rcvMsgs.count) { // a sanity check, API always returns the same size + if rcvMsgs[i].noMsg { + // mark entity as ready if there are no message on the server (or on error) + finalizeEntity(msgReqs[i].chatId) + } } - return } } else if let dbStatus = dbStatus { setServiceBestAttemptNtf(createErrorNtf(dbStatus, badgeCount)) } } + // try to deliver the best attempt before exiting deliverBestAttemptNtf() } - func addExpectedMessage(ntfConn: NtfConn) { - let expectedMsgId = ntfConn.expectedMsg_?.msgId - if let receiveEntityId = ntfConn.connEntity.id { - logger.debug("NotificationService: addExpectedMessage: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private)") - expectedMessages[receiveEntityId] = ExpectedMessage( + @inline(__always) + func mkNotificationEntity(ntfConn: NtfConn) -> NotificationEntity? { + if let rcvEntityId = ntfConn.connEntity.id { + // don't receive messages for muted user profile + let expectedMsg: NtfMsgInfo? = if ntfConn.user.showNotifications { ntfConn.expectedMsg_ } else { nil } + return NotificationEntity( ntfConn: ntfConn, - expectedMsgId: expectedMsgId, - allowedGetNextAttempts: 3, - msgBestAttemptNtf: defaultBestAttemptNtf(ntfConn), - ready: ntfConn.expectedMsg_ == nil, // show defaultBestAttemptNtf(ntfConn) if there is no expected message - shouldProcessNtf: false, - startedProcessingNewMsgs: false, - semaphore: DispatchSemaphore(value: 0) + entityId: rcvEntityId, + expectedMsg: expectedMsg, + msgBestAttemptNtf: defaultBestAttemptNtf(ntfConn) ) } + return nil } - func checkCanStart(_ entityId: String) -> Bool { - return !NSEThreads.shared.activeThreads.contains(where: { - (tId, nse) in tId != threadId && nse.expectedMessages.contains(where: { $0.key == entityId }) - }) - } - - func processDroppedNotifications(_ entityId: String) { - if !NSEThreads.shared.droppedNotifications.isEmpty { - let messagesToProcess = NSEThreads.shared.droppedNotifications.filter { (eId, _) in eId == entityId } - NSEThreads.shared.droppedNotifications.removeAll(where: { (eId, _) in eId == entityId }) - for (index, (_, ntf)) in messagesToProcess.enumerated() { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entity \(entityId, privacy: .private): processing dropped notification \(index, privacy: .private)") } - processReceivedNtf(entityId, ntf, signalReady: false) + // Processes notifications received and postponed by the previous NSE instance + func processDroppedNotifications(_ ntfEntity: NotificationEntity, _ expectedMsg: NtfMsgInfo) -> Bool { + var completed = false + while !completed { + if let dropped = NSEThreads.shared.takeDroppedNtf(ntfEntity) { + completed = processReceivedNtf(ntfEntity, expectedMsg, dropped.ntf) + } else { + break } } + if completed { + finalizeEntity(ntfEntity.entityId) + } else { + notificationEntities[ntfEntity.entityId]?.shouldProcessNtf = true + } + return completed } override func serviceExtensionTimeWillExpire() { @@ -351,69 +517,70 @@ class NotificationService: UNNotificationServiceExtension { deliverBestAttemptNtf(urgent: true) } + @inline(__always) var expectingMoreMessages: Bool { - !expectedMessages.allSatisfy { $0.value.ready } + notificationEntities.contains { $0.value.expectedMsg != nil } } - func processReceivedNtf(_ id: ChatId, _ ntf: NSENotificationData, signalReady: Bool) { - guard let expectedMessage = expectedMessages[id] else { - return - } - guard let expectedMsgTs = expectedMessage.ntfConn.expectedMsg_?.msgTs else { - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - return - } + // processReceivedNtf returns "completed" - true when no more messages for the passed entity should be processed by the current NSE instance. + // This is used to call finalizeEntity(id) and by processDroppedNotifications to decide if further processing is needed. + func processReceivedNtf(_ ntfEntity: NotificationEntity, _ expectedMsg: NtfMsgInfo, _ ntf: NSENotificationData) -> Bool { + let id = ntfEntity.entityId if case let .msgInfo(info) = ntf { - if info.msgId == expectedMessage.expectedMsgId { + if info.msgId == expectedMsg.msgId { + // The message for this instance is processed, no more expected, deliver. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): expected") - expectedMessages[id]?.expectedMsgId = nil - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() - } else if let msgTs = info.msgTs_, msgTs > expectedMsgTs { + return true + } else if let msgTs = info.msgTs_, msgTs > expectedMsg.msgTs { + // Otherwise check timestamp - if it is after the currently expected timestamp, preserve .msgInfo marker for the next instance. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, let other instance to process it, stopping this one") - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() - } else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let connMsgReq = expectedMessages[id]?.connMsgReq { + NSEThreads.shared.addDroppedNtf(id, ntf) + return true + } else if ntfEntity.allowedGetNextAttempts > 0, let connMsgReq = ntfEntity.connMsgReq { + // Otherwise this instance expects more messages, and still has allowed attempts - + // request more messages with getConnNtfMessage. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, get next message") - expectedMessages[id]?.allowedGetNextAttempts -= 1 - if let receivedMsg = getConnNtfMessage(connMsgReq: connMsgReq) { - logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)") + notificationEntities[id]?.allowedGetNextAttempts -= 1 + let receivedMsg = getConnNtfMessage(connMsgReq: connMsgReq) + if case let .info(msg) = receivedMsg, let msg { + // Server delivered message, it will be processed in the loop - see the comments in receiveNtfMessages. + logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(msg.msgId, privacy: .private)") + return false } else { + // Server reported no messages or error, deliver what we have. logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt") - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() + return true } } else { + // Current instance needs more messages, but ran out of attempts - deliver what we have. logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unknown message, let other instance to process it") - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } - self.deliverBestAttemptNtf() + return true } - } else if expectedMessage.ntfConn.user.showNotifications { + } else if ntfEntity.ntfConn.user.showNotifications { + // This is the notification event for the user with enabled notifications. logger.debug("NotificationService processNtf: setting best attempt") if ntf.notificationEvent != nil { setBadgeCount() } - let prevBestAttempt = expectedMessages[id]?.msgBestAttemptNtf - if prevBestAttempt?.callInvitation == nil || ntf.callInvitation != nil { - expectedMessages[id]?.msgBestAttemptNtf = ntf + // If previous "best attempt" is not a call, or if the current notification is a call, replace best attempt. + // NOTE: we are delaying it until notification marker to make sure we are not delivering stale calls that can't be connected. + // A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above. + // If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once, + // instead of requesting further messages. + if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil { + notificationEntities[id]?.msgBestAttemptNtf = ntf } // otherwise keep call as best attempt + return false } else { - NSEThreads.shared.droppedNotifications.append((id, ntf)) - if signalReady { entityReady(id) } + // We should not get to this branch, as notifications are not delivered for muted users. + return true } } - func entityReady(_ entityId: ChatId) { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: entity \(entityId, privacy: .private)") } - expectedMessages[entityId]?.ready = true - if let (tNext, nse) = NSEThreads.shared.activeThreads.first(where: { (_, nse) in nse.expectedMessages[entityId]?.startedProcessingNewMsgs == false }) { - if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: signal next thread \(tNext, privacy: .private) for entity \(entityId, privacy: .private)") } - nse.expectedMessages[entityId]?.semaphore.signal() - } + func finalizeEntity(_ entityId: ChatId) { + if let t = threadId { logger.debug("NotificationService thread \(t): entityReady: entity \(entityId)") } + NSEThreads.shared.signalNextThread(self, entityId) + deliverBestAttemptNtf() } func setBadgeCount() { @@ -421,9 +588,10 @@ class NotificationService: UNNotificationServiceExtension { ntfBadgeCountGroupDefault.set(badgeCount) } + @inline(__always) func setServiceBestAttemptNtf(_ ntf: UNMutableNotificationContent) { logger.debug("NotificationService.setServiceBestAttemptNtf") - serviceBestAttemptNtf = .nse(ntf) + serviceBestAttemptNtf = ntf } private func deliverBestAttemptNtf(urgent: Bool = false) { @@ -434,8 +602,8 @@ class NotificationService: UNNotificationServiceExtension { } logger.debug("NotificationService.deliverBestAttemptNtf") // stop processing other messages - for (key, _) in expectedMessages { - expectedMessages[key]?.shouldProcessNtf = false + for (key, _) in notificationEntities { + notificationEntities[key]?.shouldProcessNtf = false } let suspend: Bool @@ -449,22 +617,24 @@ class NotificationService: UNNotificationServiceExtension { } } + @inline(__always) private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false, handler: @escaping (UNNotificationContent) -> Void) { - if useCallKit() && expectedMessages.contains(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }) { + let callInv = notificationEntities.lazy.compactMap({ $0.value.msgBestAttemptNtf.callInvitation }).first + if callInv != nil && useCallKit() { logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit") + // suspending NSE even though there may be other notifications + // to allow the app to process callkit call if urgent { - // suspending NSE even though there may be other notifications - // to allow the app to process callkit call suspendChat(0) - deliverNotification(handler: handler) + deliverNotification(handler, callInv) } else { - // suspending NSE with delay and delivering after the suspension + // when not "urgent", suspending NSE with delay and delivering after the suspension // because pushkit notification must be processed without delay - // to avoid app termination + // to avoid app termination. DispatchQueue.global().asyncAfter(deadline: .now() + fastNSESuspendSchedule.delay) { suspendChat(fastNSESuspendSchedule.timeout) DispatchQueue.global().asyncAfter(deadline: .now() + Double(fastNSESuspendSchedule.timeout)) { - self.deliverNotification(handler: handler) + self.deliverNotification(handler, callInv) } } } @@ -483,68 +653,71 @@ class NotificationService: UNNotificationServiceExtension { } } } - deliverNotification(handler: handler) + deliverNotification(handler, callInv) } } - private func deliverNotification(handler: @escaping (UNNotificationContent) -> Void) { - if serviceBestAttemptNtf != nil, let ntf = prepareNotification() { - contentHandler = nil + private func deliverNotification(_ handler: @escaping (UNNotificationContent) -> Void, _ callInv: RcvCallInvitation?) { + if let serviceNtf = serviceBestAttemptNtf { serviceBestAttemptNtf = nil - switch ntf { - case let .nse(content): - content.badge = badgeCount as NSNumber - handler(content) - case let .callkit(invitation): - logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(invitation.contact.id)") - CXProvider.reportNewIncomingVoIPPushPayload([ - "displayName": invitation.contact.displayName, - "contactId": invitation.contact.id, - "callUUID": invitation.callUUID ?? "", - "media": invitation.callType.media.rawValue, - "callTs": invitation.callTs.timeIntervalSince1970 - ]) { error in - logger.debug("reportNewIncomingVoIPPushPayload result: \(error)") - handler(error == nil ? UNMutableNotificationContent() : createCallInvitationNtf(invitation, self.badgeCount)) + contentHandler = nil + if let callInv { + if useCallKit() { + logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(callInv.contact.id)") + CXProvider.reportNewIncomingVoIPPushPayload([ + "displayName": callInv.contact.displayName, + "contactId": callInv.contact.id, + "callUUID": callInv.callUUID ?? "", + "media": callInv.callType.media.rawValue, + "callTs": callInv.callTs.timeIntervalSince1970 + ]) { error in + logger.debug("reportNewIncomingVoIPPushPayload result: \(error)") + handler(error == nil ? UNMutableNotificationContent() : createCallInvitationNtf(callInv, self.badgeCount)) + } + } else { + handler(createCallInvitationNtf(callInv, badgeCount)) } - case .empty: - handler(UNMutableNotificationContent()) // used to mute notifications that did not unsubscribe yet - } - } - } - - private func prepareNotification() -> NSENotification? { - if expectedMessages.isEmpty { - return serviceBestAttemptNtf - } else if let callNtfKV = expectedMessages.first(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }), - let callInv = callNtfKV.value.msgBestAttemptNtf?.callInvitation, - let callNtf = callNtfKV.value.msgBestAttemptNtf { - return useCallKit() ? .callkit(callInv) : .nse(callNtf.notificationContent(badgeCount)) - } else { - logger.debug("NotificationService prepareNotification \(String(describing: self.expectedMessages.map { $0.key }))") - let ntfEvents = expectedMessages.compactMap { $0.value.msgBestAttemptNtf?.notificationEvent } - logger.debug("NotificationService prepareNotification \(ntfEvents.count)") - if ntfEvents.isEmpty { - return .empty - } else if let ntfEvent = ntfEvents.count == 1 ? ntfEvents.first : nil { - return .nse(ntfEvent.notificationContent(badgeCount)) + } else if notificationEntities.isEmpty { + handler(serviceNtf) } else { - return .nse(createJointNtf(ntfEvents)) + handler(prepareNotification()) } } } - private func createJointNtf(_ ntfEvents: [NSENotificationData]) -> UNMutableNotificationContent { + @inline(__always) + private func prepareNotification() -> UNMutableNotificationContent { + // uncomment localDisplayName in ConnectionEntity + // let conns = self.notificationEntities.compactMap { $0.value.ntfConn.connEntity.localDisplayName } + // logger.debug("NotificationService prepareNotification for \(String(describing: conns))") + let ntfs = notificationEntities.compactMap { $0.value.msgBestAttemptNtf.notificationEvent } + let newMsgNtfs = ntfs.compactMap({ $0.newMsgNtf }) + let useNtfs = if newMsgNtfs.isEmpty { ntfs } else { newMsgNtfs } + return createNtf(useNtfs) + + func createNtf(_ ntfs: [NSENotificationData]) -> UNMutableNotificationContent { + logger.debug("NotificationService prepareNotification: \(ntfs.count) events") + return switch ntfs.count { + case 0: UNMutableNotificationContent() // used to mute notifications that did not unsubscribe yet + case 1: ntfs[0].notificationContent(badgeCount) + default: createJointNtf(ntfs) + } + } + } + + // NOTE: this can be improved when there are two or more connection entity events when no messages were delivered. + // Possibly, it is better to postpone this improvement until message priority is added to prevent notifications in muted groups, + // unless it is a mention, a reply or some other high priority message marked for notification delivery. + @inline(__always) + private func createJointNtf(_ ntfs: [NSENotificationData]) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() - let newMsgsData: [(any UserLike, ChatInfo)] = ntfEvents.compactMap { $0.newMsgData } - if !newMsgsData.isEmpty, let userId = newMsgsData.first?.0.userId { - let newMsgsChats: [ChatInfo] = newMsgsData.map { $0.1 } - let uniqueChatsNames = uniqueNewMsgsChatsNames(newMsgsChats) - var body: String - if previewMode == .hidden { - body = String.localizedStringWithFormat(NSLocalizedString("New messages in %d chats", comment: "notification body"), uniqueChatsNames.count) + logger.debug("NotificationService.createJointNtf ntfs: \(ntfs.count)") + let (userId, chatsNames) = newMsgsChatsNames(ntfs) + if !chatsNames.isEmpty, let userId { + let body = if previewMode == .hidden { + String.localizedStringWithFormat(NSLocalizedString("From %d chat(s)", comment: "notification body"), chatsNames.count) } else { - body = String.localizedStringWithFormat(NSLocalizedString("From: %@", comment: "notification body"), newMsgsChatsNamesStr(uniqueChatsNames)) + String.localizedStringWithFormat(NSLocalizedString("From: %@", comment: "notification body"), newMsgsChatsNamesStr(chatsNames)) } return createNotification( categoryIdentifier: ntfCategoryManyEvents, @@ -557,24 +730,32 @@ class NotificationService: UNNotificationServiceExtension { return createNotification( categoryIdentifier: ntfCategoryManyEvents, title: NSLocalizedString("New events", comment: "notification"), - body: String.localizedStringWithFormat(NSLocalizedString("%d new events", comment: "notification body"), ntfEvents.count), + body: String.localizedStringWithFormat(NSLocalizedString("%d new events", comment: "notification body"), ntfs.count), badgeCount: badgeCount ) } } - private func uniqueNewMsgsChatsNames(_ newMsgsChats: [ChatInfo]) -> [String] { + @inline(__always) + private func newMsgsChatsNames(_ ntfs: [NSENotificationData]) -> (Int64?, [String]) { var seenChatIds = Set() - var uniqueChatsNames: [String] = [] - for chat in newMsgsChats { - if !seenChatIds.contains(chat.id) { - seenChatIds.insert(chat.id) - uniqueChatsNames.append(chat.chatViewName) + var chatsNames: [String] = [] + var userId: Int64? + for ntf in ntfs { + switch ntf { + case let .messageReceived(user, chat, _): + if seenChatIds.isEmpty { userId = user.userId } + if !seenChatIds.contains(chat.id) { + seenChatIds.insert(chat.id) + chatsNames.append(chat.chatViewName) + } + default: () } } - return uniqueChatsNames + return (userId, chatsNames) } + @inline(__always) private func newMsgsChatsNamesStr(_ names: [String]) -> String { return switch names.count { case 1: names[0] @@ -593,9 +774,8 @@ class NSEChatState { static let shared = NSEChatState() private var value_ = NSEState.created - var value: NSEState { - value_ - } + @inline(__always) + var value: NSEState { value_ } func set(_ state: NSEState) { nseStateGroupDefault.set(state) @@ -603,7 +783,7 @@ class NSEChatState { value_ = state } - init() { + private init() { // This is always set to .created state, as in case previous start of NSE crashed in .active state, it is stored correctly. // Otherwise the app will be activating slower set(.created) @@ -651,7 +831,7 @@ func startChat() -> DBMigrationResult? { startLock.wait() defer { startLock.signal() } - + if hasChatCtrl() { return switch NSEChatState.shared.value { case .created: doStartChat() @@ -803,8 +983,11 @@ func chatRecvMsg() async -> APIResult? { } private let isInChina = SKStorefront().countryCode == "CHN" + +@inline(__always) private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } +@inline(__always) func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? { logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") switch res { @@ -851,12 +1034,10 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? case .chatSuspended: chatSuspended() return nil - default: - logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)") - return nil } } +@inline(__always) func updateNetCfg() { let newNetConfig = getNetCfg() if newNetConfig != networkConfig { @@ -925,42 +1106,41 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [NtfConn]? { guard apiGetActiveUser() != nil else { - logger.debug("no active user") + logger.debug("NotificationService: no active user") return nil } let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo)) if case let .result(.ntfConns(ntfConns)) = r { - logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)") + logger.debug("NotificationService apiGetNtfConns response ntfConns: \(ntfConns.count) conections") return ntfConns } else if case let .error(error) = r { - logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") + logger.debug("NotificationService apiGetNtfMessage error response: \(String.init(describing: error))") } else { - logger.debug("apiGetNtfMessage ignored response: \(r.responseType) \(String.init(describing: r))") + logger.debug("NotificationService apiGetNtfMessage ignored response: \(r.responseType) \(String.init(describing: r))") } return nil } -func apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) -> [NtfMsgInfo?]? { +func apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) -> [RcvNtfMsgInfo]? { guard apiGetActiveUser() != nil else { logger.debug("no active user") return nil } - logger.debug("apiGetConnNtfMessages command: \(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)") +// logger.debug("NotificationService apiGetConnNtfMessages command: \(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)") + logger.debug("NotificationService apiGetConnNtfMessages requests: \(connMsgReqs.count)") let r: APIResult = sendSimpleXCmd(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs)) - if case let .result(.connNtfMessages(receivedMsgs)) = r { - logger.debug("apiGetConnNtfMessages response receivedMsgs: total \(receivedMsgs.count), expecting messages \(receivedMsgs.count { $0 != nil })") - return receivedMsgs + if case let .result(.connNtfMessages(msgs)) = r { +// logger.debug("NotificationService apiGetConnNtfMessages responses: \(String(describing: msgs))") + logger.debug("NotificationService apiGetConnNtfMessages responses: total \(msgs.count), expecting messages \(msgs.count { !$0.noMsg }), errors \(msgs.count { $0.isError })") + return msgs } - logger.debug("apiGetConnNtfMessages error: \(responseError(r.unexpected))") + logger.debug("NotificationService apiGetConnNtfMessages error: \(responseError(r.unexpected))") return nil } -func getConnNtfMessage(connMsgReq: ConnMsgReq) -> NtfMsgInfo? { - let r_ = apiGetConnNtfMessages(connMsgReqs: [connMsgReq]) - if let r = r_, let receivedMsg = r.count == 1 ? r.first : nil { - return receivedMsg - } - return nil +func getConnNtfMessage(connMsgReq: ConnMsgReq) -> RcvNtfMsgInfo? { + let r = apiGetConnNtfMessages(connMsgReqs: [connMsgReq]) + return if let r, r.count > 0 { r[0] } else { nil } } func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? { @@ -1021,4 +1201,3 @@ func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData { } } } - diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c7fd19e615..62c6732ab5 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1971,7 +1971,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 273; + CURRENT_PROJECT_VERSION = 274; 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.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2046,7 +2046,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2123,7 +2123,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2160,7 +2160,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; 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.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; 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.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; 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.3; + MARKETING_VERSION = 6.3.4; 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 = 273; + CURRENT_PROJECT_VERSION = 274; 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.3; + MARKETING_VERSION = 6.3.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 75bb537b94..29ccab7357 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -311,12 +311,14 @@ public class EnumDefault where T.RawValue == String { } public class BoolDefault: Default { + @inline(__always) public func get() -> Bool { self.defaults.bool(forKey: self.key) } } public class IntDefault: Default { + @inline(__always) public func get() -> Int { self.defaults.integer(forKey: self.key) } @@ -326,11 +328,13 @@ public class Default { var defaults: UserDefaults var key: String + @inline(__always) public init(defaults: UserDefaults = UserDefaults.standard, forKey: String) { self.defaults = defaults self.key = forKey } + @inline(__always) public func set(_ value: T) { defaults.set(value, forKey: key) defaults.synchronize() diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index bff1ebb4fd..db8f1dd26e 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2409,6 +2409,19 @@ public enum ConnectionEntity: Decodable, Hashable { nil } } + + // public var localDisplayName: String? { + // switch self { + // case let .rcvDirectMsgConnection(conn, contact): + // if let name = contact?.localDisplayName { "@\(name)" } else { conn.id } + // case let .rcvGroupMsgConnection(_, g, m): + // "#\(g.localDisplayName) @\(m.localDisplayName)" + // case let .userContactConnection(_, userContact): + // userContact.id + // default: + // nil + // } + // } public var conn: Connection { switch self { @@ -2434,6 +2447,21 @@ public struct NtfMsgInfo: Decodable, Hashable { public var msgTs: Date } +public enum RcvNtfMsgInfo: Decodable { + case info(ntfMsgInfo: NtfMsgInfo?) + case error(ntfMsgError: AgentErrorType) + + @inline(__always) + public var noMsg: Bool { + if case let .info(msg) = self { msg == nil } else { true } + } + + @inline(__always) + public var isError: Bool { + if case .error = self { true } else { false } + } +} + let iso8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] diff --git a/apps/ios/SimpleXChat/hs_init.c b/apps/ios/SimpleXChat/hs_init.c index 4731e7b829..e75173d6cf 100644 --- a/apps/ios/SimpleXChat/hs_init.c +++ b/apps/ios/SimpleXChat/hs_init.c @@ -29,10 +29,10 @@ void haskell_init_nse(void) { char *argv[] = { "simplex", "+RTS", // requires `hs_init_with_rtsopts` - "-A1m", // chunk size for new allocations - "-H1m", // initial heap size + "-A256k", // chunk size for new allocations + "-H512k", // initial heap size "-F0.5", // heap growth triggering GC - "-Fd1", // memory return + "-Fd0.3", // memory return "-c", // compacting garbage collector 0 }; diff --git a/cabal.project b/cabal.project index a9a4b45f1a..687e4788f9 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: a632eea75b677cf2b146ad06ee875307d0321f23 + tag: 7bbd99644ae2f7e49033742c22d7fb8f51500ced source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 842348157a..2fb002d790 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a632eea75b677cf2b146ad06ee875307d0321f23" = "03vk7214941f5jwmf7sp26lxzh4c1xl89wqmlky379d6gwypbzy6"; + "https://github.com/simplex-chat/simplexmq.git"."7bbd99644ae2f7e49033742c22d7fb8f51500ced" = "05aqkcsra4kjhid75a23s6y6a7ky8rlqwa5yjkfj286bd5rlafxl"; "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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d1bdeba341..02e2e121b3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -708,7 +708,7 @@ data ChatResponse | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer} | CRNtfConns {ntfConns :: [NtfConn]} - | CRConnNtfMessages {receivedMsgs :: NonEmpty (Maybe NtfMsgInfo)} + | CRConnNtfMessages {receivedMsgs :: NonEmpty RcvNtfMsgInfo} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} @@ -1139,13 +1139,20 @@ data NtfConn = NtfConn } deriving (Show) --- brokerTs is the same msgTs, it is used in ConnMsgReq / APIGetConnNtfMessages +-- msgTs is broker message timestamp, it is used in ConnMsgReq / APIGetConnNtfMessages -- to set it as last connection message in case queue is empty data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) -receivedMsgInfo :: SMPMsgMeta -> NtfMsgInfo -receivedMsgInfo SMPMsgMeta {msgId, msgTs} = ntfMsgInfo_ msgId msgTs +data RcvNtfMsgInfo + = RNMInfo {ntfMsgInfo :: Maybe NtfMsgInfo} + | RNMError {ntfMsgError :: AgentErrorType} + deriving (Show) + +receivedMsgInfo :: Either AgentErrorType (Maybe SMPMsgMeta) -> RcvNtfMsgInfo +receivedMsgInfo = \case + Right msgMeta_ -> RNMInfo $ (\SMPMsgMeta {msgId, msgTs} -> ntfMsgInfo_ msgId msgTs) <$> msgMeta_ + Left e -> RNMError e expectedMsgInfo :: NMsgMeta -> NtfMsgInfo expectedMsgInfo NMsgMeta {msgId, msgTs} = ntfMsgInfo_ msgId msgTs @@ -1642,6 +1649,8 @@ $(JQ.deriveJSON defaultJSON ''UserProfileUpdateSummary) $(JQ.deriveJSON defaultJSON ''NtfMsgInfo) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RNM") ''RcvNtfMsgInfo) + $(JQ.deriveJSON defaultJSON ''NtfConn) $(JQ.deriveJSON defaultJSON ''NtfMsgAckInfo) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8e7fba8255..f21c9526a5 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1304,7 +1304,7 @@ processChatCommand' vr = \case $>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId) APIGetConnNtfMessages connMsgs -> withUser $ \_ -> do msgs <- lift $ withAgent' (`getConnectionMessages` connMsgs) - let ntfMsgs = L.map (receivedMsgInfo <$>) msgs + let ntfMsgs = L.map receivedMsgInfo msgs pure $ CRConnNtfMessages ntfMsgs GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do srvs <- withFastStore (`getUserServers` user) From 9329bf61447ad3ce9640d82b7b08f05f1db1729f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 7 May 2025 08:14:11 +0100 Subject: [PATCH 213/289] core: 6.3.4.0 (simplexmq 6.4.0.2) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 687e4788f9..3e6ccab8a5 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: 7bbd99644ae2f7e49033742c22d7fb8f51500ced + tag: deaec3cce286e959bd594b9620c307954b510a07 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2fb002d790..8d17a2ce99 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."7bbd99644ae2f7e49033742c22d7fb8f51500ced" = "05aqkcsra4kjhid75a23s6y6a7ky8rlqwa5yjkfj286bd5rlafxl"; + "https://github.com/simplex-chat/simplexmq.git"."deaec3cce286e959bd594b9620c307954b510a07" = "0b8m4czjiwsi9169plslyk2rjw0f370vv7ha6qm2hpx14bxzz7xm"; "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 6469f24244..52e40d6c6f 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.3.1 +version: 6.3.4.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 105f872b75..021f048d6b 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, 3, 1] +minRemoteCtrlVersion = AppVersion [6, 3, 4, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 3, 1] +minRemoteHostVersion = AppVersion [6, 3, 4, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version 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 214/289] 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 215/289] 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 216/289] 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 73fe6827b23276bf02e36e3264549e228b79876f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 7 May 2025 09:54:15 +0100 Subject: [PATCH 217/289] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 62c6732ab5..e96c45b474 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.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.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.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.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.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.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.3.1-5KR5yzeCZIzIubYi5BDCKe-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.3.1-5KR5yzeCZIzIubYi5BDCKe.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.a */, ); path = Libraries; sourceTree = ""; From ecb4a36045971c5492b3b76c4dd263589be5acf6 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 7 May 2025 10:34:42 +0100 Subject: [PATCH 218/289] ui: translations (#5874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) Currently translated at 100.0% (2352 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2056 of 2056 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% (2056 of 2056 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2056 of 2056 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Czech) Currently translated at 99.9% (2351 of 2352 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Czech) Currently translated at 56.6% (1165 of 2056 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2056 of 2056 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% (2356 of 2356 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% (2356 of 2356 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% (2356 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Arabic) 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/ar/ * 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 (Italian) 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/it/ * Translated using Weblate (Arabic) 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/ar/ * Translated using Weblate (Arabic) Currently translated at 35.5% (730 of 2056 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/ * Translated using Weblate (Catalan) 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/ca/ * 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% (2056 of 2056 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) 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/vi/ * Translated using Weblate (Spanish) 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/es/ * Translated using Weblate (Czech) Currently translated at 99.9% (2354 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * 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% (2056 of 2056 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% (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% (2056 of 2056 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/ * process localizations --------- Co-authored-by: mlanp Co-authored-by: Random Co-authored-by: No name Co-authored-by: zenobit Co-authored-by: summoner001 Co-authored-by: 大王叫我来巡山 Co-authored-by: Ghost of Sparta Co-authored-by: Muhammad Co-authored-by: jonnysemon Co-authored-by: fran secs Co-authored-by: Bezruchenko Simon Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: Rafi --- .../ar.xcloc/Localized Contents/ar.xliff | 364 ++++++++++++++++-- .../bg.xcloc/Localized Contents/bg.xliff | 16 +- .../cs.xcloc/Localized Contents/cs.xliff | 20 +- .../de.xcloc/Localized Contents/de.xliff | 22 +- .../en.xcloc/Localized Contents/en.xliff | 20 +- .../es.xcloc/Localized Contents/es.xliff | 22 +- .../fi.xcloc/Localized Contents/fi.xliff | 16 +- .../fr.xcloc/Localized Contents/fr.xliff | 17 +- .../hu.xcloc/Localized Contents/hu.xliff | 30 +- .../it.xcloc/Localized Contents/it.xliff | 22 +- .../ja.xcloc/Localized Contents/ja.xliff | 16 +- .../nl.xcloc/Localized Contents/nl.xliff | 17 +- .../pl.xcloc/Localized Contents/pl.xliff | 17 +- .../ru.xcloc/Localized Contents/ru.xliff | 17 +- .../th.xcloc/Localized Contents/th.xliff | 16 +- .../tr.xcloc/Localized Contents/tr.xliff | 16 +- .../uk.xcloc/Localized Contents/uk.xliff | 17 +- .../Localized Contents/zh-Hans.xliff | 16 +- .../SimpleX NSE/de.lproj/Localizable.strings | 3 - .../SimpleX NSE/es.lproj/Localizable.strings | 3 - .../SimpleX NSE/fr.lproj/Localizable.strings | 3 - .../SimpleX NSE/hu.lproj/Localizable.strings | 3 - .../SimpleX NSE/it.lproj/Localizable.strings | 3 - .../SimpleX NSE/nl.lproj/Localizable.strings | 3 - .../SimpleX NSE/ru.lproj/Localizable.strings | 3 - .../SimpleX NSE/uk.lproj/Localizable.strings | 3 - apps/ios/cs.lproj/Localizable.strings | 12 + apps/ios/de.lproj/Localizable.strings | 15 + apps/ios/es.lproj/Localizable.strings | 15 + apps/ios/hu.lproj/Localizable.strings | 23 +- apps/ios/it.lproj/Localizable.strings | 15 + .../commonMain/resources/MR/ar/strings.xml | 4 + .../commonMain/resources/MR/ca/strings.xml | 4 + .../commonMain/resources/MR/cs/strings.xml | 16 +- .../commonMain/resources/MR/de/strings.xml | 6 +- .../commonMain/resources/MR/es/strings.xml | 4 + .../commonMain/resources/MR/hu/strings.xml | 12 +- .../commonMain/resources/MR/in/strings.xml | 10 + .../commonMain/resources/MR/it/strings.xml | 4 + .../commonMain/resources/MR/uk/strings.xml | 24 +- .../commonMain/resources/MR/vi/strings.xml | 6 +- .../resources/MR/zh-rCN/strings.xml | 4 + 42 files changed, 708 insertions(+), 171 deletions(-) diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index 613a5e08ad..278b9ec9b2 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -814,8 +814,9 @@ جهة الاتصال مخفية: notification - + Contact is connected + تم الاتصال notification @@ -850,8 +851,9 @@ Core built at: %@ No comment provided by engineer. - + Core version: v%@ + الإصدار الأساسي: v%@ No comment provided by engineer. @@ -901,8 +903,9 @@ عبارة المرور الحالية… No comment provided by engineer. - + Currently maximum supported file size is %@. + الحد الأقصى لحجم الملف المدعوم حاليًا هو %@. No comment provided by engineer. @@ -920,9 +923,11 @@ قاعدة البيانات مُعمّاة! No comment provided by engineer. - + Database encryption passphrase will be updated and stored in the keychain. + سيتم تحديث عبارة المرور الخاصة بتشفير قاعدة البيانات وتخزينها في سلسلة المفاتيح. + No comment provided by engineer. @@ -957,8 +962,9 @@ عبارة مرور قاعدة البيانات وتصديرها No comment provided by engineer. - + Database passphrase is different from saved in the keychain. + عبارة المرور الخاصة بقاعدة البيانات مختلفة عن تلك المحفوظة في سلسلة المفاتيح. No comment provided by engineer. @@ -966,9 +972,11 @@ عبارة مرور قاعدة البيانات مطلوبة لفتح الدردشة. No comment provided by engineer. - + Database will be encrypted and the passphrase stored in the keychain. + سيتم تشفير قاعدة البيانات وتخزين عبارة المرور في سلسلة المفاتيح. + No comment provided by engineer. @@ -978,8 +986,9 @@ No comment provided by engineer. - + Database will be migrated when the app restarts + سيتم نقل قاعدة البيانات عند إعادة تشغيل التطبيق No comment provided by engineer. @@ -1079,36 +1088,44 @@ حذف المجموعة؟ No comment provided by engineer. - + Delete invitation + حذف الدعوة No comment provided by engineer. - + Delete link + حذف الرابط No comment provided by engineer. - + Delete link? + حذف الرابط؟ No comment provided by engineer. - + Delete message? + حذف الرسالة؟ No comment provided by engineer. - + Delete messages + حذف الرسائل No comment provided by engineer. - + Delete messages after + حذف الرسائل بعد No comment provided by engineer. - + Delete old database + حذف قاعدة البيانات القديمة No comment provided by engineer. - + Delete old database? + حذف قاعدة البيانات القديمة؟ No comment provided by engineer. @@ -1125,8 +1142,9 @@ حذف قائمة الانتظار server test step - + Delete user profile? + حذف ملف تعريف المستخدم؟ No comment provided by engineer. @@ -1134,8 +1152,9 @@ الوصف No comment provided by engineer. - + Develop + يطور No comment provided by engineer. @@ -1168,28 +1187,34 @@ رسائل مباشرة chat feature - + Direct messages between members are prohibited. + الرسائل المباشرة بين الأعضاء ممنوعة. No comment provided by engineer. - + Disable SimpleX Lock + تعطيل قفل SimpleX authentication reason - + Disappearing messages + الرسائل المختفية chat feature - + Disappearing messages are prohibited in this chat. + يُحظر اختفاء الرسائل في هذه الدردشة. No comment provided by engineer. - + Disappearing messages are prohibited. + الرسائل المختفية ممنوعة. No comment provided by engineer. - + Disconnect + قطع الاتصال server test step @@ -1200,12 +1225,14 @@ Display name: No comment provided by engineer. - + Do NOT use SimpleX for emergency calls. + لا تستخدم SimpleX لإجراء مكالمات الطوارئ. No comment provided by engineer. - + Do it later + افعل ذلك لاحقا No comment provided by engineer. @@ -1258,76 +1285,93 @@ تفعيل الإشعارات دورية؟ No comment provided by engineer. - + Encrypt + التشفير No comment provided by engineer. - + Encrypt database? + تشفير قاعدة البيانات؟ No comment provided by engineer. - + Encrypted database + قاعدة بيانات مشفرة No comment provided by engineer. - + Encrypted message or another event + رسالة مشفرة أو حدث آخر notification - + Encrypted message: database error + رسالة مشفرة: خطأ في قاعدة البيانات notification - + Encrypted message: keychain error + رسالة مشفرة: خطأ في سلسلة المفاتيح notification - + Encrypted message: no passphrase + الرسالة المشفرة: لا توجد عبارة مرور notification - + Encrypted message: unexpected error + رسالة مشفرة: خطأ غير متوقع notification - + Enter correct passphrase. + أدخل عبارة المرور الصحيحة. No comment provided by engineer. - + Enter passphrase… + أدخل عبارة المرور… No comment provided by engineer. - + Enter server manually + أدخل الخادم يدوياً No comment provided by engineer. - + Error + خطأ No comment provided by engineer. - + Error accepting contact request + خطأ في قبول طلب الاتصال No comment provided by engineer. Error accessing database file No comment provided by engineer. - + Error adding member(s) + خطأ في إضافة عضو (أعضاء) No comment provided by engineer. - + Error changing address + خطأ في تغيير العنوان No comment provided by engineer. - + Error changing role + خطأ في تغيير الدور المتغير No comment provided by engineer. - + Error changing setting + خطأ في تغيير الإعدادات No comment provided by engineer. @@ -5477,6 +5521,242 @@ This is your own one-time link! Conditions will be automatically accepted for enabled operators on: %@. سيتم قبول الشروط تلقائيًا للمشغلين الممكّنين على: %@. + + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + أنشئ ملفًا شخصيًا جديدًا في [تطبيق سطح المكتب](https://simplex.chat/downloads/). 💻 + + + Error adding server + خطأ في إضافة الخادم + + + Created at: %@ + تم الإنشاء في: %@ + + + Delete %lld messages of members? + حذف %lld الرسائل القديمة للأعضاء؟ + + + Disappearing message + رسالة اختفاء + + + Enabled + ممكّنة + + + Encrypted message: database migration error + رسالة مشفرة: خطأ في ترحيل قاعدة البيانات + + + Delete list? + Delete list? + + + Delivered even when Apple drops them. + يتم تسليمها حتى عندما تسقطها شركة Apple. + + + Destination server address of %@ is incompatible with forwarding server %@ settings. + عنوان خادم الوجهة %@ غير متوافق مع إعدادات خادم التوجيه %@. + + + Destination server version of %@ is incompatible with forwarding server %@. + إصدار خادم الوجهة لـ %@ غير متوافق مع خادم التوجيه %@. + + + Don't create address + لا تنشئ عنوان + + + Done + تم + + + Duration + المدة + + + Encrypt local files + تشفير الملفات المحلية + + + Encryption renegotiation in progress. + إعادة التفاوض على التشفير قيد التنفيذ. + + + Enter Passcode + أدخل رمز المرور + + + Enter passphrase + قم بأدخل عبارة المرور + + + Enter welcome message… + أدخل رسالة ترحيب… + + + Enter your name… + أدخل اسمك… + + + Error changing to incognito! + خطأ في التغيير إلى التصفح المتخفي! + + + Delete %lld messages? + حذف %lld رسائل؟ + + + Error aborting address change + خطأ في إجهاض تغيير العنوان + + + Disappears at + يختفي عند + + + Do not use credentials with proxy. + لا تستخدم بيانات الاعتماد مع البروكسي. + + + Error accepting conditions + خطأ في قبول الشروط + + + Enter password above to show! + أدخل كلمة المرور أعلاه للعرض! + + + Error changing connection profile + خطأ في تغيير ملف تعريف الاتصال + + + Desktop app version %@ is not compatible with this app. + إصدار تطبيق سطح المكتب %@ غير متوافق مع هذا التطبيق. + + + Encrypt stored files & media + تشفير الملفات والوسائط المخزنة + + + Enter this device name… + أدخل اسم الجهاز… + + + Enter welcome message… (optional) + أدخل رسالة ترحيب... (اختياري) + + + Correct name to %@? + الاسم الصحيح ل %@؟ + + + Delete member message? + حذف رسالة العضو؟ + + + Disable automatic message deletion? + تعطيل حذف الرسائل التلقائي؟ + + + Disable delete messages + تعطيل حذف الرسائل + + + Disable for all + تعطيل للجميع + + + Disabled + عاجز + + + Documents: + المستندات: + + + By using SimpleX Chat you agree to: +- send only legal content in public groups. +- respect other users – no spam. + باستخدامك SimpleX Chat، فإنك توافق على: +- إرسال محتوى قانوني فقط في المجموعات العامة. +- احترام المستخدمين الآخرين - ممنوع إرسال رسائل مزعجة. + + + Configure server operators + تكوين مشغلي الخادم + + + Enable Flux in Network & servers settings for better metadata privacy. + تمكين التدفق في إعدادات الشبكة والخوادم لتحسين خصوصية البيانات الوصفية. + + + Discover and join groups + اكتشف المجموعات وانضم إليها + + + Discover via local network + اكتشف عبر الشبكة المحلية + + + Enabled for + ممكّن ل + + + Encrypted message: app is stopped + رسالة مشفرة: تم إيقاف التطبيق + + + Enter group name… + أدخل اسم المجموعة… + + + Do NOT use private routing. + لا تستخدم التوجيه الخاص. + + + Encryption re-negotiation error + خطأ في إعادة تفاوض التشفير + + + Connection with desktop stopped + تم إيقاف الاتصال بسطح المكتب + + + Destination server error: %@ + خطأ خادم الوجهة: %@ + + + Do NOT send messages directly, even if your or destination server does not support private routing. + لا ترسل الرسائل بشكل مباشر، حتى لو كان خادمك أو خادم الوجهة لا يدعم التوجيه الخاص. + + + Direct messages between members are prohibited in this chat. + يُحظر إرسال الرسائل المباشرة بين الأعضاء في هذه الدردشة. + + + Disconnect desktop? + فصل سطح المكتب؟ + + + Disable (keep overrides) + تعطيل (الاحتفاظ بالتجاوزات) + + + Disappears at: %@ + يختفي عند: %@ + + + Do not send history to new members. + لا ترسل التاريخ إلى الأعضاء الجدد. + + + Encryption re-negotiation failed. + فشل إعادة التفاوض على التشفير. + 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 213394aa14..995698cf2e 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -778,6 +778,10 @@ swipe action 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. Всички ваши контакти ще останат свързани. @@ -7832,6 +7836,10 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Използвай чата @@ -9661,6 +9669,10 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9673,10 +9685,6 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body - 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 826ad0171b..bf7bb307e0 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -145,18 +145,22 @@ %d file(s) are still being downloaded. + %d soubor(y) stále stahován(y). forward confirmation reason %d file(s) failed to download. + %d soubor(y) se nepodařilo stáhnout. forward confirmation reason %d file(s) were deleted. + %d soubor(y) smazán(y). forward confirmation reason %d file(s) were not downloaded. + %d soubor(y) nestažen(y). forward confirmation reason @@ -743,6 +747,10 @@ swipe action 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. Všechny vaše kontakty zůstanou připojeny. @@ -7560,6 +7568,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Použijte chat @@ -9323,6 +9335,10 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9335,10 +9351,6 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body - 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 facf6d5a9e..1fb3d61bde 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -796,6 +796,10 @@ swipe action Alle Meldungen werden für Sie archiviert. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Alle Ihre Kontakte bleiben verbunden. @@ -7201,6 +7205,7 @@ chat item action Short link + Verkürzter Link No comment provided by engineer. @@ -7305,6 +7310,7 @@ chat item action SimpleX channel link + SimpleX-Kanal-Link simplex link type @@ -7899,6 +7905,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. No comment provided by engineer. @@ -8202,6 +8209,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Unsupported connection link + Verbindungs-Link wird nicht unterstützt No comment provided by engineer. @@ -8299,6 +8307,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Solange kein Port konfiguriert ist, wird TCP-Port %@ genutzt. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Verwenden Sie Chat @@ -8366,6 +8378,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Use short links (BETA) + Kurze Links verwenden (BETA) No comment provided by engineer. @@ -10191,6 +10204,10 @@ Zuletzt empfangene Nachricht: %2$@ %d neue Ereignisse notification body + + From %d chat(s) + notification body + From: %@ Von: %@ @@ -10206,11 +10223,6 @@ Zuletzt empfangene Nachricht: %2$@ Neue Nachrichten notification - - New messages in %d chats - Neue Nachrichten in %d Chats - notification body - 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 9eb60c2cbc..0082fa574d 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -796,6 +796,11 @@ swipe action All reports will be archived for you. No comment provided by engineer. + + All servers + All servers + No comment provided by engineer. + All your contacts will remain connected. All your contacts will remain connected. @@ -8303,6 +8308,11 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Use chat @@ -10196,6 +10206,11 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + From %d chat(s) + notification body + From: %@ From: %@ @@ -10211,11 +10226,6 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - New messages in %d chats - notification body - 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 5250aa0de7..3f621bee53 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -796,6 +796,10 @@ swipe action Todos los informes serán archivados para ti. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Todos tus contactos permanecerán conectados. @@ -7201,6 +7205,7 @@ chat item action Short link + Enlace corto No comment provided by engineer. @@ -7305,6 +7310,7 @@ chat item action SimpleX channel link + Enlace de canal SimpleX simplex link type @@ -7899,6 +7905,7 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible. No comment provided by engineer. @@ -8202,6 +8209,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Unsupported connection link + Enlace de conexión no compatible No comment provided by engineer. @@ -8299,6 +8307,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Se usa el puerto TCP %@ cuando no se ha especificado otro. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Usar Chat @@ -8366,6 +8378,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use short links (BETA) + Usar enlaces cortos (BETA) No comment provided by engineer. @@ -10191,6 +10204,10 @@ last received msg: %2$@ %d evento(s) nuevo(s) notification body + + From %d chat(s) + notification body + From: %@ De: %@ @@ -10206,11 +10223,6 @@ last received msg: %2$@ Mensajes nuevos notification - - New messages in %d chats - Mensajes nuevos en %d chat(s) - notification body - 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 8dc4250e20..a6b05ee48a 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -730,6 +730,10 @@ swipe action 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. Kaikki kontaktisi pysyvät yhteydessä. @@ -7537,6 +7541,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Käytä chattia @@ -9298,6 +9306,10 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9310,10 +9322,6 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body - 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 304b9e2084..2928b9f167 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -796,6 +796,10 @@ swipe action Tous les rapports seront archivés pour vous. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Tous vos contacts resteront connectés. @@ -8227,6 +8231,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Utiliser le chat @@ -10111,6 +10119,10 @@ dernier message reçu : %2$@ %d nouveaux événements notification body + + From %d chat(s) + notification body + From: %@ De : %@ @@ -10126,11 +10138,6 @@ dernier message reçu : %2$@ Nouveaux messages notification - - New messages in %d chats - Nouveaux messages dans %d chats - notification body - 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 f24ba2b781..cde7807c44 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -796,6 +796,10 @@ swipe action Az összes jelentés archiválva lesz az Ön számára. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Az összes partnerével kapcsolatban marad. @@ -1093,7 +1097,7 @@ swipe action Attach - Csatolás + Mellékelés No comment provided by engineer. @@ -4055,7 +4059,7 @@ Hiba: %2$@ Hide: - Elrejtés: + Elrejtve: No comment provided by engineer. @@ -5906,7 +5910,7 @@ Hiba: %@ Privacy redefined - Adatvédelem újraértelmezve + Újraértelmezett adatvédelem No comment provided by engineer. @@ -7201,6 +7205,7 @@ chat item action Short link + Rövid hivatkozás No comment provided by engineer. @@ -7245,7 +7250,7 @@ chat item action Show: - Megjelenítés: + Megjelenítve: No comment provided by engineer. @@ -7305,6 +7310,7 @@ chat item action SimpleX channel link + SimpleX-csatornahivatkozás simplex link type @@ -7899,6 +7905,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől. No comment provided by engineer. @@ -8202,6 +8209,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Unsupported connection link + Nem támogatott kapcsolattartási hivatkozás No comment provided by engineer. @@ -8299,6 +8307,10 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso A következő TCP-port használata, amikor nincs port megadva: %@. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat SimpleX Chat használata @@ -8366,6 +8378,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Use short links (BETA) + Rövid hivatkozások használata (béta) No comment provided by engineer. @@ -10191,6 +10204,10 @@ utoljára fogadott üzenet: %2$@ %d új esemény notification body + + From %d chat(s) + notification body + From: %@ Tőle: %@ @@ -10206,11 +10223,6 @@ utoljára fogadott üzenet: %2$@ Új üzenetek notification - - New messages in %d chats - Új üzenetek %d csevegésben - notification body - 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 353d420e27..31e89b8507 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -796,6 +796,10 @@ swipe action Tutte le segnalazioni verranno archiviate per te. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Tutti i tuoi contatti resteranno connessi. @@ -7201,6 +7205,7 @@ chat item action Short link + Link breve No comment provided by engineer. @@ -7305,6 +7310,7 @@ chat item action SimpleX channel link + Link del canale SimpleX simplex link type @@ -7899,6 +7905,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Questo link richiede una versione più recente dell'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile. No comment provided by engineer. @@ -8202,6 +8209,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Unsupported connection link + Link di connessione non supportato No comment provided by engineer. @@ -8299,6 +8307,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa la porta TCP %@ quando non è specificata alcuna porta. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Usa la chat @@ -8366,6 +8378,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Use short links (BETA) + Usa link brevi (BETA) No comment provided by engineer. @@ -10191,6 +10204,10 @@ ultimo msg ricevuto: %2$@ %d nuovi eventi notification body + + From %d chat(s) + notification body + From: %@ Da: %@ @@ -10206,11 +10223,6 @@ ultimo msg ricevuto: %2$@ Nuovi messaggi notification - - New messages in %d chats - Nuovi messaggi in %d chat - notification body - 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 34856e2e2b..e929df1f35 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -768,6 +768,10 @@ swipe action 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. あなたの連絡先が繋がったまま継続します。 @@ -7607,6 +7611,10 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat チャット @@ -9369,6 +9377,10 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9381,10 +9393,6 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body - 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 4d9df505db..2b2a79731e 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -796,6 +796,10 @@ swipe action Alle rapporten worden voor u gearchiveerd. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Al uw contacten blijven verbonden. @@ -8299,6 +8303,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik TCP-poort %@ als er geen poort is opgegeven. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Gebruik chat @@ -10191,6 +10199,10 @@ laatst ontvangen bericht: %2$@ ‐%d nieuwe gebeurtenissen notification body + + From %d chat(s) + notification body + From: %@ Van: %@ @@ -10206,11 +10218,6 @@ laatst ontvangen bericht: %2$@ Nieuwe berichten notification - - New messages in %d chats - Nieuwe berichten in %d chats - notification body - 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 0a548dc227..5e77f836a9 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -796,6 +796,10 @@ swipe action Wszystkie raporty zostaną dla Ciebie zarchiwizowane. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Wszystkie Twoje kontakty pozostaną połączone. @@ -8104,6 +8108,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Użyj czatu @@ -9975,6 +9983,10 @@ ostatnia otrzymana wiadomość: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9987,11 +9999,6 @@ ostatnia otrzymana wiadomość: %2$@ New messages notification - - New messages in %d chats - Nowe wiadomości w %d czatach - notification body - 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 61495069c7..d120909994 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -796,6 +796,10 @@ swipe action Все сообщения о нарушениях будут заархивированы для вас. No comment provided by engineer. + + All servers + No comment provided by engineer. + All your contacts will remain connected. Все контакты, которые соединились через этот адрес, сохранятся. @@ -8249,6 +8253,10 @@ To connect, please ask your contact to create another connection link and check Использовать TCP-порт %@, когда порт не указан. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Использовать чат @@ -10135,6 +10143,10 @@ last received msg: %2$@ %d новых сообщений notification body + + From %d chat(s) + notification body + From: %@ От: %@ @@ -10150,11 +10162,6 @@ last received msg: %2$@ Новые сообщения notification - - New messages in %d chats - Новые сообщения в %d разговоре(ах) - notification body - 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 7431c13969..279fab822a 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -722,6 +722,10 @@ swipe action 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. ผู้ติดต่อทั้งหมดของคุณจะยังคงเชื่อมต่ออยู่. @@ -7509,6 +7513,10 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat ใช้แชท @@ -9265,6 +9273,10 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9277,10 +9289,6 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body - 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 935c4885b5..2208f65b89 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -788,6 +788,10 @@ swipe action 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. Konuştuğun kişilerin tümü bağlı kalacaktır. @@ -8119,6 +8123,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Sohbeti kullan @@ -9989,6 +9997,10 @@ son alınan msj: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -10001,10 +10013,6 @@ son alınan msj: %2$@ New messages notification - - New messages in %d chats - notification body - 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 9ea65c4b11..1863bf6cd8 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -788,6 +788,10 @@ swipe action 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. Всі ваші контакти залишаться на зв'язку. @@ -8168,6 +8172,10 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat Використовуйте чат @@ -10052,6 +10060,10 @@ last received msg: %2$@ %d нових подій notification body + + From %d chat(s) + notification body + From: %@ Від: %@ @@ -10067,11 +10079,6 @@ last received msg: %2$@ Нові повідомлення notification - - New messages in %d chats - Нові повідомлення в чатах %d - notification body - 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 30a6567414..4bc7095bd2 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 @@ -768,6 +768,10 @@ swipe action 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. 所有联系人会保持连接。 @@ -8010,6 +8014,10 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. No comment provided by engineer. + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + Use chat 使用聊天 @@ -9875,6 +9883,10 @@ last received msg: %2$@ %d new events notification body + + From %d chat(s) + notification body + From: %@ notification body @@ -9887,10 +9899,6 @@ last received msg: %2$@ New messages notification - - New messages in %d chats - notification body - diff --git a/apps/ios/SimpleX NSE/de.lproj/Localizable.strings b/apps/ios/SimpleX NSE/de.lproj/Localizable.strings index f9779c6e05..d0b2f8bd1b 100644 --- a/apps/ios/SimpleX NSE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/de.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Neue Nachrichten"; -/* notification body */ -"New messages in %d chats" = "Neue Nachrichten in %d Chats"; - diff --git a/apps/ios/SimpleX NSE/es.lproj/Localizable.strings b/apps/ios/SimpleX NSE/es.lproj/Localizable.strings index fb190400e1..8b43c489b7 100644 --- a/apps/ios/SimpleX NSE/es.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/es.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Mensajes nuevos"; -/* notification body */ -"New messages in %d chats" = "Mensajes nuevos en %d chat(s)"; - diff --git a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings index a57961c934..999bb3608f 100644 --- a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Nouveaux messages"; -/* notification body */ -"New messages in %d chats" = "Nouveaux messages dans %d chats"; - diff --git a/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings b/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings index e64c98df9e..69456fd177 100644 --- a/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Új üzenetek"; -/* notification body */ -"New messages in %d chats" = "Új üzenetek %d csevegésben"; - diff --git a/apps/ios/SimpleX NSE/it.lproj/Localizable.strings b/apps/ios/SimpleX NSE/it.lproj/Localizable.strings index 31f463eb5b..e22f5aeac3 100644 --- a/apps/ios/SimpleX NSE/it.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/it.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Nuovi messaggi"; -/* notification body */ -"New messages in %d chats" = "Nuovi messaggi in %d chat"; - diff --git a/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings b/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings index 4cf91689b5..12d1e01f1d 100644 --- a/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/nl.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Nieuwe berichten"; -/* notification body */ -"New messages in %d chats" = "Nieuwe berichten in %d chats"; - diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index 6ba39ccc63..7205b37e7f 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Новые сообщения"; -/* notification body */ -"New messages in %d chats" = "Новые сообщения в %d разговоре(ах)"; - diff --git a/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings b/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings index 69cc53bff1..ceace71e34 100644 --- a/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/uk.lproj/Localizable.strings @@ -10,6 +10,3 @@ /* notification */ "New messages" = "Нові повідомлення"; -/* notification body */ -"New messages in %d chats" = "Нові повідомлення в чатах %d"; - diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 25fb66aa12..c150ba5564 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -124,6 +124,18 @@ /* time interval */ "%d days" = "%d dní"; +/* forward confirmation reason */ +"%d file(s) are still being downloaded." = "%d soubor(y) stále stahován(y)."; + +/* forward confirmation reason */ +"%d file(s) failed to download." = "%d soubor(y) se nepodařilo stáhnout."; + +/* forward confirmation reason */ +"%d file(s) were deleted." = "%d soubor(y) smazán(y)."; + +/* forward confirmation reason */ +"%d file(s) were not downloaded." = "%d soubor(y) nestažen(y)."; + /* time interval */ "%d hours" = "%d hodin"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index ef03de17f0..8b0e18ecce 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -4751,6 +4751,9 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Mit Kontakten teilen"; +/* No comment provided by engineer. */ +"Short link" = "Verkürzter Link"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Bei Nachrichten, die über privates Routing versendet wurden, → anzeigen."; @@ -4793,6 +4796,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "SimpleX-Adresse oder Einmal-Link?"; +/* simplex link type */ +"SimpleX channel link" = "SimpleX-Kanal-Link"; + /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX-Chat und Flux haben vereinbart, die von Flux betriebenen Server in die App aufzunehmen."; @@ -5181,6 +5187,9 @@ report reason */ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Das ist Ihre eigene SimpleX-Adresse!"; +/* 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." = "Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Dieser Link wurde schon mit einem anderen Mobiltelefon genutzt. Bitte erstellen sie einen neuen Link in der Desktop-App."; @@ -5373,6 +5382,9 @@ report reason */ /* swipe action */ "Unread" = "Ungelesen"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Verbindungs-Link wird nicht unterstützt"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Bis zu 100 der letzten Nachrichten werden an neue Mitglieder gesendet."; @@ -5466,6 +5478,9 @@ report reason */ /* No comment provided by engineer. */ "Use servers" = "Verwende Server"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Kurze Links verwenden (BETA)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Verwenden Sie SimpleX-Chat-Server?"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index f9fe85dba7..9644521723 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -4751,6 +4751,9 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Compartir con contactos"; +/* No comment provided by engineer. */ +"Short link" = "Enlace corto"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Mostrar → en mensajes con enrutamiento privado."; @@ -4793,6 +4796,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "¿Dirección SimpleX o enlace de un uso?"; +/* simplex link type */ +"SimpleX channel link" = "Enlace de canal SimpleX"; + /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux."; @@ -5181,6 +5187,9 @@ report reason */ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "¡Esta es tu propia dirección 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." = "Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Este enlace ha sido usado en otro dispositivo móvil, por favor crea un enlace nuevo en el ordenador."; @@ -5373,6 +5382,9 @@ report reason */ /* swipe action */ "Unread" = "No leído"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Enlace de conexión no compatible"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Hasta 100 últimos mensajes son enviados a los miembros nuevos."; @@ -5466,6 +5478,9 @@ report reason */ /* No comment provided by engineer. */ "Use servers" = "Usar servidores"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Usar enlaces cortos (BETA)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "¿Usar servidores SimpleX Chat?"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 2d2fbb2ad1..23ff6fc0bf 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -692,7 +692,7 @@ swipe action */ "Ask" = "Mindig kérdezzen rá"; /* No comment provided by engineer. */ -"Attach" = "Csatolás"; +"Attach" = "Mellékelés"; /* No comment provided by engineer. */ "attempts" = "próbálkozások"; @@ -2671,7 +2671,7 @@ snd error text */ "Hide profile" = "Profil elrejtése"; /* No comment provided by engineer. */ -"Hide:" = "Elrejtés:"; +"Hide:" = "Elrejtve:"; /* No comment provided by engineer. */ "History" = "Előzmények"; @@ -3915,7 +3915,7 @@ time to disappear */ "Privacy policy and conditions of use." = "Adatvédelmi szabályzat és felhasználási feltételek."; /* No comment provided by engineer. */ -"Privacy redefined" = "Adatvédelem újraértelmezve"; +"Privacy redefined" = "Újraértelmezett adatvédelem"; /* No comment provided by engineer. */ "Private chats, groups and your contacts are not accessible to server operators." = "A privát csevegések, a csoportok és a partnerek nem érhetők el a szerver üzemeltetői számára."; @@ -4751,6 +4751,9 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Megosztás a partnerekkel"; +/* No comment provided by engineer. */ +"Short link" = "Rövid hivatkozás"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Egy „→” jel megjelenítése a privát útválasztáson keresztül küldött üzeneteknél."; @@ -4776,7 +4779,7 @@ chat item action */ "Show QR code" = "QR-kód megjelenítése"; /* No comment provided by engineer. */ -"Show:" = "Megjelenítés:"; +"Show:" = "Megjelenítve:"; /* No comment provided by engineer. */ "SimpleX" = "SimpleX"; @@ -4793,6 +4796,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó?"; +/* simplex link type */ +"SimpleX channel link" = "SimpleX-csatornahivatkozás"; + /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba."; @@ -5181,6 +5187,9 @@ report reason */ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Ez a saját SimpleX-címe!"; +/* 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." = "Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén."; @@ -5373,6 +5382,9 @@ report reason */ /* swipe action */ "Unread" = "Olvasatlan"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Nem támogatott kapcsolattartási hivatkozás"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára."; @@ -5466,6 +5478,9 @@ report reason */ /* No comment provided by engineer. */ "Use servers" = "Kiszolgálók használata"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Rövid hivatkozások használata (béta)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "SimpleX Chat-kiszolgálók használata?"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 3988b33531..f68424325f 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -4751,6 +4751,9 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Condividi con i contatti"; +/* No comment provided by engineer. */ +"Short link" = "Link breve"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Mostra → nei messaggi inviati via instradamento privato."; @@ -4793,6 +4796,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "Indirizzo SimpleX o link una tantum?"; +/* simplex link type */ +"SimpleX channel link" = "Link del canale SimpleX"; + /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app."; @@ -5181,6 +5187,9 @@ report reason */ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Questo è il tuo indirizzo 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." = "Questo link richiede una versione più recente dell'app. Aggiornala o chiedi al tuo contatto di inviare un link compatibile."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Questo link è stato usato con un altro dispositivo mobile, creane uno nuovo sul desktop."; @@ -5373,6 +5382,9 @@ report reason */ /* swipe action */ "Unread" = "Non letto"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Link di connessione non supportato"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Vengono inviati ai nuovi membri fino a 100 ultimi messaggi."; @@ -5466,6 +5478,9 @@ report reason */ /* No comment provided by engineer. */ "Use servers" = "Usa i server"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Usa link brevi (BETA)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Usare i server di SimpleX Chat?"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index a804d48dfb..4bb2244785 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2372,4 +2372,8 @@ رابط قصير رابط قناة SimpleX رابط اتصال غير مدعوم + استخدم منفذ TCP 443 للخوادم المُعدة مسبقًا فقط. + إيقاف التشغيل + الخوادم المُعدة مسبقًا + جميع الخوادم diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 77c2a6ef65..0e179fadd4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -2349,4 +2349,8 @@ Emprar enllaços curts (BETA) Enllaç complet Enllaç curt + Tots els servidors + Apagat + Feu servir el port TCP 443 només per a servidors predefinits. + Servidors predefinits diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index e262a59214..439b9df18d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -446,7 +446,7 @@ špatné ID zprávy duplicitní zpráva Přeskočené zprávy - Ochrana osobních údajů a zabezpečení + Soukromí a zabezpečení Vaše soukromí Skrývat aplikaci Odesílat náhledy odkazů @@ -2251,7 +2251,7 @@ Přepnout chat seznam: Tuto akci nelze zrušit - zprávy odeslané a přijaté v tomto chatu dříve než vybraná, budou smazány. Statistiky serverů budou obnoveny - nemůže být vráceno! - Odeslat soukromý report + Odešlete soukromý report Pomozte administrátorům moderovat své skupiny. Rychlejší mazání skupin. Od %s. @@ -2335,7 +2335,7 @@ Zprávy budou smazány pro všechny členy. Aplikace vyžaduje potvrzení stahování z neznámých serverů (s výjimkou .onion nebo při aktivaci SOCKS proxy). Musíte povolit kontaktům volání, abyste jim mohli zavolat. - Nastavit expirace zpráv. + Nastavení expirace zpráv. Zobrazit procenta Nahraný archiv databáze bude ze serverů trvale odstraněn. Pro ochranu před záměnou odkazů, můžete porovnat bezpečnostní kódy. @@ -2370,4 +2370,14 @@ Nastavit operátora serveru Zásady ochrany soukromí a podmínky používání. Soukromé konverzace, skupiny a kontakty nejsou přístupné provozovatelům serverů. + Nepodporovaný odkaz k připojení + Používejte krátké odkazy (BETA) + Tento odkaz vyžaduje novější verzi aplikace. Prosím aktualizujte aplikaci nebo požádejte kontakt o odeslání kompatibilního odkazu. + odkaz SimpleX kanálu + Úplný odkaz + Krátký odkaz + Všechny servery + Vypnut + Přednastavené servery + Použít TCP port 443 jen pro přednastavené servery. 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 089ef06827..6472faa671 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -2453,9 +2453,13 @@ Server-Betreiber konfigurieren Private Chats, Gruppen und Ihre Kontakte sind für Server-Betreiber nicht zugänglich. Verbindungs-Link wird nicht unterstützt - Verkürzte Links verwenden (BETA) + Kurze Links verwenden (BETA) Verkürzter Link Vollständiger Link SimpleX-Kanal-Link Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. + Alle Server + Aus + Für voreingestellte Server nur TCP-Port 443 verwenden . + Voreingestellte Server diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 6ba4c15afb..171b367ca0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -2383,4 +2383,8 @@ Este enlace requiere una versión más reciente de la aplicación. Por favor, actualiza la aplicación o pide a tu contacto un enlace compatible. Enlace de conexión no compatible Usar enlaces cortos (BETA) + Usar puerto TCP 443 solo en servidores predefinidos. + Todos los servidores + Servidores predefinidos + No 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 6d62d3f5b8..2776310540 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -141,7 +141,7 @@ Engedélyezés Érvénytelen számítógépcím Profil hozzáadása - Csatolás + Mellékelés Alkalmazás jelkód Felkérték a kép fogadására Kamera @@ -658,7 +658,7 @@ Kép A fájlok- és a médiatartalmak küldése le van tiltva. Hogyan működik - Elrejtés: + Elrejtve: Hiba történt a partnerrel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) beolvashatja a QR-kódot a videohívásban, vagy a partnere megoszthat egy meghívási hivatkozást.]]> @@ -1122,7 +1122,7 @@ Biztonsági kód hitelesítése eltávolította Önt SimpleX-cím - Megjelenítés: + Megjelenítve: válasz fogadása… Visszaállítja az adatbázismentést? Üzenetek fogadása… @@ -1175,7 +1175,7 @@ elküldve SOCKS-proxy használata Élő üzenet küldése - Adatvédelem újraértelmezve + Újraértelmezett adatvédelem Hangüzenet… Alkalmazás képernyőjének védelme QR-kód megjelenítése @@ -2350,4 +2350,8 @@ Teljes hivatkozás Ez a hivatkozás újabb alkalmazásverziót igényel. Frissítse az alkalmazást vagy kérjen egy kompatibilis hivatkozást a partnerétől. SimpleX-csatornahivatkozás + Összes kiszolgáló + 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. 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 95b60a3394..a269149e99 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2349,4 +2349,14 @@ Kebijakan privasi dan ketentuan penggunaan. Obrolan pribadi, grup, dan kontak Anda tidak dapat diakses oleh operator server. Frasa sandi di Keystore tidak dapat dibaca, silakan masukkan secara manual. Hal ini mungkin terjadi setelah pembaruan sistem yang tidak kompatibel dengan aplikasi. Jika tidak demikian, silakan hubungi pengembang. + Gunakan port TCP 443 hanya untuk presetel server. + Semua server + Mati + Presetel server + Tautan lengkap + Tautan ini perlu versi aplikasi yang baru. Harap perbarui aplikasi atau minta kontak untuk kirim tautan kompatibel. + Tautan saluran SimpleX + Gunakan tautan singkat (BETA) + Tautan koneksi tidak didukung + Tautan singkat 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 4373091266..201745f042 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2387,4 +2387,8 @@ Link del canale SimpleX Link di connessione non supportato Usa link brevi (BETA) + Tutti i server + Off + Server preimpostati + Usa la porta TCP 443 solo per i server preimpostati. 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 ad2a7e67be..549cb01b63 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -18,7 +18,7 @@ 1-разове посилання Про SimpleX Chat Додавайте сервери, скануючи QR-коди. - Всі чати і повідомлення будуть видалені - цю дію неможливо скасувати! + Усі чати та повідомлення будуть видалені - цю дію неможливо скасувати! Дозволити дзвінки тільки за умови, що ваш контакт дозволяє їх. Дозволити безповоротне видалення повідомлень, тільки якщо ваш контакт дозволяє вам. (24 години) Дозволити голосові повідомлення\? @@ -50,20 +50,20 @@ Дозволити Розширені налаштування мережі Отримувати доступ до серверів через SOCKS-проксі на порті %d? Проксі має бути запущено до активації цієї опції. - Всі ваші контакти залишаться підключеними. - Всі дані застосунку буде видалено. + Усі ваші контакти залишаться підключеними. + Усі дані застосунку буде видалено. Після перезапуску додатка або зміни ключової фрази буде використано сховище ключів Android для безпечного збереження ключової фрази - це дозволить отримувати сповіщення. Дозвольте вашим контактам надсилати голосові повідомлення. Прийняти інкогніто Додати сервер адміністратор Додати привітання - Всі учасники групи залишаться підключеними. + Усі учасники групи залишаться підключеними. Дозвольте вашим контактам надсилати повідомлення, які зникають. - Всі повідомлення будуть видалені - цю дію неможливо скасувати! Повідомлення будуть видалені ЛИШЕ для вас. + Усі повідомлення будуть видалені - цю дію неможливо скасувати! Повідомлення будуть видалені ЛИШЕ для вас. Версія додатку Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Оновлення профілю буде відправлено вашим контактам. - Всі ваші контакти залишаться підключеними. Оновлення профілю буде відправлено вашим контактам. + Усі ваші контакти залишаться підключеними. Оновлення профілю буде відправлено вашим контактам. Відповісти на виклик Адреса Додати профіль @@ -717,7 +717,7 @@ Зникає о: %s (поточне) Вилучити учасника - Роль буде змінено на "%s". Всі учасники групи будуть сповіщені. + Роль буде змінено на %s. Усі учасники групи будуть сповіщені. Роль буде змінено на "%s". Учасник отримає нове запрошення. Група Привітальне повідомлення @@ -1428,7 +1428,7 @@ Ви вже подали запит на підключення за цією адресою! надіслати приватне повідомлення Показувати консоль в новому вікні - Всі нові повідомлення від %s будуть приховані! + Усі нові повідомлення від %s будуть приховані! підключив(лась) безпосередньо заблоковано Блокувати учасників групи @@ -1674,7 +1674,7 @@ Перевірте підключення до Інтернету та спробуйте ще раз Переконайтеся, що ви пам\'ятаєте пароль до бази даних для її перенесення. Помилка при перевірці парольної фрази: - Всі ваші контакти, розмови та файли будуть надійно зашифровані та завантажені частинами на налаштовані XFTP-реле. + Усі ваші контакти, розмови та файли будуть надійно зашифровані та завантажені частинами на налаштовані XFTP-реле. Please note: використання однієї і тієї ж бази даних на двох пристроях порушить розшифровку повідомлень з ваших з\'єднань, як захист безпеки.]]> Скасувати міграцію Чат перемістився! @@ -2025,7 +2025,7 @@ Проксірований Надіслати помилки Завершено - Всі профілі + Усі профілі Скинути Вивантажено Видалити %d повідомлень учасників? @@ -2384,4 +2384,8 @@ Коротке посилання Посилання на канал SimpleX Несумісне посилання для підключення + Усі сервери + Ні + Типові сервери + Використовуйте TCP порт 443 лише для попередньо налаштованих серверів. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 99931215e7..3cbc54f652 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -1263,7 +1263,7 @@ Đang chờ xử lý Đang chờ xử lý Không tìm thấy mật khẩu trong Keystore, vui lòng nhập thủ công. Điều này có thể xảy ra nếu bạn khôi phục dữ liệu ứng dụng bằng một công cụ sao lưu. Nếu không phải như vậy, xin vui lòng liên hệ với nhà phát triển. - Thành viên cũ %1$s + Thành viên trước đây %1$s Dán đường dẫn để kết nối! Thông báo định kỳ Dán đường dẫn mà bạn nhận được @@ -2358,4 +2358,8 @@ Sử dụng đường dẫn ngắn (BETA) Toàn bộ đường dẫn Đường dẫn ngắn + Tắt + Các máy chủ cài sẵn + Chỉ sử dụng cổng TCP 443 cho các máy chủ cài sẵn. + Tất cả máy chủ 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 1958885843..a85bd119c5 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 @@ -2371,4 +2371,8 @@ 短链接 此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。 完整链接 + 全部服务器 + 关闭 + 预设服务器 + 仅预设服务器使用 TCP 协议 443 端口。 From f49c51ae16cb521db681f0982e7bbf6210fa2578 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 7 May 2025 11:27:10 +0100 Subject: [PATCH 219/289] website: translations, readme: ZEC address (#5875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) 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/de/ * Translated using Weblate (Arabic) Currently translated at 91.4% (235 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * 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 (Hungarian) Currently translated at 92.2% (237 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Arabic) 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/ar/ * Translated using Weblate (Arabic) 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/ar/ * 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 (German) 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/de/ * Translated using Weblate (Arabic) Currently translated at 91.4% (235 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * 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 (Hungarian) Currently translated at 92.2% (237 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Arabic) 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/ar/ * Translated using Weblate (Arabic) 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/ar/ * 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 (Arabic) 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/ar/ * Translated using Weblate (Italian) 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/it/ * 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/ * fix * ZEC address --------- Co-authored-by: mlanp Co-authored-by: Muhammad Co-authored-by: 大王叫我来巡山 Co-authored-by: summoner001 Co-authored-by: jonnysemon Co-authored-by: Random --- README.md | 1 + docs/dependencies/README.md | 6 ++-- website/langs/ar.json | 56 ++++++++++++++++++------------------- website/langs/de.json | 40 +++++++++++++------------- website/langs/hu.json | 40 +++++++++++++------------- website/langs/it.json | 40 +++++++++++++------------- website/langs/zh_Hans.json | 40 +++++++++++++------------- website/web.sh | 1 + 8 files changed, 113 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 7e6e5d975d..40d552b84d 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ It is possible to donate via: - ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg +- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq - DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf - SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - please ask if you want to donate any other coins. diff --git a/docs/dependencies/README.md b/docs/dependencies/README.md index 02c0623988..bec369c126 100644 --- a/docs/dependencies/README.md +++ b/docs/dependencies/README.md @@ -1,9 +1,9 @@ # SimpleX Chat and SimpleX servers dependencies -[SQLCipher](https://github.com/sqlcipher/sqlcipher): Extension of [SQLite](https://sqlite.org) with encryption ([BSD-style](./licences/build/sqlcipher/LICENSE.md)) +[SQLCipher](https://github.com/sqlcipher/sqlcipher): Extension of [SQLite](https://sqlite.org) with encryption ([BSD-style](./licences/apps/sqlcipher/LICENSE.md)) -[vlc](https://github.com/videolan/vlc): VLC media player library ([LGPLv2](./licences/build/vlc/COPYING.LIB)) +[vlc](https://github.com/videolan/vlc): VLC media player library ([LGPLv2](./licences/apps/vlc/COPYING.LIB)) -[WebRTC](https://webrtc.googlesource.com/src/): RTC for calls ([BSD-3-clause](./licences/build/webrtc/LICENSE)) +[WebRTC](https://webrtc.googlesource.com/src/): RTC for calls ([BSD-3-clause](./licences/apps/webrtc/LICENSE)) [Haskell dependencies](./HASKELL.md). diff --git a/website/langs/ar.json b/website/langs/ar.json index fd759d5491..0c1fb4515a 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -29,7 +29,7 @@ "simplex-explained-tab-2-text": "2. كيف يعمل", "simplex-explained-tab-1-p-2": "كيف يمكن أن تعمل مع قوائم انتظار أحادية الاتجاه وبدون معرّفات ملف تعريف المستخدم؟", "simplex-explained-tab-2-p-1": "لكل اتصال، تستخدم قائمتي انتظار منفصلتين للمُراسلة لإرسال واستلام الرسائل عبر خوادم مختلفة.", - "simplex-explained-tab-2-p-2": "تقوم الخوادم بتمرير الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", + "simplex-explained-tab-2-p-2": "تمرّر الخوادم الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", "simplex-explained-tab-3-p-1": "تحتوي الخوادم على بيانات اعتماد مجهولة منفصلة لكل قائمة انتظار، ولا تعرف المستخدمين الذين ينتمون إليهم.", "copyright-label": "مشروع مفتوح المصدر © SimpleX 2020-2025", "simplex-chat-protocol": "بروتوكول دردشة SimpleX", @@ -51,13 +51,13 @@ "simplex-private-5-title": "طبقات متعددة من
حشوة المحتوى", "simplex-private-7-title": "التحقق
من سلامة الرسالة", "simplex-private-8-title": "خلط الرسائل
لتقليل من الارتباط", - "simplex-private-10-title": "معرفات زوجية مجهولة مؤقتة", + "simplex-private-10-title": "معرّفات زوجية مجهولة مؤقتة", "simplex-private-card-3-point-1": "يتم استخدام TLS 1.2 / 1.3 مع خوارزميات قوية فقط لاتصالات الخادم والعميل.", "simplex-private-card-3-point-2": "تعمل بصمة الخادم وربط القناة على منع هجمات الوسيط (MITM) وإعادة التشغيل.", "simplex-private-card-3-point-3": "استئناف الاتصال معطل لمنع هجمات الجلسة.", "simplex-private-card-4-point-1": "لحماية عنوان IP الخاص بك، يمكنك الوصول إلى الخوادم عبر تور أو بعض شبكات تراكب النقل الأخرى.", "simplex-private-card-5-point-1": "يستخدم SimpleX حشوة المحتوى لكل طبقة تعمية لإحباط هجمات حجم الرسالة.", - "simplex-private-card-6-point-2": "لمنع ذلك، تقوم تطبيقات SimpleX بتمرير مفاتيح لمرة واحدة خارج النطاق، عند مشاركة عنوان كرابط أو رمز QR.", + "simplex-private-card-6-point-2": "لمنع ذلك، تقوم تطبيقات SimpleX بتمرير مفاتيح لمرة واحدة خارج النطاق، عندما تُشارك عنوان كرابط أو رمز QR.", "simplex-private-card-8-point-1": "تعمل خوادم SimpleX كعقد مختلطة بزمن انتقال منخفض — الرسائل الواردة والصادرة لها ترتيب مختلف.", "simplex-private-card-9-point-1": "كل رسالة انتظار تمرر الرسائل في اتجاه واحد، بعناوين إرسال واستلام مختلفة.", "simplex-private-card-9-point-2": "إنه يقلل من نواقل الهجوم، مقارنة بوسطاء الرسائل التقليديين، والبيانات الوصفية المتاحة.", @@ -74,7 +74,7 @@ "simplex-unique-1-overlay-1-title": "الخصوصية الكاملة لهويتك وملفك الشخصي وجهات الاتصال والبيانات الوصفية", "simplex-unique-2-title": "أنت محمي
من رسائل الإزعاج وإساءة الاستخدام", "simplex-unique-3-title": "أنت تتحكم في بياناتك", - "simplex-unique-3-overlay-1-title": "ملكية ومراقبة وأمان بياناتك", + "simplex-unique-3-overlay-1-title": "الملكية والتحكم وأمن بياناتك", "simplex-unique-4-title": "أنت تمتلك شبكة SimpleX", "simplex-unique-4-overlay-1-title": "لامركزية بالكامل — يمتلك المستخدمون شبكة SimpleX", "hero-overlay-card-1-p-4": "هذا التصميم يمنع تسريب أي البيانات الوصفية للمستخدمين على مستوى التطبيق. لزيادة تحسين الخصوصية وحماية عنوان IP الخاص بك، يمكنك الاتصال بخوادم المراسلة عبر Tor.", @@ -85,23 +85,23 @@ "privacy-matters-overlay-card-1-p-2": "يعرف تجار التجزئة عبر الإنترنت أن الأشخاص ذوي الدخل المنخفض هم أكثر عرضة لإجراء عمليات شراء عاجلة، لذلك قد يفرضون أسعارًا أعلى أو يزيلون الخصومات.", "simplex-private-6-title": "تبديل
خارج النطاق", "simplex-private-9-title": "قوائم انتظار
أحادية الاتجاه", - "privacy-matters-overlay-card-1-p-4": "تحمي منصة SimpleX خصوصية اتصالاتك بشكل أفضل من أي بديل آخر، مما يمنع تمامًا الرسم البياني الاجتماعي الخاص بك من أن يصبح متاحًا لأي شركات أو مؤسسات. حتى عندما يستخدم الأشخاص الخوادم التي توفرها SimpleX Chat، فإننا لا نعرف عدد المستخدمين أو اتصالاتهم.", + "privacy-matters-overlay-card-1-p-4": "تحمي شبكة SimpleX خصوصية اتصالاتك بشكل أفضل من أي بديل آخر، مما يمنع تمامًا الرسم البياني الاجتماعي الخاص بك من أن يصبح متاحًا لأي شركات أو مؤسسات. حتى عندما يستخدم الأشخاص الخوادم التي توفرها SimpleX Chat، فإننا لا نعرف عدد المستخدمين أو اتصالاتهم.", "simplex-private-card-1-point-1": "بروتوكول السقاطة المزدوجة —
رسائل OTR مع السرية المستمرة واستعادة الاختراق.", "simplex-private-card-1-point-2": "NaCL cryptobox في كل قائمة انتظار لمنع ارتباط حركة مرور البيانات بين قوائم انتظار الرسائل في حالة اختراق TLS.", "simplex-private-card-2-point-1": "طبقة إضافية من تعمية الخادم للتسليم إلى المُستلم، لمنع الارتباط بين حركة مرور بيانات الخادم المُستلمة والمُرسلة في حالة اختراق TLS.", "simplex-private-card-4-point-2": "لاستخدام SimpleX عبر تور، يُرجى تثبيت تطبيق Orbot وتمكّين وكيل SOCKS5 (أو VPN على iOS ).", "simplex-private-card-5-point-2": "يجعل الرسائل ذات الأحجام المختلفة تبدو متشابهة للخوادم ومراقبي الشبكة.", - "simplex-private-card-6-point-1": "العديد من منصات الاتصال عرضة لهجمات الوسيط (MITM) من قبل الخوادم أو موفري الشبكات.", + "simplex-private-card-6-point-1": "العديد من شبكات التواصل عرضة لهجمات الوسيط (MITM) من قِبل الخوادم أو موفري الشبكات.", "simplex-private-card-7-point-1": "لضمان سلامة الرسائل يتم ترقيمها بالتسلسل وتضمين تجزئة الرسالة السابقة.", "simplex-private-card-7-point-2": "إذا أُضيفت أي رسالة أو أُزيلت أو تغيّرت، فسيتم تنبيه المُستلم.", "simplex-private-card-10-point-2": "يسمح بتسليم الرسائل بدون معرّفات ملف تعريف المستخدم، مما يوفر خصوصية للبيانات الوصفية أفضل من البدائل.", "privacy-matters-2-overlay-1-linkText": "تمنحك الخصوصية القوة", "simplex-unique-2-overlay-1-title": "أفضل حماية من رسائل الإزعاج وإساءة الاستخدام", - "hero-overlay-card-1-p-3": "أنت تحدد الخادم (الخوادم) المراد استخدامه لاستلام الرسائل وجهات الاتصال الخاصة بك — الخوادم التي تستخدمها لإرسال الرسائل إليهم. من المرجح أن تستخدم كل مُحادثة خادمين مختلفين.", + "hero-overlay-card-1-p-3": "أنت تحدد الخادم (الخوادم) المراد استخدامه لاستلام الرسائل وجهات اتصالك — الخوادم التي تستخدمها لإرسال الرسائل إليهم. من المرجح أن تستخدم كل مُحادثة خادمين مختلفين.", "hero-overlay-card-1-p-1": "سأل العديد من المستخدمين: إذا لم يكن لدى SimpleX معرّفات مستخدم، فكيف يمكنها معرفة مكان تسليم الرسائل؟ ", - "hero-overlay-card-1-p-2": "لتوصيل الرسائل، بدلاً من معرفات المستخدم التي تستخدمها جميع المنصات الأخرى، يستخدم SimpleX معرّفات مزدوجة مؤقتة مجهولة الهوية لقوائم انتظار الرسائل، مختلفة لكل اتصال من اتصالاتك — لا توجد معرفات مستخدم دائمة.", + "hero-overlay-card-1-p-2": "لتسليم الرسائل، بدلاً من معرّفات المُستخدم المُستخدمة من قِبل جميع الشبكات الأخرى، يستخدم SimpleX معرّفات زوجية مجهولة مؤقتة لقوائم الرسائل، منفصلة لكل اتصال من اتصالاتك — ولا توجد معرّفات طويلة الأجل.", "simplex-network-overlay-card-1-p-1": "بروتوكولات المُراسلة والتطبيقات P2P بها مشاكل مختلفة تجعلها أقل موثوقية من SimpleX وأكثر تعقيدًا في التحليل و عرضة لعدة أنواع من الهجمات.", - "hero-overlay-card-2-p-1": "عندما يكون لدى المستخدمين هويات ثابتة، حتى لو كان هذا مجرد رقم عشوائي، مثل معرف الجلسة، فهناك خطر يتمثل في أن الموفر أو المهاجم يمكنه مراقبة كيفية اتصال المستخدمين وعدد الرسائل التي يرسلونها.", + "hero-overlay-card-2-p-1": "عندما يكون لدى المستخدمين هويات ثابتة، حتى لو كان هذا مجرد رقم عشوائي، مثل معرّف الجلسة، فهناك خطر يتمثل في أن الموفر أو المهاجم يمكنه مراقبة كيفية اتصال المستخدمين وعدد الرسائل التي يرسلونها.", "hero-overlay-card-1-p-6": "اقرأ المزيد في SimpleX whitepaper .", "hero-overlay-card-2-p-3": "حتى مع معظم التطبيقات الخاصة التي تستخدم خدمات Tor v3، إذا تحدثت إلى جهتي اتصال مختلفتين عبر نفس الملف الشخصي، فيمكنهما إثبات أنهما متصلان بنفس الشخص.", "hero-overlay-card-2-p-4": "يحمي SimpleX من هذه الهجمات من خلال عدم وجود أي معرّفات مستخدم في تصميمه. وإذا كنت تستخدم وضع التخفي، فسيكون لديك اسم عرض مختلف لكل جهة اتصال، مع تجنب أي بيانات مشتركة بينهما.", @@ -112,23 +112,23 @@ "simplex-network-overlay-card-1-li-5": "قد تكون جميع شبكات P2P المعروفة عرضة لهجوم Sybil، لأن كل عقدة قابلة للاكتشاف، وتعمل الشبكة ككل. تتطلب الإجراءات المعروفة لتخفيفها إما مكونًا مركزيًا أو إثبات عمل مكلف . لا تحتوي شبكة SimpleX على إمكانية اكتشاف الخادم، فهي مجزأة وتعمل كشبكات فرعية متعددة ومعزولة، مما يجعل الهجمات على مستوى الشبكة مستحيلة.", "privacy-matters-overlay-card-3-p-1": "يجب على الجميع الاهتمام بخصوصية وأمان اتصالاتهم — يمكن للمُحادثات غير الضارة أن تعرضك للخطر، حتى لو لم يكن لديك ما تخفيه.", "privacy-matters-overlay-card-3-p-4": "لا يكفي استخدام برنامج مُراسلة مُعمَّاة بين الطرفين، يجب علينا جميعًا استخدام برامج مُراسلة التي تحمي خصوصية شبكاتنا الشخصية — مع من نحن مرتبطون.", - "simplex-unique-overlay-card-1-p-3": "يحمي هذا التصميم خصوصية الأشخاص الذين تتواصل معهم، ويخفيها عن خوادم منصة SimpleX ومن أي مراقبين. لإخفاء عنوان IP الخاص بك من الخوادم، يمكنك الاتصال بخوادم SimpleX عبر تور.", - "simplex-unique-overlay-card-2-p-1": "نظرًا لعدم وجود معرف لديك على نظام SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", + "simplex-unique-overlay-card-1-p-3": "يحمي هذا التصميم خصوصية الأشخاص الذين تتواصل معهم، ويخفيها عن خوادم شبكة SimpleX ومن أي مراقبين. لإخفاء عنوان IP الخاص بك من الخوادم، يمكنك الاتصال بخوادم SimpleX عبر تور.", + "simplex-unique-overlay-card-2-p-1": "نظرًا لعدم وجود معرّف لديك على شبكة SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", "simplex-unique-overlay-card-2-p-2": "حتى مع عنوان المستخدم الاختياري، بينما يمكن استخدامه لإرسال طلبات جهات اتصال مزعجة، يمكنك تغييره أو حذفه بالكامل دون فقد أي من اتصالاتك.", "simplex-unique-overlay-card-3-p-2": "يتم الاحتفاظ بالرسائل المُعمَّاة بين الطرفين مؤقتًا على خوادم ترحيل SimpleX حتى يتم استلامها، ثُمَّ تُحذف نهائيًا.", - "simplex-unique-overlay-card-3-p-4": "لا توجد معرفّات أو نص مُعَمَّى مشترك بين حركة مرور بيانات الخادم المُرسلة والمُستلمة — ؛ إذا كان أي شخص يراقب ذلك، فلن يتمكّن بسهولة من تحديد من يتواصل مع من، حتى لو اختُرق TLS.", - "simplex-unique-card-1-p-1": "يحمي SimpleX خصوصية ملف التعريف الخاص بك، جهات الاتصال والبيانات الوصفية، ويخفيه عن خوادم منصة SimpleX وأي مراقبين.", + "simplex-unique-overlay-card-3-p-4": "لا توجد معرفّات أو نص مُعَمَّى مشترك بين حركة مرور بيانات الخادم المُرسلة والمُستلمة — إذا كان أي شخص يراقب ذلك، فلن يتمكّن بسهولة من تحديد من يتواصل مع من، حتى لو اختُرق TLS.", + "simplex-unique-card-1-p-1": "يحمي SimpleX خصوصية ملف تعريفك، جهات اتصالك والبيانات الوصفية، ويخفيه عن خوادم شبكة SimpleX وأي مراقبين.", "privacy-matters-overlay-card-2-p-1": "منذ وقت ليس ببعيد، لاحظنا أن الانتخابات الرئيسية يتم التلاعب بها بواسطة شركة استشارية ذات سمعة طيبة التي استخدمت الرسوم البيانية الاجتماعية لتشويه نظرتنا للعالم الحقيقي والتلاعب بأصواتنا.", - "privacy-matters-overlay-card-2-p-2": "لكي تكون موضوعيًا وتتخذ قرارات مستقلة، يجب أن تكون متحكمًا في مساحة المعلومات الخاصة بك. هذا ممكن فقط إذا كنت تستخدم منصة اتصالات خاصة لا يمكنها الوصول إلى الرسم البياني الاجتماعي الخاص بك.", - "privacy-matters-overlay-card-2-p-3": "SimpleX هو النظام الأساسي الأول الذي لا يحتوي على أي معرّفات مستخدم صمّم ليكون خاصًا، وبهذه الطريقة تحمي مخطط اتصالاتك بشكل أفضل من أي بديل معروف.", + "privacy-matters-overlay-card-2-p-2": "لكي تكون موضوعيًا وتتخذ قرارات مستقلة، عليك التحكم في مساحة معلوماتك. لا يمكن تحقيق ذلك إلا باستخدام شبكة اتصال خاصة لا يمكنها الوصول إلى حسابك على مواقع التواصل الاجتماعي.", + "privacy-matters-overlay-card-2-p-3": "SimpleX هي أول شبكة لا تحتوي على أي معرّفات مستخدم من حيث التصميم، وبهذه الطريقة تحمي مخطط اتصالاتك بشكل أفضل من أي بديل معروف.", "privacy-matters-overlay-card-3-p-2": "واحدة من أكثر القصص إثارة للصدمة هي تجربة محمدو ولد صلاحي الموصوفة في مذكراته والموضحة في فيلم موريتاني. تم وضعه في معتقل غوانتانامو بدون محاكمة، وتعرض للتعذيب هناك لمدة 15 عامًا بعد مكالمة هاتفية مع قريبه في أفغانستان، للاشتباه في تورطه في هجمات 11 سبتمبر، على الرغم من أنه عاش في ألمانيا طوال السنوات العشر الماضية.", "privacy-matters-overlay-card-3-p-3": "يتم القبض على الأشخاص العاديين بسبب ما يشاركونه عبر الإنترنت، حتى عبر حساباتهم \"المجهولة\"، وحتى في البلدان الديمقراطية.", - "simplex-unique-overlay-card-1-p-1": "على عكس أنظمة المُراسلة الأخرى، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين. لا يعتمد على أرقام الهواتف أو العناوين المستندة إلى النطاقات (مثل البريد الإلكتروني أو XMPP)، أسماء المستخدمين، المفاتيح العامة أو حتى الأرقام العشوائية لتحديد مستخدميها — لا نعرف عدد الأشخاص الذين يستخدمون خوادم SimpleX الخاصة بنا.", - "simplex-unique-overlay-card-1-p-2": "لتسليم الرسائل، يستخدم SimpleX العناوين المزدوجة المجهولة لقوائم انتظار الرسائل أحادية الاتجاه، منفصلة عن الرسائل المُستلمة والمُرسلة، عادةً عبر خوادم مختلفة. إن استخدام SimpleX يشبه امتلاك بريد إلكتروني أو هاتف “مؤقت” مختلف لكل جهة اتصال، ولا توجد متاعب في إدارتها.", + "simplex-unique-overlay-card-1-p-1": "على عكس شبكات المُراسلة الأخرى، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين. لا يعتمد على أرقام الهواتف أو العناوين المستندة إلى النطاقات (مثل البريد الإلكتروني أو XMPP)، أسماء المستخدمين، المفاتيح العامة أو حتى الأرقام العشوائية لتحديد مستخدميها — مُشغلي خادم SimpleX لا يعرفون عدد الأشخاص الذين يستخدمون خوادمهم.", + "simplex-unique-overlay-card-1-p-2": "لتسليم الرسائل، يستخدم SimpleX العناوين المزدوجة المجهولة لقوائم انتظار الرسائل أحادية الاتجاه، منفصلة عن الرسائل المُستلمة والمُرسلة، عادةً عبر خوادم مختلفة.", "simplex-unique-overlay-card-3-p-1": "يخزن SimpleX Chat جميع بيانات المستخدم على أجهزة العميل فقط باستخدام تنسيق قاعدة بيانات محمولة مُعمَّاة يمكّن تصديرها ونقلها إلى أي جهاز مدعوم.", "simplex-unique-overlay-card-3-p-3": "على عكس خوادم الشبكات الاتحادية (البريد الإلكتروني أو XMPP أو Matrix)، لا تقوم خوادم SimpleX بتخزين حسابات المستخدمين، فهي تقوم فقط بترحيل الرسائل، مما يحمي خصوصية كلا الطرفين.", - "simplex-unique-overlay-card-4-p-1": "يمكنك استخدام SimpleX مع الخوادم الخاصة بك والاستمرار في التواصل مع الأشخاص الذين يستخدمون الخوادم المهيأة مسبقًا التي نقدمها.", - "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير لمنصة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", + "simplex-unique-overlay-card-4-p-1": "يمكنك استخدام SimpleX مع خوادمك والاستمرار في التواصل مع الأشخاص الذين يستخدمون الخوادم المُهيأة مسبقًا في التطبيقات.", + "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير شبكة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", "donate-here-to-help-us": "تبرّع هنا لمساعدتنا", "sign-up-to-receive-our-updates": "اشترك للحصول على آخر مستجداتنا", "enter-your-email-address": "أدخل عنوان بريدك الإلكتروني", @@ -152,16 +152,16 @@ "no-decentralized": "لا - لامركزي", "no-federated": "لا - اتِحاديّ", "comparison-section-list-point-2": "العناوين تعتمد على الـDNS", - "comparison-section-list-point-3": "المفتاح العام أو معرف آخر فريد وعام", + "comparison-section-list-point-3": "المفتاح العام أو معرّف آخر فريد وعام", "comparison-section-list-point-7": "شبكات P2P إما لديها سلطة مركزية أو أن الشبكة كلها يمكن عرضة للخطر", "see-here": "اقرأ هنا", "no-secure": "لا - آمن", "comparison-section-list-point-5": "لا يحمي خصوصية البيانات الوصفية للمستخدمين", - "comparison-section-list-point-6": "على الرغم من أن الـP2P موزعة، إلا أنها ليست اتِحاديَّة - يعملون كشبكة واحدة", + "comparison-section-list-point-6": "على الرغم من أن الـP2P موزعة، إلا أنها ليست اتِحاديَّة — يعملون كشبكة واحدة", "comparison-section-list-point-1": "عادة ما يكون مكوناً من رقم الهاتف، أو اسم المستخدم في بعض الأحيان", "comparison-section-list-point-4": "إذا خوادم المشغّل مُخترقة. تحقق من رمز الأمان في Signal وبعض التطبيقات الأخرى للتخفيف منه", "simplex-unique-card-3-p-1": "يخزن SimpleX جميع بيانات المستخدم على الأجهزة العميلة بتنسيق قاعدة بيانات محمولة مُعمَّاة — يمكّن نقلها إلى جهاز آخر.", - "simplex-unique-card-4-p-1": "شبكة SimpleX لا مركزية بالكامل ومستقلة عن أي عملة مُعمَّاة أو أي منصة أخرى، بخلاف الإنترنت.", + "simplex-unique-card-4-p-1": "شبكة SimpleX لا مركزية بالكامل ومستقلة عن أي عملة مُعمَّاة أو أي شبكة أخرى، بخلاف الإنترنت.", "simplex-unique-card-4-p-2": "يمكنك استخدام SimpleX مع خوادمك الخاصة أو مع الخوادم التي نوفرها — ولا يزال الاتصال ممكن بأي مستخدم.", "join": "انضم إلى", "we-invite-you-to-join-the-conversation": "نحن ندعوك للانضمام إلى المُحادثة", @@ -178,7 +178,7 @@ "use-this-command": "استخدم هذا الأمر:", "see-simplex-chat": "انظر SimpleX Chat", "github-repository": "مستودع Github", - "the-instructions--source-code": "التعليمات حول كيفية تنزيله أو تجميعه من التعليمات البرمجية المصدر.", + "the-instructions--source-code": "للتعليمات حول كيفية تنزيله أو تجميعه من التعليمات البرمجية المصدر.", "if-you-already-installed-simplex-chat-for-the-terminal": "إذا قمت بالفعل بتثبيت SimpleX Chat للوحدة الطرفية", "simplex-chat-for-the-terminal": "SimpleX Chat للوحدة الطرفية", "privacy-matters-section-header": "لماذا الخصوصية مهمة", @@ -189,8 +189,8 @@ "simplex-network-3-header": "شبكة SimpleX", "protocol-1-text": "Signal، منصات كبيرة", "protocol-2-text": "XMPP ،Matrix", - "simplex-unique-card-1-p-2": "بخلاف أي نظام مُراسلة آخر، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين — ولا حتى أرقام عشوائية.", - "simplex-unique-card-2-p-1": "نظرًا لعدم وجود معرف أو عنوان ثابت على منصة SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", + "simplex-unique-card-1-p-2": "على عكس أي شبكة مُراسلة آخر، لا يحتوي SimpleX على معرّفات مخصّصة للمستخدمين — ولا حتى أرقام عشوائية.", + "simplex-unique-card-2-p-1": "نظرًا لعدم وجود معرّف أو عنوان ثابت على شبكة SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", "simplex-unique-card-3-p-2": "يتم الاحتفاظ بالرسائل المُعمَّاة بين الطرفين مؤقتًا على خوادم ترحيل SimpleX حتى يتم استلامها، ثُمَّ تُحذف نهائيًا.", "tap-the-connect-button-in-the-app": "اضغط على زر \"اتصال\" في التطبيق", "scan-the-qr-code-with-the-simplex-chat-app": "امسح رمز QR باستخدام تطبيق SimpleX Chat", @@ -208,7 +208,7 @@ "simplex-network-2-desc": "لا تقوم خوادم الترحيل SimpleX بتخزين ملفات تعريف المستخدمين وجهات الاتصال والرسائل التي تم تسليمها، ولا تتصل ببعضها البعض، ولا يوجد دليل خوادم.", "comparison-point-1-text": "يتطلب هوية عالمية", "protocol-3-text": "بروتوكولات P2P", - "simplex-unique-overlay-card-4-p-2": "تستخدم منصة SimpleX بروتوكول مفتوح وتوفر SDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي يمكنك إنشاؤها.", + "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة SimpleX بروتوكول مفتوح وتوفر SDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", "guide-dropdown-1": "بداية سريعة", "guide-dropdown-2": "إرسال الرسائل", "guide-dropdown-3": "مجموعات سرية", @@ -227,7 +227,7 @@ "menu": "قائمة", "on-this-page": "على هذه الصفحة", "back-to-top": "عد إلى الأعلى", - "docs-dropdown-1": "منصة SimpleX", + "docs-dropdown-1": "شبكة SimpleX", "guide": "الدليل", "docs-dropdown-2": "الوصول إلى ملفات اندرويد", "docs-dropdown-3": "الوصول إلى قاعدة بيانات الدردشة", @@ -244,7 +244,7 @@ "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يُرجى تصدير قاعدة بيانات الدردشة وإعادة تثبيت التطبيق.", "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تعمية بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق", "hero-overlay-3-title": "التقييمات الأمنية", - "hero-overlay-card-3-p-2": "قامت Trail of Bits بمراجعة مكونات التشفير والشبكات الخاصة بمنصة SimpleX في نوفمبر 2022. اقرأ المزيد في الإعلان.", + "hero-overlay-card-3-p-2": "قامت Trail of Bits بمراجعة مكونات التشفير والشبكات الخاصة بشبكة SimpleX في نوفمبر 2022. اقرأ المزيد في الإعلان.", "jobs": "انضم للفريق", "hero-overlay-3-textlink": "التقييمات الأمنية", "hero-overlay-card-3-p-1": "Trail of Bits هي شركة رائدة في مجال الاستشارات الأمنية والتكنولوجية، ومن بين عملائها شركات التكنولوجيا الكبرى والوكالات الحكومية ومشاريع blockchain الكبرى.", diff --git a/website/langs/de.json b/website/langs/de.json index f2ec261ab7..3e5ef9fbc4 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -15,7 +15,7 @@ "simplex-explained-tab-1-p-1": "Sie können Kontakte und Gruppen erstellen und haben Zwei-Wege-Kommunikation wie in jedem anderen Messenger.", "simplex-explained-tab-1-p-2": "Wie funktioniert das mit den unidirektionalen Warteschlangen und ohne Profilkennungen?", "simplex-explained-tab-2-p-1": "Für jede Verbindung nutzen Sie zwei separate Nachrichten-Warteschlangen, um die Nachrichten über verschiedene Server zu senden und zu empfangen.", - "simplex-explained-tab-2-p-2": "Die Server leiten Nachrichten immer nur in eine Richtung weiter, ohne den vollständigen Verlauf der Nutzer-Unterhaltung oder seiner Verbindungen zu kennen.", + "simplex-explained-tab-2-p-2": "Die Server leiten Nachrichten immer nur in eine Richtung weiter, ohne den vollständigen Verlauf der Nutzer-Unterhaltungen oder seiner Verbindungen zu kennen.", "simplex-explained-tab-3-p-1": "Die Server nutzen für jede Warteschlange separate, anonyme Anmeldeinformationen und wissen nicht welchem Nutzer diese gehören.", "simplex-explained-tab-3-p-2": "Durch die Verwendung von Tor-Zugangsservern können Nutzer ihre Metadaten-Privatsphäre weiter verbessern und Korellationen von IP-Adressen verhindern.", "smp-protocol": "SMP-Protokoll", @@ -64,7 +64,7 @@ "simplex-private-card-4-point-2": "Um SimpleX per Tor zu nutzen, installieren Sie unter Android bitte die Orbot-App und aktivieren Sie den SOCKS5-Proxy oder unter iOS per VPN.", "simplex-private-card-5-point-1": "SimpleX nutzt Inhalte-Auffüllung für jede Verschlüsselungs-Schicht, um Angriffe auf die Nachrichtengröße zu vereiteln.", "simplex-private-card-5-point-2": "Erzeugt Nachrichten mit unterschiedlichen Größen, die für Server und Netzwerk-Beobachter identisch aussehen.", - "simplex-private-card-6-point-1": "Viele Kommunikations-Plattformen sind für MITM-Angriffe durch Server oder Netzwerk-Anbieter anfällig.", + "simplex-private-card-6-point-1": "Viele Kommunikations-Netzwerke sind für MITM-Angriffe durch Server oder Netzwerk-Anbieter anfällig.", "simplex-private-card-9-point-1": "Jede Nachrichten-Warteschlange leitet Nachrichten mit unterschiedlichen Sende- und Empfängeradressen jeweils nur in einer Richtung weiter.", "simplex-private-card-9-point-2": "Verglichen mit traditionellen Nachrichten-Brokern, werden mögliche Angriffs-Vektoren und vorhandene Metadaten reduziert.", "simplex-private-card-10-point-1": "SimpleX nutzt für jeden Nutzer-Kontakt oder jedes Gruppenmitglied eigene temporäre, anonyme und paarweise Adressen und Berechtigungsnachweise.", @@ -95,29 +95,29 @@ "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX-Whitepaper.", "hero-overlay-card-2-p-2": "Sie können diese Informationen mit bestehenden öffentlichen sozialen Netzwerken korrelieren und damit wahre Identitäten herausfinden.", "hero-overlay-card-2-p-3": "Wenn Sie sich mit zwei unterschiedlichen Kontakten über dasselbe Profil unterhalten, können sie, selbst bei sehr auf Privatsphäre bedachten Apps, die Tor-v3-Dienste nutzen, feststellen, dass diese Kontakte mit derselben Person verbunden sind.", - "hero-overlay-card-1-p-2": "Um Nachrichten auszuliefern, nutzt SimpleX statt Benutzerkennungen wie auf allen anderen Plattformen temporäre, anonyme und paarweise Kennungen für Nachrichten-Warteschlangen, die für jede Ihrer Verbindungen unterschiedlich sind — Es gibt keinerlei Langzeit-Kennungen.", + "hero-overlay-card-1-p-2": "Um Nachrichten auszuliefern, nutzt SimpleX statt Benutzerkennungen wie alle anderen Netzwerke nur temporäre, anonyme und paarweise Kennungen für Nachrichten-Warteschlangen, die für jede Ihrer Verbindungen unterschiedlich sind — es gibt keinerlei Langzeit-Kennungen.", "hero-overlay-card-1-p-4": "Dieses Design verhindert schon auf der Applikations-Ebene Datenlecks für jegliche Benutzer'Metadaten. Sie können sich über Tor mit Nachrichten-Servern verbinden, um Ihre Privatsphäre weiter zu verbessern und die von Ihnen genutzte IP-Adresse zu schützen.", "hero-overlay-card-2-p-1": "Wenn Nutzer dauerhafte Identitäten besitzen, selbst wenn diese eine Zufallsnummer, wie eine Sitzungs-ID, ist, besteht ein Risiko, das Provider oder Angreifer feststellen können, wie Nutzer miteinander verbunden sind und wie viele Nachrichten sie versenden.", "hero-overlay-card-2-p-4": "SimpleX schützt gegen solche Angriffe, weil es vom Design her keinerlei Benutzerkennungen besitzt. Und Sie haben sogar unterschiedliche Anzeigenamen für jeden Kontakt und vermeiden jegliche geteilte Daten zwischen diesen, wenn Sie den Inkognito-Modus nutzen.", "simplex-network-overlay-card-1-p-1": "Peer-to-Peer-Nachrichten-Protokolle und -Applikationen haben verschiedene Probleme, die diese weniger vertrauenswürdig, die Analyse wesentlich komplexer und anfälliger gegen verschiedene Arten von Angriffen, als bei SimpleX machen.", "simplex-network-overlay-card-1-li-2": "Das SimpleX Design hat, im Gegensatz zu den meisten P2P-Netzwerken, keinerlei globalen Benutzerkennungen, auch keine temporären. Es nutzt ausschließlich temporäre paarweise Kennungen, die bessere Anonymität und Metadaten-Schutz bieten.", - "simplex-network-overlay-card-1-li-4": "P2P-Implementierungen können durch Internetanbieter blockiert werden, wie beispielweise BitTorrent). SimpleX ist transportunabhängig – es kann über Standard-Web-Protokolle, wie beispielsweise WebSockets, arbeiten.", + "simplex-network-overlay-card-1-li-4": "——P2P-Implementierungen können durch Internetanbieter blockiert werden, wie beispielweise BitTorrent). SimpleX ist transportunabhängig — es kann über Standard-Web-Protokolle, wie beispielsweise WebSockets, arbeiten.", "simplex-network-overlay-card-1-li-6": "P2P-Netzwerke können anfällig für DRDoS-Angriffe sein, wenn die Clients den Datenverkehr erneut senden und verstärken können, was zu einem netzwerkweiten Denial-of-Service führt. SimpleX-Clients leiten nur Datenverkehr von bekannten Verbindungen weiter und können von einem Angreifer nicht dazu verwendet werden, den Datenverkehr im gesamten Netzwerk zu verstärken.", "privacy-matters-overlay-card-1-p-1": "Viele große Unternehmen nutzen Informationen, mit wem Sie in Verbindung stehen, um Ihr Einkommen zu schätzen, Ihnen Produkte zu verkaufen, die Sie nicht wirklich benötigen und um die Preise zu bestimmen.", "privacy-matters-overlay-card-1-p-2": "Online-Händler wissen, dass Menschen mit geringerem Einkommen eher dringende Einkäufe tätigen, sodass sie möglicherweise höhere Preise verlangen können oder Rabatte streichen.", "privacy-matters-overlay-card-1-p-3": "Einige Finanz- und Versicherungsunternehmen verwenden soziale Graphen, um Zinssätze und Prämien zu ermitteln. Menschen mit niedrigerem Einkommen zahlen so häufig mehr — dies ist als \"Armutsprämie\" bekannt.", - "privacy-matters-overlay-card-2-p-2": "Um objektiv zu sein und unabhängige Entscheidungen treffen zu können, müssen Sie die Kontrolle über Ihren Informationsraum haben. Dies ist nur möglich, wenn Sie eine private Kommunikationsplattform verwenden, die keinen Zugriff auf Ihren sozialen Graphen hat.", - "privacy-matters-overlay-card-2-p-3": "SimpleX ist die erste Plattform, die per Design keinerlei Benutzerkennungen hat und auf diese Weise Ihren Verbindungsgraphen besser schützt als jede andere bekannte Alternative.", + "privacy-matters-overlay-card-2-p-2": "Um objektiv zu sein und unabhängige Entscheidungen treffen zu können, müssen Sie die Kontrolle über Ihren Informationsraum haben. Dies ist nur möglich, wenn Sie ein privates Kommunikations-Netzwerk verwenden, welches keinen Zugriff auf Ihren sozialen Graphen hat.", + "privacy-matters-overlay-card-2-p-3": "SimpleX ist das erste Netzwerk, welches per Design keinerlei Benutzerkennungen hat und auf diese Weise Ihren Verbindungsgraphen besser schützt als jede andere bekannte Alternative.", "privacy-matters-overlay-card-3-p-1": "Jede Person sollte sich um ihre Privatsphäre und die Sicherheit ihrer Kommunikation kümmern — Harmlose Gespräche könnten Sie in Gefahr bringen, selbst wenn Sie nichts zu verbergen haben.", "privacy-matters-overlay-card-3-p-4": "Es reicht nicht aus, einfach einen Ende-zu-Ende-verschlüsselten Messenger zu verwenden. Wir alle sollten den Messenger verwenden, der die Privatsphäre unserer persönlichen Netzwerke schützt, mit welchen wir verbunden sind.", - "simplex-unique-overlay-card-1-p-3": "Dieses Design schützt die Privatsphäre von Ihnen und der Personen, mit denen Sie kommunizieren, und verbirgt die Verbindungen vor den SimpleX-Plattform-Servern und möglichen Beobachtern. Um Ihre IP-Adresse vor den Servern zu verbergen, können Sie sich per Tor mit den SimpleX-Servern verbinden.", - "simplex-unique-overlay-card-2-p-1": "Da Sie auf der SimpleX-Plattform keine Kennungen haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder eines Links weiter.", + "simplex-unique-overlay-card-1-p-3": "Dieses Design schützt die Privatsphäre von Ihnen und der Nutzer, mit denen Sie kommunizieren, und verbirgt die Verbindungen vor den SimpleX-Netzwerk-Servern und möglichen Beobachtern. Um Ihre IP-Adresse vor den Servern zu verbergen, können Sie sich per Tor mit den SimpleX-Servern verbinden.", + "simplex-unique-overlay-card-2-p-1": "Da Sie im SimpleX-Netzwerk keine Kennungen haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder eines Links weiter.", "simplex-unique-overlay-card-3-p-2": "Die Ende-zu-Ende-verschlüsselten Nachrichten werden vorübergehend auf SimpleX-Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", "simplex-unique-overlay-card-3-p-3": "Im Gegensatz zu föderierten Netzwerkservern (wie z.B. Mail, XMPP oder Matrix) speichern die SimpleX-Server keine Benutzerkonten, sondern leiten Nachrichten nur weiter, so dass die Privatsphäre der beteiligten Parteien geschützt ist.", - "simplex-unique-overlay-card-4-p-1": "Sie können SimpleX mit Ihren eigenen Servern verwenden und trotzdem mit Personen kommunizieren, welche die von uns bereitgestellten und vorkonfigurierten Server verwenden.", - "simplex-unique-card-1-p-1": "SimpleX schützt die Privatsphäre Ihres Profils, Ihrer Kontakte und Metadaten und verbirgt sie vor den SimpleX-Plattform-Servern und allen Beobachtern.", - "simplex-unique-card-1-p-2": "Im Gegensatz zu allen anderen bestehenden Messaging-Plattformen werden den Nutzern von SimpleX keine Kennungen zugewiesen — nicht einmal Zufallszahlen.", - "simplex-unique-card-2-p-1": "Da Sie keine Kennung oder feste Adresse auf der SimpleX-Plattform haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder Links weiter.", + "simplex-unique-overlay-card-4-p-1": "Sie können SimpleX mit Ihren eigenen Servern verwenden und trotzdem mit Nutzern kommunizieren, welche die vorkonfigurierten Server der App verwenden.", + "simplex-unique-card-1-p-1": "SimpleX schützt die Privatsphäre Ihres Profils, Ihrer Kontakte und Metadaten und verbirgt sie vor den SimpleX-Netzwerk-Servern und weiteren möglichen Beobachtern.", + "simplex-unique-card-1-p-2": "Im Gegensatz zu allen anderen bestehenden Messaging-Netzwerken werden den Nutzern von SimpleX keine Kennungen zugewiesen — nicht einmal Zufallszahlen.", + "simplex-unique-card-2-p-1": "Da Sie keine Kennung oder feste Adresse im SimpleX-Netzwerk haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder Links weiter.", "simplex-unique-card-3-p-1": "SimpleX speichert Benutzerdaten nur auf den Endgeräten und das in einem portablen, verschlüsselten Datenbankformat — welches auf ein anderes Gerät übertragen werden kann.", "simplex-unique-card-3-p-2": "Die Ende-zu-Ende-verschlüsselten Nachrichten werden vorübergehend auf SimpleX-Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", "join": "Nutzen Sie", @@ -142,7 +142,7 @@ "use-this-command": "Benutzen Sie dieses Kommando:", "see-simplex-chat": "Siehe SimpleX-Chat", "github-repository": "GitHub-Repository", - "the-instructions--source-code": "Die Anleitung, wie Sie es herunterladen und aus dem Quellcode kompilieren.", + "the-instructions--source-code": "Für die Anleitungen, wie Sie es herunterladen oder aus dem Quellcode kompilieren.", "if-you-already-installed": "Wenn Sie es schon installiert haben", "simplex-chat-for-the-terminal": "SimpleX Chat für das Terminal", "privacy-matters-section-header": "Warum es auf Privatsphäre ankommt", @@ -171,13 +171,13 @@ "comparison-section-list-point-2": "DNS-basierte Adressen", "comparison-section-list-point-3": "Öffentlicher Schlüssel oder eine andere weltweit eindeutige ID", "comparison-section-list-point-4": "Wenn die Server des Betreibers kompromittiert werden. In Signal und weiteren Apps kann der Sicherheitscode überprüft werden, um dies zu entschärfen", - "comparison-section-list-point-6": "P2P sind zwar verteilt, aber nicht föderiert - sie arbeiten als ein einziges Netzwerk", + "comparison-section-list-point-6": "P2P sind zwar verteilt, aber nicht föderiert — sie arbeiten als ein einziges Netzwerk", "comparison-section-list-point-7": "P2P-Netzwerke haben entweder eine zentrale Verwaltung oder das gesamte Netzwerk kann kompromittiert werden", "see-here": "Siehe hier", - "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Plattformen außer dem Internet.", + "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Netzwerken außer dem Internet.", "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", - "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Plattformen weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder sogar Zufallszahlen, um seine Benutzer zu identifizieren — Wir wissen nicht, wie viele Personen unsere SimpleX-Server verwenden.", + "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Netzwerken weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder Zufallszahlen, um seine Benutzer zu identifizieren — selbst SimpleX-Server-Betreiber wissen nicht, wie viele Personen deren Server verwenden.", "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden.", "simplex-network-overlay-card-1-li-5": "Alle bekannten P2P-Netzwerke können anfällig für Sybil-Angriffe sein, da jeder Knoten ermittelbar ist und das Netzwerk als Ganzes funktioniert. Bekannte Maßnahmen zur Verhinderung erfordern entweder eine zentralisierte Komponente oder einen teuren Ausführungsnachweis. Das SimpleX-Netzwerk bietet keine Ermittlung der Server, ist fragmentiert und arbeitet mit mehreren isolierten Subnetzwerken, wodurch netzwerkweite Angriffe unmöglich werden.", "simplex-network-overlay-card-1-li-3": "P2P löst nicht das Problem des MITM-Angriffs und die meisten bestehenden Implementierungen nutzen für den initialen Schlüsselaustausch keine Out-of-Band-Nachrichten. Im Gegensatz hierzu nutzt SimpleX für den initialen Schlüsselaustausch Out-of-Band-Nachrichten oder zum Teil schon bestehende sichere und vertrauenswürdige Verbindungen.", @@ -196,14 +196,14 @@ "comparison-section-list-point-5": "Die Privatsphäre-Metadaten des Nutzers werden nicht geschützt", "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von DHT, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über O(log N)-Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", - "simplex-unique-overlay-card-4-p-3": "Wenn Sie darüber nachdenken, für die SimpleX-Plattform entwickeln zu wollen, z.B. einen Chatbot für SimpleX-App-Nutzer oder die Integration der SimpleX-Chat-Bibliothek in Ihre mobilen Apps, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", - "privacy-matters-overlay-card-1-p-4": "Die SimpleX-Plattform schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn die Anwender SimpleX-Chat-Server verwenden, kennen wir die Anzahl der Benutzer oder ihre Verbindungen nicht.", + "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX-App-Nutzer zu entwickeln oder die Integration der SimpleX-Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", + "privacy-matters-overlay-card-1-p-4": "Das SimpleX-Netzwerk schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn Anwender die in der SimpleX-Chat-App vorkonfigurierten Server verwenden, kennen die Server-Betreiber die Anzahl der Benutzer oder deren Verbindungen nicht.", "contact-hero-header": "Sie haben eine Adresse zur Verbindung mit SimpleX Chat erhalten", "invitation-hero-header": "Sie haben einen Einmal-Link zur Verbindung mit SimpleX Chat erhalten", "privacy-matters-overlay-card-3-p-3": "Normale Menschen werden für das, was sie online teilen, sogar unter Nutzung ihrer „anonymen“ Konten, selbst in demokratischen Ländern verhaftet.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat speichert alle Benutzerdaten ausschließlich auf den Endgeräten in einem portablen und verschlüsselten Datenbankformat, welches exportiert und auf jedes unterstützte Gerät übertragen werden kann.", "simplex-unique-overlay-card-2-p-2": "Auch wenn die optionale Benutzeradresse zum Versenden von Spam-Kontaktanfragen verwendet werden kann, können Sie sie ändern oder ganz löschen, ohne dass Ihre Verbindungen verloren gehen.", - "simplex-unique-overlay-card-4-p-2": "Die SimpleX-Plattform verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an, welches die Erstellung von Diensten ermöglicht, mit denen Nutzer über SimpleX-Chat interagieren können — Wir sind gespannt, welche SimpleX-Dienste Sie erstellen können.", + "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX-Chat-Apps interagieren können — wir sind gespannt, welche SimpleX-Dienste Sie entwickeln werden.", "simplex-unique-card-4-p-2": "Sie können SimpleX mit Ihren eigenen Servern oder mit den von uns zur Verfügung gestellten Servern verwenden — und sich trotzdem mit jedem Benutzer verbinden.", "why-simplex-is-unique": "Warum ist SimpleX einmalig", "contact-hero-p-1": "Die öffentlichen Schlüssel und die Adresse der Nachrichtenwarteschlange in diesem Link werden NICHT über das Netzwerk gesendet, wenn Sie diese Seite aufrufen — sie sind in dem Hash-Fragment der Link-URL enthalten.", @@ -218,7 +218,7 @@ "guide-dropdown-8": "App-Einstellungen", "guide-dropdown-9": "Verbindungen herstellen", "guide": "Leitfaden", - "docs-dropdown-1": "SimpleX-Plattform", + "docs-dropdown-1": "SimpleX-Netzwerk", "docs-dropdown-2": "Zugriff auf Android-Dateien", "docs-dropdown-3": "Zugriff auf die Chat-Datenbank", "docs-dropdown-4": "Den SMP-Server hosten", diff --git a/website/langs/hu.json b/website/langs/hu.json index f502291e52..f062899224 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -14,7 +14,7 @@ "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetváltó-alkalmazásban.", "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú üzenet-sorballítással és felhasználói profilazonosítók nélkül?", "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 egyirányú üzeneteket továbbítanak, anélkül, hogy teljes képet kapnának a felhasználók beszélgetéseiről vagy kapcsolatairól.", + "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.", "smp-protocol": "SMP-protokoll", @@ -60,7 +60,7 @@ "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-6-point-1": "Számos kommunikációs platform sebezhető a kiszolgálók vagy a hálózat-szolgáltatók MITM-támadásaival szemben.", + "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.", @@ -86,7 +86,7 @@ "simplex-unique-4-title": "Öné a SimpleX-hálózat", "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 platform á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-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-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.", @@ -104,32 +104,32 @@ "privacy-matters-overlay-card-1-p-1": "Sok nagyvállalat arra használja fel az önnel kapcsolatban álló személyek adatait, hogy megbecsülje az ön jövedelmét, hogy olyan termékeket adjon el önnek, amelyekre valójában nincs is szüksége, és hogy meghatározza az árakat.", "privacy-matters-overlay-card-1-p-2": "Az online kiskereskedők tudják, hogy az alacsonyabb jövedelműek nagyobb valószínűséggel vásárolnak azonnal, ezért magasabb árakat számíthatnak fel, vagy eltörölhetik a kedvezményeket.", "privacy-matters-overlay-card-1-p-3": "Egyes pénzügyi és biztosítótársaságok szociális grafikonokat használnak a kamatlábak és a díjak meghatározásához. Ez gyakran arra készteti az alacsonyabb jövedelmű embereket, hogy többet fizessenek — ez az úgynevezett „szegénységi prémium”.", - "privacy-matters-overlay-card-1-p-4": "A SimpleX-platform minden alternatívánál jobban védi a kapcsolatainak adatait, teljes mértékben megakadályozva, hogy a ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által biztosított kiszolgálókat is használják, sem a felhasználók számát, sem a kapcsolataikat nem ismerjük.", + "privacy-matters-overlay-card-1-p-4": "A SimpleX-hálózat minden alternatívánál jobban védi a kapcsolatainak adatait, teljes mértékben megakadályozva, hogy az ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által előre beállított kiszolgálókat is használják, sem az alkalmazások, sem a kiszolgálók üzemeltetői nem ismerik, sem felhasználók számát, sem a kapcsolataikat.", "privacy-matters-overlay-card-2-p-1": "Nem is olyan régen megfigyelhettük, hogy a nagy választásokat manipulálta egy neves tanácsadó cég, amely az ismeretségi-háló segítségével eltorzította a valós világról alkotott képünket, és manipulálta a szavazatainkat.", - "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha privát kommunikációs platformot használ, amely nem fér hozzá az ismeretségi-hálójához.", - "privacy-matters-overlay-card-2-p-3": "A SimpleX az első olyan platform, amely eleve nem rendelkezik felhasználói azonosítókkal, így jobban védi az ismeretségi-hálóját, mint bármely ismert alternatíva.", + "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha privát kommunikációs hálózatot használ, amely nem fér hozzá az ismeretségi-hálójához.", + "privacy-matters-overlay-card-2-p-3": "A SimpleX az első olyan hálózat, amely eleve nem rendelkezik felhasználói azonosítókkal, így jobban védi az ismeretségi-hálóját, mint bármely ismert alternatíva.", "privacy-matters-overlay-card-3-p-1": "Mindenkinek törődnie kell a magánélet és a kommunikáció biztonságával — az ártalmatlan beszélgetések veszélybe sodorhatják, még akkor is, ha nincs semmi rejtegetnivalója.", "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", "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ő platformoktó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 — nem tudjuk, hogy hányan használják a SimpleX-kiszolgálóinkat.", + "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-platform 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-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, 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-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-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.", "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", - "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az általunk biztosított, előre konfigurált kiszolgálókat használják.", - "simplex-unique-overlay-card-4-p-2": "A SimpleX-platform nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", - "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX-platformra való fejlesztést fontolgatja, például a SimpleX-alkalmazások felhasználóinak szánt chatbotot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", - "simplex-unique-card-1-p-1": "A SimpleX védi az ön profiljához tartozó kapcsolatokat és metaadatokat, elrejtve azokat a SimpleX-platform kiszolgálói és a megfigyelők elől.", - "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő platformtól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", - "simplex-unique-card-2-p-1": "Mivel a SimpleX-platformon nincs azonosítója vagy állandó címe, 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-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az előre beállított kiszolgálókat használják az alkalmazásban.", + "simplex-unique-overlay-card-4-p-2": "A SimpleX-hálózat nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", + "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX-hálózatra való fejlesztést fontolgatja, például a SimpleX-alkalmazások felhasználóinak szánt chatbotot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", + "simplex-unique-card-1-p-1": "A SimpleX megvédi a profilhoz tartozó kapcsolatokat és metaadatokat, elrejtve azokat a SimpleX-hálózat kiszolgálói és a megfigyelők elől.", + "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő hálózattól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", + "simplex-unique-card-2-p-1": "Mivel a SimpleX-hálózaton nincs azonosítója vagy állandó címe, 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-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.", "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", - "simplex-unique-card-4-p-1": "A SimpleX-hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", + "simplex-unique-card-4-p-1": "A SimpleX-hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más hálózattól, kivéve az internetet.", "simplex-unique-card-4-p-2": "Használhatja a SimpleXet a saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", "join": "Csatlakozzon a közösségeinkhez", "we-invite-you-to-join-the-conversation": "Meghívjuk Önt, hogy csatlakozzon a beszélgetésekhez", @@ -165,7 +165,7 @@ "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-label": "Győződjön meg arról, hogy az üzenetváltó-alkalmazás amit használ nem fér hozzá az adataidhoz!", + "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", "simplex-network-section-desc": "A Simplex Chat a P2P- és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", @@ -194,7 +194,7 @@ "comparison-section-list-point-4a": "A SimpleX továbbítókiszolgálói nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", "comparison-section-list-point-4": "Ha az üzemeltetett kiszolgálók veszélybe kerülnek. Hitelesítse a biztonsági kódot a Signal vagy más biztonságos üzenetküldő alkalmazás segítségével a támadások veszélyeinek csökkentésére", "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", - "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált – egyetlen hálózatként működnek", + "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált — egyetlen hálózatként működnek", "comparison-section-list-point-7": "A P2P-hálózatoknak vagy van egy központi hitelesítője, vagy az egész hálózat kompromittálódhat", "see-here": "tekintse meg itt", "guide-dropdown-1": "Gyors indítás", @@ -206,7 +206,7 @@ "guide-dropdown-7": "Adatvédelem és biztonság", "guide-dropdown-8": "Alkalmazás beállításai", "guide": "Útmutató", - "docs-dropdown-1": "SimpleX-platform", + "docs-dropdown-1": "SimpleX-hálózat", "docs-dropdown-2": "Android fájlok elérése", "docs-dropdown-3": "Hozzáférés a csevegési adatbázishoz", "docs-dropdown-8": "SimpleX jegyzékszolgáltatás", @@ -222,7 +222,7 @@ "please-use-link-in-mobile-app": "Használja a mobilalkalmazásban található hivatkozást", "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, pl. WebSocketsen keresztül is működik.", + "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-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.", diff --git a/website/langs/it.json b/website/langs/it.json index 032706ebab..18a47bfe9f 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -60,23 +60,23 @@ "hero-overlay-card-2-p-2": "Potrebbero quindi correlare queste informazioni con i social network pubblici esistenti e determinare alcune identità reali.", "hero-overlay-card-2-p-4": "SimpleX protegge da questi attacchi non avendo alcun ID utente per design. Inoltre, se usi la modalità di modalità in incognito, il tuo nome mostrato sarà diverso per ogni contatto, evitando dati condivisi tra di loro.", "simplex-network-overlay-card-1-li-2": "Il design di SimpleX, a differenza della maggior parte delle reti P2P, non ha identificatori utente globali di alcun tipo, nemmeno temporanei, e usa solo identificatori temporanei a coppie, garantendo una maggiore protezione dell'anonimato e dei metadati.", - "simplex-network-overlay-card-1-li-4": "Le implementazioni P2P possono essere bloccate da alcuni fornitori di internet (come BitTorrent). SimpleX è indipendente dal trasporto - può funzionare su protocolli web standard, es. WebSocket.", + "simplex-network-overlay-card-1-li-4": "Le implementazioni P2P possono essere bloccate da alcuni fornitori di internet (come BitTorrent). SimpleX è indipendente dal trasporto — può funzionare su protocolli web standard, es. WebSocket.", "hero-overlay-card-2-p-1": "Quando gli utenti hanno identità permanenti, anche se si tratta solo di un numero casuale, come un Session ID, c'è il rischio che il fornitore o un malintenzionato possano osservare come gli utenti sono connessi e quanti messaggi inviano.", "simplex-network-overlay-card-1-li-6": "Le reti P2P possono essere vulnerabili all'attacco DRDoS, quando i client possono ritrasmettere e amplificare il traffico, con conseguente \"denial of service\" a livello di rete. I client SimpleX si limitano a inoltrare il traffico da una connessione nota e non possono essere usati da un aggressore per amplificare il traffico nell'intera rete.", - "privacy-matters-overlay-card-1-p-4": "La piattaforma SimpleX protegge la privacy delle tue connessioni meglio di qualsiasi alternativa, impedendo completamente che il tuo grafico sociale sia disponibile a qualsiasi azienda o organizzazione. Anche quando le persone usano i server forniti da SimpleX Chat, non conosciamo il numero di utenti o le loro connessioni.", - "privacy-matters-overlay-card-2-p-3": "SimpleX è la prima piattaforma che non ha alcun identificatore utente per design, proteggendo così il tuo grafico delle connessioni meglio di qualsiasi alternativa conosciuta.", + "privacy-matters-overlay-card-1-p-4": "La rete di SimpleX protegge la privacy delle tue connessioni meglio di qualsiasi alternativa, impedendo completamente che il tuo grafico sociale sia disponibile a qualsiasi azienda o organizzazione. Anche quando le persone usano i server preconfigurati in SimpleX Chat, gli operatori dei server non conoscono il numero di utenti o le loro connessioni.", + "privacy-matters-overlay-card-2-p-3": "SimpleX è la prima rete che non ha alcun identificatore utente per design, proteggendo così il tuo grafico delle connessioni meglio di qualsiasi alternativa conosciuta.", "privacy-matters-overlay-card-3-p-1": "Tutti dovrebbero preoccuparsi della privacy e della sicurezza delle proprie comunicazioni — conversazioni innocue possono metterti in pericolo, anche se non hai nulla da nascondere.", "privacy-matters-overlay-card-3-p-3": "Le persone comuni vengono arrestate per ciò che condividono online, anche tramite i loro account \"anonimi\", anche nei Paesi democratici.", - "simplex-unique-overlay-card-1-p-3": "Questo design protegge la privacy di chi stai comunicando, nascondendola ai server della piattaforma SimpleX e a qualsiasi osservatore. Per nascondere il tuo indirizzo IP ai server, puoi connetterti ai server SimpleX tramite Tor.", - "simplex-unique-overlay-card-2-p-1": "Poiché non hai alcun identificatore sulla piattaforma SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come un codice QR o un link.", + "simplex-unique-overlay-card-1-p-3": "Questo design protegge la privacy di chi stai comunicando, nascondendola ai server della rete SimpleX e a qualsiasi osservatore. Per nascondere il tuo indirizzo IP ai server, puoi connetterti ai server SimpleX tramite Tor.", + "simplex-unique-overlay-card-2-p-1": "Poiché non hai alcun identificatore sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come un codice QR o un link.", "simplex-unique-overlay-card-2-p-2": "Anche l'indirizzo utente opzionale, che può essere usato per inviare richieste di contatto spam, è possibile modificarlo o eliminarlo completamente senza perdere alcuna connessione.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat conserva tutti i dati utente solo sui dispositivi client usando un formato trasferibile di database crittografato che può essere esportato e trasferito su qualsiasi dispositivo supportato.", "simplex-unique-overlay-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", - "simplex-unique-overlay-card-4-p-1": "Puoi usare SimpleX con i tuoi server personali e continuare a comunicare con le persone che usano i server preconfigurati forniti da noi.", - "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la piattaforma SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", - "simplex-unique-card-1-p-1": "SimpleX protegge la privacy del tuo profilo, contatti e metadati, nascondendoli ai server della piattaforma SimpleX e ad eventuali osservatori.", - "simplex-unique-card-1-p-2": "A differenza di qualsiasi altra piattaforma di messaggistica esistente, SimpleX non ha identificatori assegnati agli utenti — nemmeno numeri casuali.", - "simplex-unique-card-2-p-1": "Poiché non hai un identificatore o un indirizzo fisso sulla piattaforma SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come codice un QR o un link.", + "simplex-unique-overlay-card-4-p-1": "Puoi usare SimpleX con i tuoi server personali e continuare a comunicare con le persone che usano i server preconfigurati nelle app.", + "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la rete SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", + "simplex-unique-card-1-p-1": "SimpleX protegge la privacy del tuo profilo, contatti e metadati, nascondendoli ai server della rete SimpleX e ad eventuali osservatori.", + "simplex-unique-card-1-p-2": "A differenza di qualsiasi altra rete di messaggistica esistente, SimpleX non ha identificatori assegnati agli utenti — nemmeno numeri casuali.", + "simplex-unique-card-2-p-1": "Poiché non hai un identificatore o un indirizzo fisso sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come codice un QR o un link.", "simplex-unique-card-3-p-1": "SimpleX conserva tutti i dati utente sui dispositivi client in un formato trasferibile di database crittografato — può essere trasferito su un altro dispositivo.", "join": "Unisciti a", "we-invite-you-to-join-the-conversation": "Ti invitiamo a unirti alla conversazione", @@ -102,7 +102,7 @@ "use-this-command": "Usa questo comando:", "see-simplex-chat": "Vedi SimpleX Chat", "github-repository": "Repository GitHub", - "the-instructions--source-code": "le istruzioni su come scaricarlo o compilarlo dal codice sorgente.", + "the-instructions--source-code": "per le istruzioni su come scaricarlo o compilarlo dal codice sorgente.", "if-you-already-installed-simplex-chat-for-the-terminal": "Se hai già installato SimpleX Chat per il terminale", "if-you-already-installed": "Se hai già installato", "privacy-matters-section-subheader": "Preservare la privacy dei tuoi metadati — con chi parli — ti protegge da:", @@ -130,13 +130,13 @@ "no-federated": "No - federato", "comparison-section-list-point-1": "Solitamente si basa su un numero di telefono, in alcuni casi su nomi utente", "comparison-section-list-point-2": "Indirizzi basati su DNS", - "comparison-section-list-point-6": "Sebbene i P2P siano distribuiti, non sono federati - operano come un'unica rete", + "comparison-section-list-point-6": "Sebbene i P2P siano distribuiti, non sono federati — operano come un'unica rete", "comparison-section-list-point-7": "Le reti P2P hanno un'autorità centrale o l'intera rete può essere compromessa", "see-here": "vedi qui", "simplex-network-3-header": "Nella rete di SimpleX", "simplex-private-card-7-point-2": "Se un messaggio viene aggiunto, rimosso o modificato, il destinatario verrà avvisato.", - "hero-overlay-card-1-p-2": "Per consegnare i messaggi, invece degli ID utente utilizzati da tutte le altre piattaforme, SimpleX usa identificatori temporanei anonimi a coppie delle code di messaggi, separati per ciascuna delle tue connessioni — non ci sono identificatori a lungo termine.", - "simplex-private-card-6-point-1": "Molte piattaforme di comunicazione sono vulnerabili agli attacchi MITM da parte di server o fornitori di rete.", + "hero-overlay-card-1-p-2": "Per consegnare i messaggi, invece degli ID utente utilizzati da tutte le altre reti, SimpleX usa identificatori temporanei anonimi a coppie delle code di messaggi, separati per ciascuna delle tue connessioni — non ci sono identificatori a lungo termine.", + "simplex-private-card-6-point-1": "Molte reti di comunicazione sono vulnerabili agli attacchi MITM da parte di server o fornitori di rete.", "simplex-private-card-7-point-1": "Per garantire l'integrità, i messaggi sono numerati in sequenza e includono l'hash del messaggio precedente.", "simplex-private-card-9-point-2": "Riduce i vettori di attacco, rispetto ai broker di messaggi tradizionali, e i metadati disponibili.", "simplex-private-card-10-point-2": "Ciò consente di recapitare messaggi senza identificatori del profilo utente, garantendo una migliore privacy dei metadati rispetto alle alternative.", @@ -161,7 +161,7 @@ "feature-3-title": "Gruppi decentralizzati crittografati E2E — solo gli utenti sanno che esistono", "blog": "Blog", "simplex-explained-tab-2-p-1": "Per ogni connessione usi due code di messaggi distinte per inviare e ricevere i messaggi attraverso server diversi.", - "simplex-explained-tab-2-p-2": "I server passano i messaggi solo in una direzione, senza avere il quadro completo della conversazione dell'utente o delle connessioni.", + "simplex-explained-tab-2-p-2": "I server passano i messaggi solo in una direzione, senza avere il quadro completo delle conversazioni dell'utente o delle connessioni.", "simplex-explained-tab-3-p-2": "Gli utenti possono aumentare ulteriormente la privacy dei metadati usando Tor per accedere ai server, evitando correlazioni per indirizzo IP.", "smp-protocol": "Protocollo SMP", "feature-1-title": "Messaggi crittografati E2E con markdown e modifica", @@ -181,21 +181,21 @@ "privacy-matters-overlay-card-1-p-2": "I rivenditori online sanno che le persone con redditi più bassi sono più propense a fare acquisti urgenti, quindi possono applicare prezzi più alti o rimuovere sconti.", "privacy-matters-overlay-card-1-p-3": "Alcune società finanziarie e assicurative usano grafici sociali per determinare i tassi di interesse e i premi. Spesso ciò fa pagare di più le persone con redditi più bassi — è noto come \"premio di povertà\" .", "privacy-matters-overlay-card-2-p-1": "Non molto tempo fa abbiamo assistito alla manipolazione delle principali elezioni da una rispettabile società di consulenza che ha usato i nostri grafici sociali per distorcere la nostra visione del mondo reale e manipolare i nostri voti.", - "privacy-matters-overlay-card-2-p-2": "Per essere obiettivi e prendere decisioni indipendenti devi avere il controllo del tuo spazio informativo. È possibile solo se utilizzi una piattaforma di comunicazione privata che non ha accesso al tuo grafico sociale.", + "privacy-matters-overlay-card-2-p-2": "Per essere obiettivi e prendere decisioni indipendenti devi avere il controllo del tuo spazio informativo. È possibile solo se utilizzi una rete di comunicazione privata che non ha accesso al tuo grafico sociale.", "privacy-matters-overlay-card-3-p-2": "Una delle storie più scioccanti è l'esperienza di Mohamedou Ould Salahi descritta nel suo libro di memorie e mostrata nel film The Mauritanian. È stato rinchiuso nel campo di Guantánamo, senza processo, e lì è stato torturato per 15 anni dopo una telefonata a un suo parente in Afghanistan, sospettato di essere coinvolto negli attacchi dell'11/9, nonostante avesse vissuto in Germania per i precedenti 10 anni.", "join-us-on-GitHub": "Unisciti a noi su GitHub", "simplex-chat-for-the-terminal": "SimpleX Chat per il terminale", "privacy-matters-overlay-card-3-p-4": "Non è sufficiente usare un messenger crittografato end-to-end, tutti dovremmo usare i messenger che proteggono la privacy delle nostre reti personali — con chi siamo connessi.", "simplex-unique-overlay-card-1-p-2": "Per recapitare i messaggi, SimpleX usa indirizzi anonimi a coppie di code di messaggi unidirezionali, separate per i messaggi ricevuti e inviati, di solito tramite server diversi.", - "simplex-unique-overlay-card-1-p-1": "A differenza di altre piattaforme di messaggistica, SimpleX non ha alcun identificatore assegnato agli utenti. Non si basa su numeri di telefono, indirizzi basati su domini (come email o XMPP), nomi utente, chiavi pubbliche o persino numeri casuali per identificare i suoi utenti — non sappiamo quante persone usano i nostri server SimpleX.", + "simplex-unique-overlay-card-1-p-1": "A differenza di altre reti di messaggistica, SimpleX non ha alcun identificatore assegnato agli utenti. Non si basa su numeri di telefono, indirizzi basati su domini (come email o XMPP), nomi utente, chiavi pubbliche o persino numeri casuali per identificare i suoi utenti — gli operatori dei server non sanno quante persone usano i loro server.", "simplex-unique-overlay-card-3-p-3": "A differenza dei server di reti federate (email, XMPP o Matrix), i server SimpleX non conservano gli account utente, ma trasmettono solo i messaggi, proteggendo la privacy di entrambe le parti.", "simplex-unique-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", "simplex-unique-card-4-p-2": "Puoi usare SimpleX con i tuoi server personali o con i server forniti da noi — e connetterti comunque a qualsiasi utente.", "join-the-REDDIT-community": "Unisciti alla comunità di REDDIT", "sign-up-to-receive-our-updates": "Iscriviti per ricevere i nostri aggiornamenti", "simplex-unique-overlay-card-3-p-4": "Non ci sono identificatori o testi cifrati in comune tra il traffico del server inviato e quello ricevuto — se qualcuno lo osserva, non può determinare facilmente chi comunica con chi, anche se il TLS è compromesso.", - "simplex-unique-overlay-card-4-p-2": "La piattaforma SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX puoi creare.", - "simplex-unique-card-4-p-1": "La rete SimpleX è completamente decentralizzata e indipendente da qualsiasi criptovaluta o altra piattaforma, ad eccezione di internet.", + "simplex-unique-overlay-card-4-p-2": "La rete di SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX creerai.", + "simplex-unique-card-4-p-1": "La rete SimpleX è completamente decentralizzata e indipendente da qualsiasi criptovaluta o altra rete, ad eccezione di internet.", "donate-here-to-help-us": "Dona qui per aiutarci", "to-make-a-connection": "Per stabilire una connessione:", "scan-the-qr-code-with-the-simplex-chat-app-description": "Le chiavi pubbliche e l'indirizzo della coda di messaggi in questo link NON vengono inviati in rete quando vedi questa pagina —
sono contenuti nel frammento hash dell'URL del link.", @@ -218,7 +218,7 @@ "guide-dropdown-7": "Privacy e sicurezza", "guide-dropdown-9": "Creare connessioni", "guide": "Guida", - "docs-dropdown-1": "Piattaforma SimpleX", + "docs-dropdown-1": "Rete di SimpleX", "docs-dropdown-2": "Accesso ai file Android", "docs-dropdown-3": "Accesso al database di chat", "docs-dropdown-4": "Ospita un server SMP", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index c6712e7bbf..aa51a7cff5 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -24,7 +24,7 @@ "simplex-network-overlay-card-1-li-2": "与大多数 P2P 网络不同,SimpleX 在设计上没有任何类型的全局用户标识符,甚至临时的也没有。SimpleX 仅使用临时的成对标识符,提供更好的匿名性和元数据保护。", "privacy-matters-overlay-card-1-p-1": "许多大型公司会使用您的人际关系来估算您的收入,决定商品的价格,并向您兜售您并不真正需要的产品。", "simplex-unique-overlay-card-3-p-4": "发送和接收的服务器流量之间没有共同的标识符或密文—— 如果有人在观察它,他们也无法轻易确定谁与谁通信,即使 TLS 受到威胁。", - "simplex-unique-card-4-p-1": "SimpleX 网络是完全去中心化的,并且独立于任何加密货币或除互联网以外的任何其他平台。", + "simplex-unique-card-4-p-1": "SimpleX 网络是完全去中心化的,并且独立于任何加密货币或除互联网以外的任何其他网络。", "join": "加入", "get-simplex": "获取 SimpleX desktop app", "hide-info": "隐藏信息", @@ -50,7 +50,7 @@ "simplex-explained-tab-3-text": "3. 服务器能看到什么", "hero-header": "重新定义隐私", "simplex-explained-tab-1-p-2": "它是如何在单向消息队列与没有用户识别符的情况下工作的?", - "simplex-explained-tab-2-p-2": "服务器只会单向传输消息,而无法掌握用户的会话或连接的全貌。", + "simplex-explained-tab-2-p-2": "服务器只会单向传输消息,而无法掌握用户的对话或连接的全貌。", "simplex-explained-tab-3-p-1": "服务器对每个队列都有单独的匿名凭证,并且不知道这些凭证属于哪些用户。", "donate": "捐赠", "simplex-explained-tab-2-p-1": "对于每个连接,您都会使用两个单独的消息队列,通过不同的服务器发送和接收消息。", @@ -104,7 +104,7 @@ "simplex-private-card-10-point-1": "SimpleX 为每个用户的联系人或群组成员均使用临时匿名成对地址和凭据。", "privacy-matters-3-title": "因无辜结社而被起诉", "privacy-matters-2-overlay-1-title": "隐私给您力量", - "hero-overlay-card-1-p-2": "为了传递消息,SimpleX 使用消息队列的临时匿名成对标识符(而非其他平台使用的用户 ID)来区分您的每个连接——没有长期标识符这种东西。", + "hero-overlay-card-1-p-2": "SimpleX 使用消息队列的临时匿名成对标识符(而非其他网络使用的用户 ID)来传递消息,而每个连接的标识符均不尽相同 — 没有长期标识符这种东西。", "hero-overlay-card-1-p-3": "您定义用于接收消息的服务器、您的联系人—— 您用来向他们发送消息的服务器。 每个会话都很可能会使用两个不同的服务器。", "hero-overlay-card-1-p-4": "此设计可防止在应用程序级别泄漏任何用户元数据。 为了进一步改善隐私并保护您的 IP 地址,您可以通过 Tor 连接到消息服务器。", "hero-overlay-card-1-p-5": "只有客户端设备存储用户配置文件、联系人和群组; 消息均使用两层端到端加密发送。", @@ -115,29 +115,29 @@ "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服务的最私密的应用程序,如果您通过同一个人资料与两个不同的联系人交谈,他们也可以证明他们与同一个人有联系。", "hero-overlay-card-2-p-4": "为防止这些攻击,SimpleX 在其设计中不包含任何用户 ID 。 而且,如果您使用隐身模式,您将为每个联系人显示不同的名称,避免他们之间共享任何数据。", "simplex-network-overlay-card-1-li-1": "P2P 网络依赖于 分布式散列表(DHT) 的某些变体来路由消息。 DHT 在设计上必须平衡可达性和延迟。 SimpleX 比 P2P 具有更好的可达性和更低的延迟,因为消息可以通过通讯双方选择的多个服务器并行地冗余传递。若是在 P2P 网络中,消息则需要使用算法选择,并依次通过 O(log N) 个节点。", - "simplex-network-overlay-card-1-li-4": "P2P 实现(如 BitTorrent)可能会被某些互联网提供商阻止。 SimpleX 与传输协议无关——它可以在标准网络协议上工作,例如 WebSockets。", + "simplex-network-overlay-card-1-li-4": "P2P 实现(如 BitTorrent)可能会被某些互联网提供商阻止。 SimpleX 与传输协议无关 — 它可以在标准网络协议上工作,例如 WebSockets。", "simplex-network-overlay-card-1-li-3": "P2P 并未解决中间人攻击(MITM Attack) 问题。大多数现有的 P2P 实现没有使用带外通讯来进行初始密钥的交换,而 SimpleX 使用带外通讯,或者在某些情况下,使用预先存在的安全和可信连接来进行初始密钥交换。", "simplex-network-overlay-card-1-li-5": "所有已知的 P2P 网络都可能受到 Sybil 攻击,因为每个节点都是可发现的,并且网络作为一个整体运行。 已知的缓解措施不是需要一个中心化的组件就是需要昂贵的工作量证明。而 SimpleX 网络没有服务器可发现性,它是碎片化的并且作为多个隔离的子网运行,这样全网络范围的攻击便无从实现。", "simplex-network-overlay-card-1-li-6": "P2P 网络可能受到 DRDoS 攻击 。客户端有能力重新广播和放大流量,从而导致整个网络范围内的服务中断。 SimpleX 客户端仅中继来自已知连接的流量,因此不能被攻击者用来放大整个网络的流量。", "privacy-matters-overlay-card-1-p-2": "在线零售商知道收入较低的人更有可能在紧急情况下购买商品,因此他们可能会收取更高的价格或取消折扣。", "privacy-matters-overlay-card-1-p-3": "一些金融和保险公司使用社交图谱来确定利率和保费。 它通常会让收入较低的人支付更多—— 它被称为“贫困溢价”。", - "privacy-matters-overlay-card-1-p-4": "SimpleX 平台比任何替代方案都能更好地保护您人际关系层面的隐私,防止您的社交图谱被任何公司或组织使用。 即使人们使用 SimpleX Chat 提供的服务器,我们也不知道用户数量或他们的连接数。", + "privacy-matters-overlay-card-1-p-4": "SimpleX 网络比任何替代方案都能更好地保护您人际关系层面的隐私,防止您的社交图谱被任何公司或组织使用。 即使人们使用 SimpleX Chat 应用预配置的服务器,服务器运营方也不知道用户数量或他们的连接数。", "privacy-matters-overlay-card-2-p-1": "不久前,我们观察到几次大选被一家知名咨询公司操纵,该公司使用我们的社交图谱扭曲我们对现实世界的看法并操纵我们的选票。", - "privacy-matters-overlay-card-2-p-2": "为了客观并做出独立的决定,您需要控制您的信息空间。 而这只有当您使用没有能力访问您的社交图谱的,注重隐私的通信平台时,这才有可能。", - "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一个没有设计任何用户标识符的平台,这样能比任何已知的替代方案都更好地保护您的连接图谱。", + "privacy-matters-overlay-card-2-p-2": "为了客观并做出独立的决定,您需要控制您的信息空间。 而这只有当您使用没有能力访问您的社交图谱的,注重隐私的通信网络时,这才有可能。", + "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一个没有设计任何用户标识符的网络,这样能比任何已知的替代方案都更好地保护您的连接图谱。", "privacy-matters-overlay-card-3-p-2": "最令人震惊的故事之一是 Mohamedou Ould Salahi 在他的回忆录中描述并在毛里塔尼亚电影中展示的经历。 他在未经审判的情况下被关进关塔那摩集中营,并在打电话给他在阿富汗的亲戚后在那里遭受了 15 年的折磨,他被怀疑参与了 9/11 袭击,尽管他在过去 10 年住在德国。", "privacy-matters-overlay-card-3-p-1": "每个人都应该关心他们通信的隐私和安全——无害的谈话会让您处于危险之中,即使您没有什么可隐瞒的。", "privacy-matters-overlay-card-3-p-3": "普通人会因为他们在网上分享的内容而被捕,即使是通过他们的“匿名”帐户,即使是在民主国家。", "privacy-matters-overlay-card-3-p-4": "使用端到端加密的即时通讯软件还不够,我们都应该使用保护我们个人网络隐私——即我们与谁有联系的即时通讯软件。", - "simplex-unique-overlay-card-1-p-1": "与其他消息传递平台不同,SimpleX 没有分配给用户的标识符。 它不依赖电话号码、基于域的地址(如电子邮件或 XMPP)、用户名、公钥甚至随机数来识别其用户—— 我们不知道有多少人使用我们的 SimpleX 服务器。", - "simplex-unique-overlay-card-1-p-2": "为了传递消息,SimpleX 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。 使用 SimpleX 就像为每个联系人使用不同的即时电子邮件或电话,并且无需管理它们。", - "simplex-unique-overlay-card-1-p-3": "这种设计保护了您正在与之通信的人的隐私,将其隐藏在 SimpleX 平台服务器和任何观察者之外。 要对服务器隐藏您的 IP 地址,您可以通过 Tor 连接到 SimpleX 服务器。", - "simplex-unique-overlay-card-4-p-1": "您可以将 SimpleX 与您自己的服务器一起使用,并且仍然可以与使用我们提供的预配置服务器的人进行通信。", + "simplex-unique-overlay-card-1-p-1": "与其他消息网络不同,SimpleX 没有分配给用户的标识符。 它不依赖电话号码、基于域的地址(如电子邮件或 XMPP)、用户名、公钥甚至随机数来识别其用户—— SimpleX 服务器运营方不知道有多少人使用其服务器。", + "simplex-unique-overlay-card-1-p-2": "为了传递消息,SimpleX 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。", + "simplex-unique-overlay-card-1-p-3": "这种设计保护了您正在与之通信的人的隐私,将其隐藏在 SimpleX 网络的服务器和任何观察者之外。 要对服务器隐藏您的 IP 地址,您可以通过 Tor 连接到 SimpleX 服务器。", + "simplex-unique-overlay-card-4-p-1": "您可以将 SimpleX 与您自己的服务器一起使用,并且仍然可以与使用应用中预配置服务器的人们进行通信。", "simplex-unique-card-3-p-2": "端到端加密的消息在被收到前会暂时保存在 SimpleX 中继服务器上,传送完成后它们会被永久删除。", - "simplex-unique-card-1-p-1": "SimpleX 保护您的个人资料、联系人和元数据的隐私,将其隐藏在 SimpleX 平台服务器和任何观察者之外。", - "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 平台上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用程序中,请 联系我们 以获取建议和支持。", + "simplex-unique-card-1-p-1": "SimpleX 保护您的个人资料、联系人和元数据的隐私,不让 SimpleX 网络服务器和任何观察者看到它们。", + "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 网络上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用程序中,请 联系我们 以获取建议和支持。", "simplex-unique-card-4-p-2": "您可以搭配自己的服务器来使用 SimpleX 或使用我们提供的服务器 — 并仍然连接到任何用户。", - "simplex-unique-card-2-p-1": "因为您在 SimpleX 平台上没有标识符或固定地址,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", + "simplex-unique-card-2-p-1": "因为您在 SimpleX 网络上没有标识符或固定地址,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", "simplex-unique-card-3-p-1": "SimpleX 以便携式加密数据库格式将所有用户数据存储在客户端设备上—— 它可以转移到另一个设备。", "sign-up-to-receive-our-updates": "注册以接收我们的更新", "we-invite-you-to-join-the-conversation": "我们邀请您加入对话", @@ -164,7 +164,7 @@ "github-repository": "GitHub 仓库", "copy-the-command-below-text": "复制下面的命令并在聊天中使用它:", "privacy-matters-section-header": "为什么隐私很重要", - "the-instructions--source-code": "如何从源代码下载或编译它的说明。", + "the-instructions--source-code": "如何下载或从源代码下载进行编译的说明。", "if-you-already-installed-simplex-chat-for-the-terminal": "如果您已经为终端安装了 SimpleX Chat", "if-you-already-installed": "如果您已经安装", "simplex-chat-for-the-terminal": "用于终端的 SimpleX Chat", @@ -197,16 +197,16 @@ "comparison-section-list-point-4": "如果运营商的服务器受到威胁。 验证 Signal 和其他一些应用程序中的安全代码以缓解该问题", "comparison-section-list-point-1": "通常基于电话号码,在某些情况下基于用户名", "comparison-section-list-point-2": "基于 DNS 的地址", - "comparison-section-list-point-6": "P2P 是分布式的,而非联邦式的 - 它们作为单个网络运行", + "comparison-section-list-point-6": "P2P 是分布式的,但并非联邦式的 — 它们作为单个网络运行", "see-here": "参见此处", "comparison-section-list-point-7": "P2P 网络要么拥有中央权威,要么整个网络可能被攻陷", "simplex-private-card-5-point-1": "SimpleX 为每个加密层进行内容填充来对抗长度扩展攻击。", - "simplex-unique-overlay-card-4-p-2": "SimpleX 平台使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—— 我们真的很期待看到您可以依托SimpleX构建哪些服务。", + "simplex-unique-overlay-card-4-p-2": "SimpleX 网络使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—— 我们真的很期待看到您会依托SimpleX构建哪些服务。", "simplex-unique-overlay-card-2-p-2": "即使使用可选的用户地址,当它被用于发送垃圾邮件联系请求,您可以更改或完全删除它而不会丢失任何连接。", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat 使用便携式加密数据库格式仅将所有用户数据存储在客户端设备上,该格式可以导出并传输到任何支持的设备。", "donate-here-to-help-us": "在这里捐款来帮助我们", - "simplex-unique-card-1-p-2": "与任何其他现有的消息传递平台不同,SimpleX 没有分配给用户的标识符—— 甚至随机数也没有。", - "simplex-unique-overlay-card-2-p-1": "因为您在 SimpleX 平台上没有标识符,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", + "simplex-unique-card-1-p-2": "与任何其他现有的消息传递网络不同,SimpleX 没有分配给用户的标识符—— 甚至随机数也没有。", + "simplex-unique-overlay-card-2-p-1": "因为您在 SimpleX 网络上没有标识符,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", "simplex-unique-overlay-card-3-p-2": "端到端加密的消息在被收到前会暂时保存在 SimpleX 中继服务器上,传送完成后它们会被永久删除。", "simplex-unique-overlay-card-3-p-3": "与联合网络服务器(电子邮件、XMPP 或 Matrix)不同,SimpleX 服务器不存储用户帐户,它们仅中继消息,保护双方的隐私。", "guide-dropdown-2": "发送消息", @@ -223,7 +223,7 @@ "click-to-see": "点击查看", "menu": "菜单", "guide-dropdown-1": "快速指南", - "docs-dropdown-1": "SimpleX 平台", + "docs-dropdown-1": "SimpleX 网络", "docs-dropdown-5": "托管 XFTP 服务器", "newer-version-of-eng-msg": "本页面有较新的英语版本。", "guide-dropdown-4": "聊天档案", diff --git a/website/web.sh b/website/web.sh index 4feba4be50..897c87743e 100755 --- a/website/web.sh +++ b/website/web.sh @@ -5,6 +5,7 @@ set -e cp -R docs website/src rm -rf website/src/docs/rfcs rm website/src/docs/lang/*/README.md +rm -rf website/src/docs/dependencies cp -R blog website/src cp -R images website/src rm website/src/blog/README.md 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 221/289] 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 222/289] 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 223/289] 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 224/289] 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 225/289] 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 4b42a19ccb035f113cfd8ed318bfb5b97756cd85 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 10 May 2025 14:37:45 +0100 Subject: [PATCH 226/289] ios: fix XCode 16 regressions (tap not working on files, quotes, images, voice messages, etc.), open link previews on tap (#5880) * ios: fix XCode 16 regressions (tap not working on files, quotes, images, voice messages, etc.), open link previews on tap * fix voice recording * fix video, accepting calls from chat, preference toggles in chat * WIP message and meta * handle links in attributed strings * custom attribute for links to prevent race conditions with default tap handler --- apps/ios/Shared/Model/AppAPITypes.swift | 19 - .../Views/Chat/ChatItem/CICallItemView.swift | 20 +- .../ChatItem/CIFeaturePreferenceView.swift | 4 +- .../Views/Chat/ChatItem/CIFileView.swift | 50 +-- .../Chat/ChatItem/CIGroupInvitationView.swift | 4 +- .../Views/Chat/ChatItem/CIImageView.swift | 6 +- .../Chat/ChatItem/CIInvalidJSONView.swift | 4 +- .../Views/Chat/ChatItem/CILinkView.swift | 22 ++ .../ChatItem/CIMemberCreatedContactView.swift | 4 +- .../Views/Chat/ChatItem/CIMetaView.swift | 4 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 4 +- .../Views/Chat/ChatItem/CIVideoView.swift | 146 ++++---- .../Views/Chat/ChatItem/CIVoiceView.swift | 101 ++---- .../Views/Chat/ChatItem/FramedItemView.swift | 40 +-- .../Chat/ChatItem/FullScreenMediaView.swift | 2 +- .../ChatItem/IntegrityErrorItemView.swift | 2 +- .../Views/Chat/ChatItem/MsgContentView.swift | 340 ++++++++++++------ .../Shared/Views/Chat/ChatItemInfoView.swift | 5 +- .../Chat/ComposeMessage/ContextItemView.swift | 2 +- .../Chat/ComposeMessage/SendMessageView.swift | 17 +- .../Views/Chat/Group/GroupWelcomeView.swift | 4 +- .../Views/ChatList/ChatPreviewView.swift | 23 +- .../Shared/Views/Helpers/ViewModifiers.swift | 1 + .../Views/UserSettings/AppSettings.swift | 2 - .../Views/UserSettings/PrivacySettings.swift | 12 - .../Views/UserSettings/SettingsView.swift | 3 - apps/ios/SimpleXChat/ChatTypes.swift | 30 +- src/Simplex/Chat/AppSettings.hs | 10 - 28 files changed, 461 insertions(+), 420 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index fd89026cfe..3bf4cb7b56 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1413,22 +1413,6 @@ enum NotificationsMode: String, Decodable, SelectableItem { static var values: [NotificationsMode] = [.instant, .periodic, .off] } -enum PrivacyChatListOpenLinksMode: String, CaseIterable, Codable, RawRepresentable, Identifiable { - case yes - case no - case ask - - var id: Self { self } - - var text: LocalizedStringKey { - switch self { - case .yes: return "Yes" - case .no: return "No" - case .ask: return "Ask" - } - } -} - struct RemoteCtrlInfo: Decodable { var remoteCtrlId: Int64 var ctrlDeviceName: String @@ -1941,7 +1925,6 @@ struct AppSettings: Codable, Equatable { var privacyAskToApproveRelays: Bool? = nil var privacyAcceptImages: Bool? = nil var privacyLinkPreviews: Bool? = nil - var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = nil var privacyShowChatPreviews: Bool? = nil var privacySaveLastDraft: Bool? = nil var privacyProtectScreen: Bool? = nil @@ -1977,7 +1960,6 @@ struct AppSettings: Codable, Equatable { if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } - if privacyChatListOpenLinks != def.privacyChatListOpenLinks { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } @@ -2014,7 +1996,6 @@ struct AppSettings: Codable, Equatable { privacyAskToApproveRelays: true, privacyAcceptImages: true, privacyLinkPreviews: true, - privacyChatListOpenLinks: .ask, privacyShowChatPreviews: true, privacySaveLastDraft: true, privacyProtectScreen: false, diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 3b3e1b3899..024aeed96a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -60,16 +60,16 @@ struct CICallItemView: View { @ViewBuilder private func acceptCallButton() -> some View { if case let .direct(contact) = chat.chatInfo { - Button { - if let invitation = m.callInvitations[contact.id] { - CallController.shared.answerCall(invitation: invitation) - logger.debug("acceptCallButton call answered") - } else { - AlertManager.shared.showAlertMsg(title: "Call already ended!") - } - } label: { - Label("Answer call", systemImage: "phone.arrow.down.left") - } + Label("Answer call", systemImage: "phone.arrow.down.left") + .foregroundColor(theme.colors.primary) + .simultaneousGesture(TapGesture().onEnded { + if let invitation = m.callInvitations[contact.id] { + CallController.shared.answerCall(invitation: invitation) + logger.debug("acceptCallButton call answered") + } else { + AlertManager.shared.showAlertMsg(title: "Call already ended!") + } + }) } else { Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index 2c9c261536..67f7b69e2c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -26,9 +26,9 @@ struct CIFeaturePreferenceView: View { allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) { let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept") - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil) - } + }) } else { featurePreferenceView() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index bc44afdd7f..b0b404d8b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -19,42 +19,42 @@ struct CIFileView: View { var body: some View { if smallViewSize != nil { fileIndicator() - .onTapGesture(perform: fileAction) + .simultaneousGesture(TapGesture().onEnded(fileAction)) } else { let metaReserve = edited ? " " : " " - Button(action: fileAction) { - HStack(alignment: .bottom, spacing: 6) { - fileIndicator() - .padding(.top, 5) - .padding(.bottom, 3) - if let file = file { - let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) - VStack(alignment: .leading, spacing: 2) { - Text(file.fileName) - .lineLimit(1) - .multilineTextAlignment(.leading) - .foregroundColor(theme.colors.onBackground) - Text(prettyFileSize + metaReserve) - .font(.caption) - .lineLimit(1) - .multilineTextAlignment(.leading) - .foregroundColor(theme.colors.secondary) - } - } else { - Text(metaReserve) + HStack(alignment: .bottom, spacing: 6) { + fileIndicator() + .padding(.top, 5) + .padding(.bottom, 3) + if let file = file { + let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(theme.colors.onBackground) + Text(prettyFileSize + metaReserve) + .font(.caption) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(theme.colors.secondary) } + } else { + Text(metaReserve) } - .padding(.top, 4) - .padding(.bottom, 6) - .padding(.leading, 10) - .padding(.trailing, 12) } + .padding(.top, 4) + .padding(.bottom, 6) + .padding(.leading, 10) + .padding(.trailing, 12) + .simultaneousGesture(TapGesture().onEnded(fileAction)) .disabled(!itemInteractive) } } + @inline(__always) private var itemInteractive: Bool { if let file = file { switch (file.fileStatus) { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 107208a033..3fcf578875 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -84,12 +84,12 @@ struct CIGroupInvitationView: View { } if action { - v.onTapGesture { + v.simultaneousGesture(TapGesture().onEnded { inProgress = true joinGroup(groupInvitation.groupId) { await MainActor.run { inProgress = false } } - } + }) .disabled(inProgress) } else { v diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 4c8221d887..ba6a2bd200 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -31,7 +31,7 @@ struct CIImageView: View { .if(!smallView) { view in view.modifier(PrivacyBlur(blurred: $blurred)) } - .onTapGesture { showFullScreenImage = true } + .simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true }) .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenImage = false } @@ -43,7 +43,7 @@ struct CIImageView: View { imageView(preview).modifier(PrivacyBlur(blurred: $blurred)) } } - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { if let file = file { switch file.fileStatus { case .rcvInvitation, .rcvAborted: @@ -80,7 +80,7 @@ struct CIImageView: View { default: () } } - } + }) } } .onDisappear { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift index 918d8f9449..5e9fa691de 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift @@ -24,7 +24,7 @@ struct CIInvalidJSONView: View { .padding(.vertical, 6) .background(Color(uiColor: .tertiarySystemGroupedBackground)) .textSelection(.disabled) - .onTapGesture { showJSON = true } + .simultaneousGesture(TapGesture().onEnded { showJSON = true }) .appSheet(isPresented: $showJSON) { invalidJSONView(dataToString(json)) } @@ -33,7 +33,7 @@ struct CIInvalidJSONView: View { func invalidJSONView(_ json: String) -> some View { VStack(alignment: .leading, spacing: 16) { - Button { + Button { // this is used in the sheet, Button works here showShareSheet(items: [json]) } label: { Image(systemName: "square.and.arrow.up") diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 692e6bb8a6..4f879db426 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -38,9 +38,31 @@ struct CILinkView: View { .padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: .leading) } + .simultaneousGesture(TapGesture().onEnded { + openBrowserAlert(uri: linkPreview.uri) + }) } } +func openBrowserAlert(uri: URL) { + showAlert( + NSLocalizedString("Open in browser?", comment: "alert title"), + message: uri.absoluteString, + actions: {[ + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default, + handler: { _ in } + ), + UIAlertAction( + title: NSLocalizedString("Open", comment: "alert action"), + style: .default, + handler: { _ in UIApplication.shared.open(uri) } + ) + ]} + ) +} + struct LargeLinkPreview_Previews: PreviewProvider { static var previews: some View { let preview = LinkPreview( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index e49e99c77e..2898a318a9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -20,11 +20,11 @@ struct CIMemberCreatedContactView: View { case let .groupRcv(groupMember): if let contactId = groupMember.memberContactId { memberCreatedContactView(openText: "Open") - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { ItemsModel.shared.loadOpenChat("@\(contactId)") { dismissAllSheets(animated: true) } - } + }) } else { memberCreatedContactView() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index e58ad0f74e..fc73778239 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -15,7 +15,7 @@ struct CIMetaView: View { @Environment(\.showTimestamp) var showTimestamp: Bool var chatItem: ChatItem var metaColor: Color - var paleMetaColor = Color(UIColor.tertiaryLabel) + var paleMetaColor = Color(uiColor: .tertiaryLabel) var showStatus = true var showEdited = true var invertedMaterial = false @@ -152,11 +152,13 @@ func ciMetaText( return r.font(.caption) } +@inline(__always) private func statusIconText(_ icon: String, _ color: Color?) -> Text { colored(Text(Image(systemName: icon)), color) } // Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier +@inline(__always) private func colored(_ t: Text, _ color: Color?) -> Text { if let color { t.foregroundColor(color) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 6920ab999b..e26fb62a71 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -133,7 +133,7 @@ struct CIRcvDecryptionError: View { CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) .padding(.horizontal, 12) } - .onTapGesture(perform: { onClick() }) + .simultaneousGesture(TapGesture().onEnded(onClick)) .padding(.vertical, 6) .textSelection(.disabled) } @@ -151,7 +151,7 @@ struct CIRcvDecryptionError: View { CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) .padding(.horizontal, 12) } - .onTapGesture(perform: { onClick() }) + .simultaneousGesture(TapGesture().onEnded(onClick)) .padding(.vertical, 6) .textSelection(.disabled) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index f774299ad3..74bce5e583 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -47,57 +47,57 @@ struct CIVideoView: View { let file = chatItem.file ZStack(alignment: smallView ? .topLeading : .center) { ZStack(alignment: .topLeading) { - if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView { - smallVideoView(decrypted, file, preview) - } else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted { - videoView(player, decrypted, file, preview, duration) - } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView { - smallVideoViewEncrypted(file, defaultPreview) - } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { - videoViewEncrypted(file, defaultPreview, duration) - } else if let preview, let file { - Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } - .onTapGesture { - switch file.fileStatus { - case .rcvInvitation, .rcvAborted: - receiveFileIfValidSize(file: file, receiveFile: receiveFile) - case .rcvAccepted: - switch file.fileProtocol { - case .xftp: - AlertManager.shared.showAlertMsg( - title: "Waiting for video", - message: "Video will be received when your contact completes uploading it." - ) - case .smp: - AlertManager.shared.showAlertMsg( - title: "Waiting for video", - message: "Video will be received when your contact is online, please wait or check later!" - ) - case .local: () - } - case .rcvTransfer: () // ? - case .rcvComplete: () // ? - case .rcvCancelled: () // TODO - default: () - } + if let file, let preview { + if let urlDecrypted { + if smallView { + smallVideoView(urlDecrypted, file, preview) + } else if let player { + videoView(player, urlDecrypted, file, preview, duration) } + } else if file.loaded { + if smallView { + smallVideoViewEncrypted(file, preview) + } else { + videoViewEncrypted(file, preview, duration) + } + } else { + Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } + .simultaneousGesture(TapGesture().onEnded { + switch file.fileStatus { + case .rcvInvitation, .rcvAborted: + receiveFileIfValidSize(file: file, receiveFile: receiveFile) + case .rcvAccepted: + switch file.fileProtocol { + case .xftp: + AlertManager.shared.showAlertMsg( + title: "Waiting for video", + message: "Video will be received when your contact completes uploading it." + ) + case .smp: + AlertManager.shared.showAlertMsg( + title: "Waiting for video", + message: "Video will be received when your contact is online, please wait or check later!" + ) + case .local: () + } + case .rcvTransfer: () // ? + case .rcvComplete: () // ? + case .rcvCancelled: () // TODO + default: () + } + }) + } } if !smallView { durationProgress() } } if !blurred, let file, showDownloadButton(file.fileStatus) { - if !smallView { - Button { - receiveFileIfValidSize(file: file, receiveFile: receiveFile) - } label: { - playPauseIcon("play.fill") - } - } else if !file.showStatusIconInSmallView { + if !smallView || !file.showStatusIconInSmallView { playPauseIcon("play.fill") - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { receiveFileIfValidSize(file: file, receiveFile: receiveFile) - } + }) } } } @@ -151,27 +151,26 @@ struct CIVideoView: View { ZStack(alignment: .center) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) imageView(defaultPreview) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { decrypt(file: file) { showFullScreenPlayer = urlDecrypted != nil } - } + }) .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenPlayer = false } if !blurred { if !decryptionInProgress { - Button { - decrypt(file: file) { - if urlDecrypted != nil { - videoPlaying = true - player?.play() + playPauseIcon(canBePlayed ? "play.fill" : "play.slash") + .simultaneousGesture(TapGesture().onEnded { + decrypt(file: file) { + if urlDecrypted != nil { + videoPlaying = true + player?.play() + } } - } - } label: { - playPauseIcon(canBePlayed ? "play.fill" : "play.slash") - } - .disabled(!canBePlayed) + }) + .disabled(!canBePlayed) } else { videoDecryptionProgress() } @@ -194,7 +193,7 @@ struct CIVideoView: View { } } .modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { switch player.timeControlStatus { case .playing: player.pause() @@ -205,18 +204,17 @@ struct CIVideoView: View { } default: () } - } + }) .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenPlayer = false } if !videoPlaying && !blurred { - Button { - m.stopPreviousRecPlay = url - player.play() - } label: { - playPauseIcon(canBePlayed ? "play.fill" : "play.slash") - } - .disabled(!canBePlayed) + playPauseIcon(canBePlayed ? "play.fill" : "play.slash") + .simultaneousGesture(TapGesture().onEnded { + m.stopPreviousRecPlay = url + player.play() + }) + .disabled(!canBePlayed) } } fileStatusIcon() @@ -235,7 +233,7 @@ struct CIVideoView: View { return ZStack(alignment: .topLeading) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) smallViewImageView(preview, file) - .onTapGesture { + .onTapGesture { // this is shown in chat list, where onTapGesture works decrypt(file: file) { showFullScreenPlayer = urlDecrypted != nil } @@ -256,7 +254,7 @@ struct CIVideoView: View { private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View { return ZStack(alignment: .topLeading) { smallViewImageView(preview, file) - .onTapGesture { + .onTapGesture { // this is shown in chat list, where onTapGesture works showFullScreenPlayer = true } .onChange(of: m.activeCallViewIsCollapsed) { _ in @@ -354,14 +352,14 @@ struct CIVideoView: View { case .sndCancelled: fileIcon("xmark", 10, 13) case let .sndError(sndFileError): fileIcon("xmark", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError) - } + }) case let .sndWarning(sndFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) - } + }) case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case let .rcvTransfer(rcvProgress, rcvTotal): @@ -375,14 +373,14 @@ struct CIVideoView: View { case .rcvCancelled: fileIcon("xmark", 10, 13) case let .rcvError(rcvFileError): fileIcon("xmark", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError) - } + }) case let .rcvWarning(rcvFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError, temporary: true) - } + }) case .invalid: fileIcon("questionmark", 10, 13) } } @@ -429,7 +427,7 @@ struct CIVideoView: View { Color.black.edgesIgnoringSafeArea(.all) VideoPlayer(player: fullPlayer) .overlay(alignment: .topLeading, content: { - Button(action: { showFullScreenPlayer = false }, + Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here label: { Image(systemName: "multiply") .resizable() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index e2fec02ba1..715e606a74 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -168,14 +168,14 @@ struct VoiceMessagePlayer: View { case .sndCancelled: playbackButton() case let .sndError(sndFileError): fileStatusIcon("multiply", 14) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError) - } + }) case let .sndWarning(sndFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) - } + }) case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvAccepted: loadingIcon() case .rcvTransfer: loadingIcon() @@ -184,14 +184,14 @@ struct VoiceMessagePlayer: View { case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case let .rcvError(rcvFileError): fileStatusIcon("multiply", 14) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError) - } + }) case let .rcvWarning(rcvFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(rcvFileError, temporary: true) - } + }) case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } } else { @@ -255,59 +255,29 @@ struct VoiceMessagePlayer: View { } } - @ViewBuilder private func playbackButton() -> some View { - if sizeMultiplier != 1 { - switch playbackState { - case .noPlayback: - playPauseIcon("play.fill", theme.colors.primary) - .onTapGesture { - if let recordingSource = getLoadedFileSource(recordingFile) { - startPlayback(recordingSource) - } - } - case .playing: - playPauseIcon("pause.fill", theme.colors.primary) - .onTapGesture { - audioPlayer?.pause() - playbackState = .paused - notifyStateChange() - } - case .paused: - playPauseIcon("play.fill", theme.colors.primary) - .onTapGesture { - audioPlayer?.play() - playbackState = .playing - notifyStateChange() - } - } - } else { - switch playbackState { - case .noPlayback: - Button { + private func playbackButton() -> some View { + let icon = switch playbackState { + case .noPlayback: "play.fill" + case .playing: "pause.fill" + case .paused: "play.fill" + } + return playPauseIcon(icon, theme.colors.primary) + .simultaneousGesture(TapGesture().onEnded { _ in + switch playbackState { + case .noPlayback: if let recordingSource = getLoadedFileSource(recordingFile) { startPlayback(recordingSource) } - } label: { - playPauseIcon("play.fill", theme.colors.primary) - } - case .playing: - Button { + case .playing: audioPlayer?.pause() playbackState = .paused notifyStateChange() - } label: { - playPauseIcon("pause.fill", theme.colors.primary) - } - case .paused: - Button { + case .paused: audioPlayer?.play() playbackState = .playing notifyStateChange() - } label: { - playPauseIcon("play.fill", theme.colors.primary) } - } - } + }) } private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View { @@ -329,28 +299,14 @@ struct VoiceMessagePlayer: View { } private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { - Group { - if sizeMultiplier != 1 { - playPauseIcon(icon, theme.colors.primary) - .onTapGesture { - Task { - if let user = chatModel.currentUser { - await receiveFile(user: user, fileId: recordingFile.fileId) - } - } + playPauseIcon(icon, theme.colors.primary) + .simultaneousGesture(TapGesture().onEnded { + Task { + if let user = chatModel.currentUser { + await receiveFile(user: user, fileId: recordingFile.fileId) } - } else { - Button { - Task { - if let user = chatModel.currentUser { - await receiveFile(user: user, fileId: recordingFile.fileId) - } - } - } label: { - playPauseIcon(icon, theme.colors.primary) } - } - } + }) } func notifyStateChange() { @@ -430,6 +386,7 @@ struct VoiceMessagePlayer: View { } } +@inline(__always) func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat { let squareToCircleRatio = 0.935 return squareSize + squareSize * (1 - squareToCircleRatio) @@ -446,10 +403,12 @@ class VoiceItemState { self.playbackTime = playbackTime } + @inline(__always) static func id(_ chat: Chat, _ chatItem: ChatItem) -> String { "\(chat.id) \(chatItem.id)" } + @inline(__always) static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String { "\(chatInfo.id) \(chatItem.id)" } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index e411befbfa..632d4196c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -23,8 +23,6 @@ struct FramedItemView: View { @State private var useWhiteMetaColor: Bool = false @State var showFullScreenImage = false @Binding var allowMenu: Bool - @State private var showSecrets = false - @State private var showQuoteSecrets = false @State private var showFullscreenGallery: Bool = false var body: some View { @@ -57,7 +55,7 @@ struct FramedItemView: View { if let qi = chatItem.quotedItem { ciQuoteView(qi) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { scrollToItemId(ci.id) @@ -67,7 +65,7 @@ struct FramedItemView: View { } else { showQuotedItemDoesNotExistAlert() } - } + }) } else if let itemForwarded = chatItem.meta.itemForwarded { framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) } @@ -94,14 +92,14 @@ struct FramedItemView: View { .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } if let (title, text) = chatItem.meta.itemStatus.statusInfo { - v.onTapGesture { + v.simultaneousGesture(TapGesture().onEnded { AlertManager.shared.showAlert( Alert( title: Text(title), message: Text(text) ) ) - } + }) } else { v } @@ -159,7 +157,7 @@ struct FramedItemView: View { case let .file(text): ciFileView(chatItem, text) case let .report(text, reason): - ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) + ciMsgContentView(chatItem, txtPrefix: reason.attrString) case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) @@ -270,14 +268,12 @@ struct FramedItemView: View { .padding(.top, 6) .padding(.horizontal, 12) } - + + @inline(__always) private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { - toggleSecrets(qi.formattedText, $showQuoteSecrets, - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, showSecrets: showQuoteSecrets) - .lineLimit(lines) - .font(.subheadline) - .padding(.bottom, 6) - ) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline) + .lineLimit(lines) + .padding(.bottom, 6) } private func ciQuoteIconView(_ image: String) -> some View { @@ -297,21 +293,21 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText - let v = toggleSecrets(ft, $showSecrets, MsgContentView( + let v = MsgContentView( chat: chat, text: text, formattedText: ft, + textStyle: .body, meta: ci.meta, mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, - showSecrets: showSecrets, prefix: txtPrefix - )) + ) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) .padding(.horizontal, 12) @@ -351,14 +347,6 @@ struct FramedItemView: View { } } -@ViewBuilder func toggleSecrets(_ ft: [FormattedText]?, _ showSecrets: Binding, _ v: V) -> some View { - if let ft = ft, ft.contains(where: { $0.isSecret }) { - v.onTapGesture { showSecrets.wrappedValue.toggle() } - } else { - v - } -} - func isRightToLeft(_ s: String) -> Bool { if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) { return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index c3aad9490d..10e5efa298 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -126,7 +126,7 @@ struct FullScreenMediaView: View { .scaledToFit() } } - .onTapGesture { showView = false } + .onTapGesture { showView = false } // this is used in full screen view, onTapGesture works } private func videoView( _ player: AVPlayer, _ url: URL) -> some View { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index c6d1afa04a..47a30f6cf3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -71,7 +71,7 @@ struct CIMsgError: View { .padding(.vertical, 6) .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .textSelection(.disabled) - .onTapGesture(perform: onTap) + .simultaneousGesture(TapGesture().onEnded(onTap)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index d82b8314a7..d8dbd673f4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -11,17 +11,16 @@ import SimpleXChat let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) -private let noTyping = Text(verbatim: " ") - -private let typingIndicators: [Text] = [ - (typing(.black) + typing() + typing()), - (typing(.bold) + typing(.black) + typing()), - (typing() + typing(.bold) + typing(.black)), - (typing() + typing() + typing(.bold)) -] - -private func typing(_ w: Font.Weight = .light) -> Text { - Text(verbatim: ".").fontWeight(w) +private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.Weight]) -> NSMutableAttributedString { + let res = NSMutableAttributedString() + for w in ws { + res.append(NSAttributedString(string: ".", attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: w), + .kern: -2 as NSNumber, + .foregroundColor: UIColor(theme.colors.secondary) + ])) + } + return res } struct MsgContentView: View { @@ -30,34 +29,55 @@ struct MsgContentView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil + var textStyle: UIFont.TextStyle var sender: String? = nil var meta: CIMeta? = nil var mentions: [String: CIMention]? = nil var userMemberId: String? = nil var rightToLeft = false - var showSecrets: Bool - var prefix: Text? = nil + var prefix: NSAttributedString? = nil + @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? + @State private var typingIndicators: [NSAttributedString] = [] + @State private var noTyping = NSAttributedString(string: " ") + @State private var phase: CGFloat = 0 @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false var body: some View { + let v = msgContentView() if meta?.isLive == true { - msgContentView() - .onAppear { switchTyping() } + v.onAppear { + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + noTyping = NSAttributedString(string: " ", attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular), + .kern: -2 as NSNumber, + .foregroundColor: UIColor(theme.colors.secondary) + ]) + switchTyping() + } .onDisappear(perform: stopTyping) .onChange(of: meta?.isLive, perform: switchTyping) .onChange(of: meta?.recent, perform: switchTyping) } else { - msgContentView() + v } } private func switchTyping(_: Bool? = nil) { if let meta = meta, meta.isLive && meta.recent { + if typingIndicators.isEmpty { + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + typingIndicators = [ + typing(theme, descr, [.black, .light, .light]), + typing(theme, descr, [.bold, .black, .light]), + typing(theme, descr, [.light, .bold, .black]), + typing(theme, descr, [.light, .light, .bold]) + ] + } timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in - typingIdx = (typingIdx + 1) % typingIndicators.count + typingIdx = typingIdx + 1 } } else { stopTyping() @@ -67,119 +87,221 @@ struct MsgContentView: View { private func stopTyping() { timer?.invalidate() timer = nil + typingIdx = 0 } - private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) + @inline(__always) + private func msgContentView() -> some View { + let s = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) + let t: Text if let mt = meta { if mt.isLive { - v = v + typingIndicator(mt.recent) + s.append(typingIndicator(mt.recent)) } - v = v + reserveSpaceForMeta(mt) + t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) + } else { + t = Text(AttributedString(s)) } - return v + return t.overlay(handleTextLinks(s, showSecrets: $showSecrets)) } - private func typingIndicator(_ recent: Bool) -> Text { - return (recent ? typingIndicators[typingIdx] : noTyping) - .font(.body.monospaced()) - .kerning(-2) - .foregroundColor(theme.colors.secondary) + @inline(__always) + private func typingIndicator(_ recent: Bool) -> NSAttributedString { + recent && !typingIndicators.isEmpty + ? typingIndicators[typingIdx % 4] + : noTyping } + @inline(__always) private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, mentions: [String: CIMention]?, userMemberId: String?, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { - let s = text - var res: Text - - if let ft = formattedText, ft.count > 0 && ft.count <= 200 { - res = formatText(ft[0], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId) - var i = 1 - while i < ft.count { - res = res + formatText(ft[i], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId) - i = i + 1 - } - } else { - res = Text(s) - } - - if let i = icon { - res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res - } - - if let p = prefix { - res = p + res - } - - if let s = sender { - let t = Text(s) - return (preview ? t : t.fontWeight(.medium)) + Text(verbatim: ": ") + res - } else { - return res - } -} - -private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool, mentions: [String: CIMention]?, userMemberId: String?) -> Text { - let t = ft.text - if let f = ft.format { - switch (f) { - case .bold: return Text(t).bold() - case .italic: return Text(t).italic() - case .strikeThrough: return Text(t).strikethrough() - case .snippet: return Text(t).font(.body.monospaced()) - case .secret: return - showSecret - ? Text(t) - : Text(AttributedString(t, attributes: AttributeContainer([ - .foregroundColor: UIColor.clear as Any, - .backgroundColor: UIColor.secondarySystemFill as Any - ]))) - case let .colored(color): return Text(t).foregroundColor(color.uiColor) - case .uri: return linkText(t, t, preview, prefix: "") - case let .simplexLink(linkType, simplexUri, smpHosts): - switch privacySimplexLinkModeDefault.get() { - case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") - case .full: return linkText(t, simplexUri, preview, prefix: "") - case .browser: return linkText(t, simplexUri, preview, prefix: "") - } - case let .mention(memberName): - if let m = mentions?[memberName] { - if let ref = m.memberRef { - let name: String = if let alias = ref.localAlias, alias != "" { - "\(alias) (\(ref.displayName))" - } else { - ref.displayName +func handleTextLinks(_ s: NSAttributedString, showSecrets: Binding>? = nil) -> some View { + return GeometryReader { g in + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { event in + let t = event.translation + if t.width * t.width + t.height * t.height > 100 { return } + let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString) + let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil) + let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil) + let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit + var index: CFIndex? + if let lines = CTFrameGetLines(frame) as? [CTLine] { + var origins = [CGPoint](repeating: .zero, count: lines.count) + CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) + for i in 0 ..< lines.count { + let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds) + if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) { + index = CTLineGetStringIndexForPosition(lines[i], point) + break + } } - let tName = mentionText(name) - return m.memberId == userMemberId ? tName.foregroundColor(.accentColor) : tName - } else { - return mentionText(memberName) } + if let index, let (url, browser) = attributedStringLink(s, for: index) { + if browser { + openBrowserAlert(uri: url) + } else { + UIApplication.shared.open(url) + } + } + }) + } + + func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? { + var linkURL: URL? + var browser: Bool = false + s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in + if index >= range.location && index < range.location + range.length { + if let url = attrs[linkAttrKey] as? NSURL { + linkURL = url.absoluteURL + browser = attrs[webLinkAttrKey] != nil + } else if let showSecrets, let i = attrs[secretAttrKey] as? Int { + if showSecrets.wrappedValue.contains(i) { + showSecrets.wrappedValue.remove(i) + } else { + showSecrets.wrappedValue.insert(i) + } + } + stop.pointee = true } - return Text(t) - case .email: return linkText(t, t, preview, prefix: "mailto:") - case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") } - } else { - return Text(t) + return if let linkURL { (linkURL, browser) } else { nil } } } -private func mentionText(_ name: String) -> Text { - Text(verbatim: name.contains(" @") ? "@'\(name)'" : "@\(name)").fontWeight(.semibold) -} +private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link") -private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text { - preview - ? Text(s).foregroundColor(color).underline(color: color) - : Text(AttributedString(s, attributes: AttributeContainer([ - .link: NSURL(string: prefix + link) as Any, - .foregroundColor: uiColor as Any - ]))).underline() +private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink") + +private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") + +func messageText(_ text: String, _ formattedText: [FormattedText]?, textStyle: UIFont.TextStyle = .body, sender: String?, preview: Bool = false, mentions: [String: CIMention]?, userMemberId: String?, showSecrets: Set?, secondaryColor: Color, prefix: NSAttributedString? = nil) -> NSMutableAttributedString { + let res = NSMutableAttributedString() + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) + let font = UIFont.preferredFont(forTextStyle: textStyle) + let plain: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.label + ] + var link: [NSAttributedString.Key: Any]? + + if let sender { + if preview { + res.append(NSAttributedString(string: sender + ": ", attributes: plain)) + } else { + var attrs = plain + attrs[.font] = UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.medium]]), size: descr.pointSize) + res.append(NSAttributedString(string: sender, attributes: attrs)) + res.append(NSAttributedString(string: ": ", attributes: plain)) + } + } + + if let prefix { + res.append(prefix) + } + + if let fts = formattedText, fts.count > 0 { + var bold: UIFont? + var italic: UIFont? + var snippet: UIFont? + var mention: UIFont? + var secretIdx: Int = 0 + for ft in fts { + var t = ft.text + var attrs = plain + switch (ft.format) { + case .bold: + bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) + attrs[.font] = bold + case .italic: + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + case .strikeThrough: + attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + case .snippet: + snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular) + attrs[.font] = snippet + case .secret: + if let showSecrets { + if !showSecrets.contains(secretIdx) { + attrs[.foregroundColor] = UIColor.clear + attrs[.backgroundColor] = UIColor.secondarySystemFill // secretColor + } + attrs[secretAttrKey] = secretIdx + secretIdx += 1 + } else { + attrs[.foregroundColor] = UIColor.clear + attrs[.backgroundColor] = UIColor.secondarySystemFill + } + case let .colored(color): + if let c = color.uiColor { + attrs[.foregroundColor] = UIColor(c) + } + case .uri: + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = NSURL(string: ft.text) + attrs[webLinkAttrKey] = true + } + case let .simplexLink(linkType, simplexUri, smpHosts): + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = NSURL(string: simplexUri) + } + if case .description = privacySimplexLinkModeDefault.get() { + t = simplexLinkText(linkType, smpHosts) + } + case let .mention(memberName): + if let m = mentions?[memberName] { + mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize) + attrs[.font] = mention + if let ref = m.memberRef { + let name: String = if let alias = ref.localAlias, alias != "" { + "\(alias) (\(ref.displayName))" + } else { + ref.displayName + } + if m.memberId == userMemberId { + attrs[.foregroundColor] = UIColor.tintColor + } + t = "@'\(name)'" + } else { + t = "@'\(memberName)'" + } + } + case .email: + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text) + } + case .phone: + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: "")) + } + case .none: () + } + res.append(NSAttributedString(string: t, attributes: attrs)) + } + } else { + res.append(NSMutableAttributedString(string: text, attributes: plain)) + } + + return res + + func linkAttrs() -> [NSAttributedString.Key: Any] { + link = link ?? [ + .font: font, + .foregroundColor: uiLinkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return link! + } } func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { @@ -193,9 +315,9 @@ struct MsgContentView_Previews: PreviewProvider { chat: Chat.sampleData, text: chatItem.text, formattedText: chatItem.formattedText, + textStyle: .body, sender: chatItem.memberDisplayName, - meta: chatItem.meta, - showSecrets: false + meta: chatItem.meta ) .environmentObject(Chat.sampleData) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 0fe9f5d4c2..1b840a1547 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -274,10 +274,11 @@ struct ChatItemInfoView: View { var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? - @State private var showSecrets = false + @State private var showSecrets: Set = [] var body: some View { - toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) + let s = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) + Text(AttributedString(s)).overlay(handleTextLinks(s, showSecrets: $showSecrets)) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index d5b5e6ccf4..d45cc9abc4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> Text { - return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) + return attachment() + Text(AttributedString(messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, secondaryColor: theme.colors.secondary))) func attachment() -> Text { let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 30767d66ec..d7b29a0ecb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -263,13 +263,12 @@ struct SendMessageView: View { @State private var pressed: TimeInterval? = nil var body: some View { - Button(action: {}) { - Image(systemName: "mic.fill") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .foregroundColor(theme.colors.primary) - } + Image(systemName: "mic.fill") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(theme.colors.primary) + .opacity(holdingVMR ? 0.7 : 1) .disabled(disabled) .frame(width: 31, height: 31) .padding([.bottom, .trailing], 4) @@ -279,9 +278,7 @@ struct SendMessageView: View { pressed = ProcessInfo.processInfo.systemUptime startVoiceMessageRecording?() } else { - let now = ProcessInfo.processInfo.systemUptime - if let pressed = pressed, - now - pressed >= 1 { + if let pressed, ProcessInfo.processInfo.systemUptime - pressed >= 1 { finishVoiceMessageRecording?() } holdingVMR = false diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index 58bfe182cb..4dd2b9e683 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -58,7 +58,9 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, mentions: nil, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) + let s = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: nil, secondaryColor: theme.colors.secondary) + return Text(AttributedString(s)) + .overlay(handleTextLinks(s)) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 7f92862f66..c4b584061d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -263,7 +263,7 @@ struct ChatPreviewView: View { let msg = draft.message return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) + attachment() - + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) + + Text(AttributedString(messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, secondaryColor: theme.colors.secondary))) func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { Text(Image(systemName: s)).foregroundColor(color) + textSpace @@ -282,7 +282,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> Text { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) + return Text(AttributedString(messageText(itemText, itemFormattedText, sender: cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, secondaryColor: theme.colors.secondary, prefix: prefix()))) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type @@ -309,10 +309,10 @@ struct ChatPreviewView: View { } } - func prefix() -> Text { + func prefix() -> NSAttributedString? { switch cItem.content.msgContent { - case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) - default: return Text("") + case let .report(_, reason): reason.attrString + default: nil } } } @@ -348,7 +348,6 @@ struct ChatPreviewView: View { } @ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View { - let linkClicksEnabled = privacyChatListOpenLinksDefault.get() != PrivacyChatListOpenLinksMode.no let mc = ci.content.msgContent switch mc { case let .link(_, preview): @@ -370,17 +369,7 @@ struct ChatPreviewView: View { .cornerRadius(8) } .onTapGesture { - switch privacyChatListOpenLinksDefault.get() { - case .yes: UIApplication.shared.open(preview.uri) - case .no: ItemsModel.shared.loadOpenChat(chat.id) - case .ask: AlertManager.shared.showAlert( - Alert(title: Text("Open web link?"), - message: Text(preview.uri.absoluteString), - primaryButton: .default(Text("Open chat"), action: { ItemsModel.shared.loadOpenChat(chat.id) }), - secondaryButton: .default(Text("Open link"), action: { UIApplication.shared.open(preview.uri) }) - ) - ) - } + openBrowserAlert(uri: preview.uri) } } case let .image(_, image): diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift index c790b9cff2..3a10cf84d7 100644 --- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift +++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift @@ -9,6 +9,7 @@ import SwiftUI extension View { + @inline(__always) @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { if condition { transform(self) diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 00532c0a8e..44e0b20958 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -38,7 +38,6 @@ extension AppSettings { privacyLinkPreviewsGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } - if let val = privacyChatListOpenLinks { privacyChatListOpenLinksDefault.set(val) } if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } @@ -78,7 +77,6 @@ extension AppSettings { c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) - c.privacyChatListOpenLinks = privacyChatListOpenLinksDefault.get() c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 1a17b9d661..eba7f8066a 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -14,7 +14,6 @@ struct PrivacySettings: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true - @State private var chatListOpenLinks = privacyChatListOpenLinksDefault.get() @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @@ -77,17 +76,6 @@ struct PrivacySettings: View { privacyLinkPreviewsGroupDefault.set(linkPreviews) } } - settingsRow("arrow.up.right.circle", color: theme.colors.secondary) { - Picker("Open links from chat list", selection: $chatListOpenLinks) { - ForEach(PrivacyChatListOpenLinksMode.allCases) { mode in - Text(mode.text) - } - } - } - .frame(height: 36) - .onChange(of: chatListOpenLinks) { mode in - privacyChatListOpenLinksDefault.set(mode) - } settingsRow("message", color: theme.colors.secondary) { Toggle("Show last messages", isOn: $showChatPreviews) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 961cad128f..3321b25793 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -29,7 +29,6 @@ let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group -let DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS = "privacyChatListOpenLinks" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" @@ -185,8 +184,6 @@ let connectViaLinkTabDefault = EnumDefault(defaults: UserDefa let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) -let privacyChatListOpenLinksDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS, withDefault: PrivacyChatListOpenLinksMode.ask) - let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index db8f1dd26e..960fdd466d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4137,18 +4137,16 @@ public enum FormatColor: String, Decodable, Hashable { case black = "black" case white = "white" - public var uiColor: Color { - get { - switch (self) { - case .red: return .red - case .green: return .green - case .blue: return .blue - case .yellow: return .yellow - case .cyan: return .cyan - case .magenta: return .purple - case .black: return .primary - case .white: return .primary - } + public var uiColor: Color? { + switch (self) { + case .red: .red + case .green: .green + case .blue: .blue + case .yellow: .yellow + case .cyan: .cyan + case .magenta: .purple + case .black: nil + case .white: nil } } } @@ -4173,6 +4171,14 @@ public enum ReportReason: Hashable { case let .unknown(type): return type } } + + public var attrString: NSAttributedString { + let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + return NSAttributedString(string: text.isEmpty ? self.text : "\(self.text): ", attributes: [ + .font: UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: 0), + .foregroundColor: UIColor(Color.red) + ]) + } } extension ReportReason: Encodable { diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 23b5f2ddad..1efa69fad4 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -25,8 +25,6 @@ data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Sho data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show) -data OpenLinksSetting = OLSYes | OLSNo | OLSAsk deriving (Show) - data AppSettings = AppSettings { appPlatform :: Maybe AppPlatform, networkConfig :: Maybe NetworkConfig, @@ -35,7 +33,6 @@ data AppSettings = AppSettings privacyAskToApproveRelays :: Maybe Bool, privacyAcceptImages :: Maybe Bool, privacyLinkPreviews :: Maybe Bool, - privacyChatListOpenLinks :: Maybe OpenLinksSetting, privacyShowChatPreviews :: Maybe Bool, privacySaveLastDraft :: Maybe Bool, privacyProtectScreen :: Maybe Bool, @@ -86,7 +83,6 @@ defaultAppSettings = privacyAskToApproveRelays = Just True, privacyAcceptImages = Just True, privacyLinkPreviews = Just True, - privacyChatListOpenLinks = Just OLSAsk, privacyShowChatPreviews = Just True, privacySaveLastDraft = Just True, privacyProtectScreen = Just False, @@ -124,7 +120,6 @@ defaultParseAppSettings = privacyAskToApproveRelays = Nothing, privacyAcceptImages = Nothing, privacyLinkPreviews = Nothing, - privacyChatListOpenLinks = Nothing, privacyShowChatPreviews = Nothing, privacySaveLastDraft = Nothing, privacyProtectScreen = Nothing, @@ -162,7 +157,6 @@ combineAppSettings platformDefaults storedSettings = privacyAskToApproveRelays = p privacyAskToApproveRelays, privacyAcceptImages = p privacyAcceptImages, privacyLinkPreviews = p privacyLinkPreviews, - privacyChatListOpenLinks = p privacyChatListOpenLinks, privacyShowChatPreviews = p privacyShowChatPreviews, privacySaveLastDraft = p privacySaveLastDraft, privacyProtectScreen = p privacyProtectScreen, @@ -203,8 +197,6 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls) $(JQ.deriveJSON (enumJSON $ dropPrefix "NPA") ''NetworkProxyAuth) -$(JQ.deriveJSON (enumJSON $ dropPrefix "OLS") ''OpenLinksSetting) - $(JQ.deriveJSON defaultJSON ''NetworkProxy) $(JQ.deriveToJSON defaultJSON ''AppSettings) @@ -218,7 +210,6 @@ instance FromJSON AppSettings where privacyAskToApproveRelays <- p "privacyAskToApproveRelays" privacyAcceptImages <- p "privacyAcceptImages" privacyLinkPreviews <- p "privacyLinkPreviews" - privacyChatListOpenLinks <- p "privacyChatListOpenLinks" privacyShowChatPreviews <- p "privacyShowChatPreviews" privacySaveLastDraft <- p "privacySaveLastDraft" privacyProtectScreen <- p "privacyProtectScreen" @@ -253,7 +244,6 @@ instance FromJSON AppSettings where privacyAskToApproveRelays, privacyAcceptImages, privacyLinkPreviews, - privacyChatListOpenLinks, privacyShowChatPreviews, privacySaveLastDraft, privacyProtectScreen, From 5b7f3fdd78653385029b61a7871d5ede137ecc06 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 10 May 2025 16:46:54 +0100 Subject: [PATCH 227/289] ios: export localizations --- .../bg.xcloc/Localized Contents/bg.xliff | 24 +++------------ .../cs.xcloc/Localized Contents/cs.xliff | 24 +++------------ .../de.xcloc/Localized Contents/de.xliff | 29 +++--------------- .../en.xcloc/Localized Contents/en.xliff | 30 ++++--------------- .../es.xcloc/Localized Contents/es.xliff | 29 +++--------------- .../fi.xcloc/Localized Contents/fi.xliff | 24 +++------------ .../fr.xcloc/Localized Contents/fr.xliff | 25 +++------------- .../hu.xcloc/Localized Contents/hu.xliff | 29 +++--------------- .../it.xcloc/Localized Contents/it.xliff | 29 +++--------------- .../ja.xcloc/Localized Contents/ja.xliff | 24 +++------------ .../nl.xcloc/Localized Contents/nl.xliff | 29 +++--------------- .../pl.xcloc/Localized Contents/pl.xliff | 25 +++------------- .../ru.xcloc/Localized Contents/ru.xliff | 29 +++--------------- .../th.xcloc/Localized Contents/th.xliff | 24 +++------------ .../tr.xcloc/Localized Contents/tr.xliff | 24 +++------------ .../uk.xcloc/Localized Contents/uk.xliff | 24 +++------------ .../Localized Contents/zh-Hans.xliff | 24 +++------------ 17 files changed, 69 insertions(+), 377 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 995698cf2e..926eb7e20f 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -1063,10 +1063,6 @@ swipe action Архивиране на база данни No comment provided by engineer.
- - Ask - No comment provided by engineer. - Attach Прикачи @@ -5264,7 +5260,7 @@ Requires compatible VPN. Open Отвори - No comment provided by engineer. + alert action Open Settings @@ -5294,23 +5290,15 @@ Requires compatible VPN. Отвори група No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Отвори миграцията към друго устройство authentication reason - - Open web link? - No comment provided by engineer. - Opening app… Приложението се отваря… @@ -8185,10 +8173,6 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. **Не трябва** да използвате една и съща база данни на две устройства. 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 bf7bb307e0..32403dba44 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1016,10 +1016,6 @@ swipe action Archiving database No comment provided by engineer. - - Ask - No comment provided by engineer. - Attach Připojit @@ -5068,7 +5064,7 @@ Vyžaduje povolení sítě VPN. Open Otevřít - No comment provided by engineer. + alert action Open Settings @@ -5097,22 +5093,14 @@ Vyžaduje povolení sítě VPN. Open group No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device authentication reason - - Open web link? - No comment provided by engineer. - Opening app… No comment provided by engineer. @@ -7897,10 +7885,6 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu XFTP server No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 1fb3d61bde..b4751e1014 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -1090,11 +1090,6 @@ swipe action Datenbank wird archiviert No comment provided by engineer. - - Ask - Fragen - No comment provided by engineer. - Attach Anhängen @@ -5545,7 +5540,7 @@ Dies erfordert die Aktivierung eines VPNs. Open Öffnen - No comment provided by engineer. + alert action Open Settings @@ -5577,26 +5572,15 @@ Dies erfordert die Aktivierung eines VPNs. Gruppe öffnen No comment provided by engineer. - - Open link - Web-Link öffnen - No comment provided by engineer. - - - Open links from chat list - Web-Links aus der Chat-Liste öffnen - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Migration auf ein anderes Gerät öffnen authentication reason - - Open web link? - Web-Link öffnen? - No comment provided by engineer. - Opening app… App wird geöffnet… @@ -8676,11 +8660,6 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s XFTP-Server No comment provided by engineer. - - Yes - Ja - No comment provided by engineer. - You **must not** use the same database on two devices. Sie dürfen die selbe Datenbank **nicht** auf zwei Geräten nutzen. 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 0082fa574d..a4a0c07410 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1091,11 +1091,6 @@ swipe action Archiving database No comment provided by engineer. - - Ask - Ask - No comment provided by engineer. - Attach Attach @@ -5546,7 +5541,7 @@ Requires compatible VPN. Open Open - No comment provided by engineer. + alert action Open Settings @@ -5578,26 +5573,16 @@ Requires compatible VPN. Open group No comment provided by engineer. - - Open link - Open link - No comment provided by engineer. - - - Open links from chat list - Open links from chat list - No comment provided by engineer. + + Open in browser? + Open in browser? + alert title Open migration to another device Open migration to another device authentication reason - - Open web link? - Open web link? - No comment provided by engineer. - Opening app… Opening app… @@ -8678,11 +8663,6 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. - - Yes - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. You **must not** use the same database on two devices. 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 3f621bee53..849cff5ebc 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1090,11 +1090,6 @@ swipe action Archivando base de datos No comment provided by engineer. - - Ask - Preguntar - No comment provided by engineer. - Attach Adjuntar @@ -5545,7 +5540,7 @@ Requiere activación de la VPN. Open Abrir - No comment provided by engineer. + alert action Open Settings @@ -5577,26 +5572,15 @@ Requiere activación de la VPN. Grupo abierto No comment provided by engineer. - - Open link - Abrir enlace - No comment provided by engineer. - - - Open links from chat list - Abrir enlaces desde listado de chats - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Abrir menú migración a otro dispositivo authentication reason - - Open web link? - ¿Abrir enlace web? - No comment provided by engineer. - Opening app… Iniciando aplicación… @@ -8676,11 +8660,6 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Servidor XFTP No comment provided by engineer. - - Yes - Si - No comment provided by engineer. - You **must not** use the same database on two devices. **No debes** usar la misma base de datos en dos dispositivos. 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 a6b05ee48a..7e493d5f73 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -998,10 +998,6 @@ swipe action Archiving database No comment provided by engineer. - - Ask - No comment provided by engineer. - Attach Liitä @@ -5044,7 +5040,7 @@ Edellyttää VPN:n sallimista. Open - No comment provided by engineer. + alert action Open Settings @@ -5073,22 +5069,14 @@ Edellyttää VPN:n sallimista. Open group No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device authentication reason - - Open web link? - No comment provided by engineer. - Opening app… No comment provided by engineer. @@ -7870,10 +7858,6 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja XFTP server No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 2928b9f167..80d5ae5f55 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1089,11 +1089,6 @@ swipe action Archivage de la base de données No comment provided by engineer. - - Ask - Demander - No comment provided by engineer. - Attach Attacher @@ -5510,7 +5505,7 @@ Nécessite l'activation d'un VPN. Open Ouvrir - No comment provided by engineer. + alert action Open Settings @@ -5542,23 +5537,15 @@ Nécessite l'activation d'un VPN. Ouvrir le groupe No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Ouvrir le transfert vers un autre appareil authentication reason - - Open web link? - No comment provided by engineer. - Opening app… Ouverture de l'app… @@ -8598,10 +8585,6 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Serveur XFTP No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. Vous **ne devez pas** utiliser la même base de données sur deux appareils. 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 cde7807c44..3d3d8c6383 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -1090,11 +1090,6 @@ swipe action Adatbázis archiválása No comment provided by engineer. - - Ask - Mindig kérdezzen rá - No comment provided by engineer. - Attach Mellékelés @@ -5545,7 +5540,7 @@ VPN engedélyezése szükséges. Open Megnyitás - No comment provided by engineer. + alert action Open Settings @@ -5577,26 +5572,15 @@ VPN engedélyezése szükséges. Csoport megnyitása No comment provided by engineer. - - Open link - Hivatkozás megnyitása - No comment provided by engineer. - - - Open links from chat list - Hivatkozás megnyitása a csevegési listából - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Átköltöztetés indítása egy másik eszközre authentication reason - - Open web link? - Megnyitja a webhivatkozást? - No comment provided by engineer. - Opening app… Az alkalmazás megnyitása… @@ -8676,11 +8660,6 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso XFTP-kiszolgáló No comment provided by engineer. - - Yes - Igen - No comment provided by engineer. - You **must not** use the same database on two devices. **Nem szabad** ugyanazt az adatbázist használni egyszerre két eszközön. 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 31e89b8507..c672096082 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -1090,11 +1090,6 @@ swipe action Archiviazione del database No comment provided by engineer. - - Ask - Chiedi - No comment provided by engineer. - Attach Allega @@ -5545,7 +5540,7 @@ Richiede l'attivazione della VPN. Open Apri - No comment provided by engineer. + alert action Open Settings @@ -5577,26 +5572,15 @@ Richiede l'attivazione della VPN. Apri gruppo No comment provided by engineer. - - Open link - Apri link - No comment provided by engineer. - - - Open links from chat list - Apri i link dall'elenco delle chat - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Apri migrazione ad un altro dispositivo authentication reason - - Open web link? - Aprire il link? - No comment provided by engineer. - Opening app… Apertura dell'app… @@ -8676,11 +8660,6 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Server XFTP No comment provided by engineer. - - Yes - - No comment provided by engineer. - You **must not** use the same database on two devices. **Non devi** usare lo stesso database su due dispositivi. 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 e929df1f35..4dd0d467a1 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1047,10 +1047,6 @@ swipe action Archiving database No comment provided by engineer. - - Ask - No comment provided by engineer. - Attach 添付する @@ -5121,7 +5117,7 @@ VPN を有効にする必要があります。 Open 開く - No comment provided by engineer. + alert action Open Settings @@ -5150,22 +5146,14 @@ VPN を有効にする必要があります。 Open group No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device authentication reason - - Open web link? - No comment provided by engineer. - Opening app… No comment provided by engineer. @@ -7940,10 +7928,6 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 2b2a79731e..43d568f747 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1090,11 +1090,6 @@ swipe action Database archiveren No comment provided by engineer. - - Ask - Vragen - No comment provided by engineer. - Attach Bijvoegen @@ -5545,7 +5540,7 @@ Vereist het inschakelen van VPN. Open Open - No comment provided by engineer. + alert action Open Settings @@ -5577,26 +5572,15 @@ Vereist het inschakelen van VPN. Open groep No comment provided by engineer. - - Open link - Link openen - No comment provided by engineer. - - - Open links from chat list - Open links van chatlijst - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Open de migratie naar een ander apparaat authentication reason - - Open web link? - Weblink openen? - No comment provided by engineer. - Opening app… App openen… @@ -8671,11 +8655,6 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak XFTP server No comment provided by engineer. - - Yes - Ja - No comment provided by engineer. - You **must not** use the same database on two devices. U **mag** niet dezelfde database op twee apparaten gebruiken. 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 5e77f836a9..f8a73a220d 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -1090,11 +1090,6 @@ swipe action Archiwizowanie bazy danych No comment provided by engineer. - - Ask - Zapytaj - No comment provided by engineer. - Attach Dołącz @@ -5424,7 +5419,7 @@ Wymaga włączenia VPN. Open Otwórz - No comment provided by engineer. + alert action Open Settings @@ -5454,23 +5449,15 @@ Wymaga włączenia VPN. Grupa otwarta No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Otwórz migrację na innym urządzeniu authentication reason - - Open web link? - No comment provided by engineer. - Opening app… Otwieranie aplikacji… @@ -8469,10 +8456,6 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Serwer XFTP No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. **Nie możesz** używać tej samej bazy na dwóch urządzeniach. 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 d120909994..2b4a426130 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1090,11 +1090,6 @@ swipe action Подготовка архива No comment provided by engineer. - - Ask - Спросить - No comment provided by engineer. - Attach Прикрепить @@ -5511,7 +5506,7 @@ Requires compatible VPN. Open Открыть - No comment provided by engineer. + alert action Open Settings @@ -5543,26 +5538,15 @@ Requires compatible VPN. Открыть группу No comment provided by engineer. - - Open link - Открыть ссылку - No comment provided by engineer. - - - Open links from chat list - Открыть ссылку из списка чатов - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Открытие миграции на другое устройство authentication reason - - Open web link? - Открыть веб-ссылку? - No comment provided by engineer. - Opening app… Приложение отрывается… @@ -8621,11 +8605,6 @@ To connect, please ask your contact to create another connection link and check XFTP сервер No comment provided by engineer. - - Yes - Да - No comment provided by engineer. - You **must not** use the same database on two devices. Вы **не должны** использовать одну и ту же базу данных на двух устройствах. 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 279fab822a..0398c37c8c 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -990,10 +990,6 @@ swipe action Archiving database No comment provided by engineer. - - Ask - No comment provided by engineer. - Attach แนบ @@ -5023,7 +5019,7 @@ Requires compatible VPN. Open - No comment provided by engineer. + alert action Open Settings @@ -5052,22 +5048,14 @@ Requires compatible VPN. Open group No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device authentication reason - - Open web link? - No comment provided by engineer. - Opening app… No comment provided by engineer. @@ -7840,10 +7828,6 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 2208f65b89..b1f65a1791 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -1073,10 +1073,6 @@ swipe action Veritabanı arşivleniyor No comment provided by engineer. - - Ask - No comment provided by engineer. - Attach Ekle @@ -5436,7 +5432,7 @@ VPN'nin etkinleştirilmesi gerekir. Open - No comment provided by engineer. + alert action Open Settings @@ -5466,23 +5462,15 @@ VPN'nin etkinleştirilmesi gerekir. Grubu aç No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Başka bir cihaza açık geçiş authentication reason - - Open web link? - No comment provided by engineer. - Opening app… Uygulama açılıyor… @@ -8484,10 +8472,6 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste XFTP sunucusu No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. Aynı veritabanını iki cihazda **kullanmamalısınız**. 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 1863bf6cd8..784505ee62 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1073,10 +1073,6 @@ swipe action Архівування бази даних No comment provided by engineer. - - Ask - No comment provided by engineer. - Attach Прикріпити @@ -5451,7 +5447,7 @@ Requires compatible VPN. Open Відкрито - No comment provided by engineer. + alert action Open Settings @@ -5483,23 +5479,15 @@ Requires compatible VPN. Відкрита група No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device Відкрита міграція на інший пристрій authentication reason - - Open web link? - No comment provided by engineer. - Opening app… Відкриваємо програму… @@ -8539,10 +8527,6 @@ To connect, please ask your contact to create another connection link and check XFTP-сервер No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. Ви **не повинні використовувати** одну і ту ж базу даних на двох пристроях. 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 4bc7095bd2..a1ca7d430a 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 @@ -1052,10 +1052,6 @@ swipe action 正在存档数据库 No comment provided by engineer. - - Ask - No comment provided by engineer. - Attach 附件 @@ -5347,7 +5343,7 @@ Requires compatible VPN. Open 打开 - No comment provided by engineer. + alert action Open Settings @@ -5377,23 +5373,15 @@ Requires compatible VPN. 打开群 No comment provided by engineer. - - Open link - No comment provided by engineer. - - - Open links from chat list - No comment provided by engineer. + + Open in browser? + alert title Open migration to another device 打开迁移到另一台设备 authentication reason - - Open web link? - No comment provided by engineer. - Opening app… 正在打开应用程序… @@ -8374,10 +8362,6 @@ To connect, please ask your contact to create another connection link and check XFTP 服务器 No comment provided by engineer. - - Yes - No comment provided by engineer. - You **must not** use the same database on two devices. 您 **不得** 在两台设备上使用相同的数据库。 From d33869603590a24498f7d16296d947335ecf807c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 10 May 2025 17:23:53 +0100 Subject: [PATCH 228/289] ios: 6.3.4 (build 275) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e96c45b474..af1eebd8b3 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1971,7 +1971,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2021,7 +2021,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2063,7 +2063,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2083,7 +2083,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2108,7 +2108,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2145,7 +2145,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2182,7 +2182,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2233,7 +2233,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 274; + CURRENT_PROJECT_VERSION = 275; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -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 = 274; + CURRENT_PROJECT_VERSION = 275; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -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 = 274; + CURRENT_PROJECT_VERSION = 275; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 8d54acef9220d7ae32ea76de01dbbff3c148bae0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 11 May 2025 14:15:14 +0100 Subject: [PATCH 229/289] ios: only handle taps on messages with links or secrets, use image for secret markdown (#5885) * ios: use image for secret markdown * remove unnecessary ViewBuilders --- .../vertical_logo.imageset/Contents.json | 23 ++ .../vertical_logo_x1.png | Bin 0 -> 1234 bytes .../vertical_logo_x2.png | Bin 0 -> 1617 bytes .../vertical_logo_x3.png | Bin 0 -> 2010 bytes apps/ios/Shared/ContentView.swift | 4 +- .../Shared/Views/Call/ActiveCallView.swift | 6 +- .../Views/Chat/ChatItem/CICallItemView.swift | 2 +- .../Views/Chat/ChatItem/CILinkView.swift | 2 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 2 +- .../Views/Chat/ChatItem/FramedItemView.swift | 7 +- .../Views/Chat/ChatItem/MsgContentView.swift | 61 +++- .../Views/Chat/ChatItemForwardingView.swift | 2 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 41 +-- apps/ios/Shared/Views/Chat/ChatItemView.swift | 9 + apps/ios/Shared/Views/Chat/ChatView.swift | 7 +- .../Chat/ComposeMessage/ContextItemView.swift | 6 +- .../Chat/Group/AddGroupMembersView.swift | 4 +- .../Views/Chat/Group/GroupChatInfoView.swift | 17 +- .../Views/Chat/Group/GroupWelcomeView.swift | 6 +- .../Views/ChatList/ChatListNavLink.swift | 4 +- .../Shared/Views/ChatList/ChatListView.swift | 6 +- .../Views/ChatList/ChatPreviewView.swift | 28 +- .../Views/ChatList/ServersSummaryView.swift | 8 +- .../Shared/Views/ChatList/TagListView.swift | 2 +- .../Views/Contacts/ContactListNavLink.swift | 4 +- .../Views/Database/DatabaseErrorView.swift | 2 +- .../Views/LocalAuth/PasscodeEntry.swift | 2 +- .../Views/NewChat/NewChatMenuButton.swift | 4 +- .../Shared/Views/NewChat/NewChatView.swift | 2 +- .../Onboarding/ChooseServerOperators.swift | 4 +- .../NetworkAndServers/OperatorView.swift | 8 +- .../ProtocolServersView.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 262 +++++++++--------- .../Views/UserSettings/UserProfile.swift | 1 - .../Views/UserSettings/UserProfilesView.swift | 4 +- .../bg.xcloc/Localized Contents/bg.xliff | 4 +- .../cs.xcloc/Localized Contents/cs.xliff | 4 +- .../de.xcloc/Localized Contents/de.xliff | 4 +- .../en.xcloc/Localized Contents/en.xliff | 6 +- .../es.xcloc/Localized Contents/es.xliff | 4 +- .../fi.xcloc/Localized Contents/fi.xliff | 4 +- .../fr.xcloc/Localized Contents/fr.xliff | 4 +- .../hu.xcloc/Localized Contents/hu.xliff | 4 +- .../it.xcloc/Localized Contents/it.xliff | 4 +- .../ja.xcloc/Localized Contents/ja.xliff | 4 +- .../nl.xcloc/Localized Contents/nl.xliff | 4 +- .../pl.xcloc/Localized Contents/pl.xliff | 4 +- .../ru.xcloc/Localized Contents/ru.xliff | 4 +- .../th.xcloc/Localized Contents/th.xliff | 4 +- .../tr.xcloc/Localized Contents/tr.xliff | 4 +- .../uk.xcloc/Localized Contents/uk.xliff | 4 +- .../Localized Contents/zh-Hans.xliff | 4 +- apps/ios/SimpleX SE/ShareView.swift | 2 +- 53 files changed, 352 insertions(+), 264 deletions(-) create mode 100644 apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png create mode 100644 apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png create mode 100644 apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json new file mode 100644 index 0000000000..cb29f09fe1 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "vertical_logo_x1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "vertical_logo_x2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "vertical_logo_x3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png new file mode 100644 index 0000000000000000000000000000000000000000..f916e43ea99bbda5ae2bba007dc73ac5a77c8ea9 GIT binary patch literal 1234 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I?aN0%imoq%CcqSRjz%EbxddW?Q&?xfOIj~R9FF-xv3?I3Kh9IdBs*0wn|_XRzNmLSYJs2tfVB{Rw=?aK*2e`C{@8s z&p^*W$&O1wLBXadCCw_x#SN+*$g@?-C@Cqh($_C9FV`zK*2^zS*Eh7ZwA42+(l;{F z1**_3uFNY*tkBIXR)!b?Gsh*hIJqdZpd>RtPXT0ZVp4u-iLH_n$Rap^xU(cP4PjGW zG1OZ?59)(t^bPe4^s#A6t;oco4I~562KE=kIvbE-R*^xe#rZj)0j?1ok@jPI{ed6ij7$PAl+HdcDm_guZ{q+zHC5s>IKHUmCc6RUB zq1n;Rw@y#?Pe5G*->#Pr7U`|C^33e=aOAz!5F$1ClFIYf&5!Th-TnC9L7x5lj`X|N z{ASYc^4PO_UcmGnJ_n_QKMML5&JgcLY(2J1Hq5vaU>p5IWx2^6@tu+_ zcWPrcZ>~EK#+{T`*e4-;-N0M=?eiX$azFjxd6TA`nd|b&PibHUB)wIukE-|oopnYXt(t9jmHI>-IvAhdr_44 Zw_a>YvSZQGceg<~)zj6_Wt~$(69AmVcuD{O literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png b/apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png new file mode 100644 index 0000000000000000000000000000000000000000..bb35878f0c4aec928c3e16b4d10603dfc2a14748 GIT binary patch literal 1617 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 L)z4*}Q$iB}rr7 some View { + func allViews() -> some View { ZStack { let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. @@ -209,7 +209,7 @@ struct ContentView: View { } } - @ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View { + private func activeCallInteractiveArea(_ call: Call) -> some View { HStack { Text(call.contact.displayName).font(.body).foregroundColor(.white) Spacer() diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 7c8996a99b..ab7a47b944 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -467,7 +467,7 @@ struct ActiveCallOverlay: View { .disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true) } - @ViewBuilder private func flipCameraButton() -> some View { + private func flipCameraButton() -> some View { controlButton(call, "arrow.triangle.2.circlepath", padding: 12) { Task { if await WebRTCClient.isAuthorized(for: .video) { @@ -477,11 +477,11 @@ struct ActiveCallOverlay: View { } } - @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { + private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform) } - @ViewBuilder private func audioDevicePickerButton() -> some View { + private func audioDevicePickerButton() -> some View { AudioDevicePicker() .opacity(0.8) .scaleEffect(2) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 024aeed96a..0283e9c07e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -50,7 +50,7 @@ struct CICallItemView: View { Image(systemName: "phone.connection").foregroundColor(.green) } - @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View { + private func endedCallIcon(_ sent: Bool) -> some View { HStack { Image(systemName: "phone.down") Text(durationText(duration)).foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 4f879db426..273c9de408 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -46,7 +46,7 @@ struct CILinkView: View { func openBrowserAlert(uri: URL) { showAlert( - NSLocalizedString("Open in browser?", comment: "alert title"), + NSLocalizedString("Open link?", comment: "alert title"), message: uri.absoluteString, actions: {[ UIAlertAction( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index e26fb62a71..4e5713c263 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -68,7 +68,7 @@ struct CIRcvDecryptionError: View { } } - @ViewBuilder private func viewBody() -> some View { + private func viewBody() -> some View { Group { if case let .direct(contact) = chat.chatInfo, let contactStats = contact.activeConn?.connectionStats { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 632d4196c2..b27d266d8a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -87,7 +87,7 @@ struct FramedItemView: View { .overlay(DetermineWidth()) .accessibilityLabel("") } - } + } .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) } .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } @@ -201,6 +201,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View { + let backgroundColor = chatItemFrameContextColor(chatItem, theme) let v = ZStack(alignment: .topTrailing) { switch (qi.content) { case let .image(_, image): @@ -242,7 +243,8 @@ struct FramedItemView: View { // if enable this always, size of the framed voice message item will be incorrect after end of playback .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) - .background(chatItemFrameContextColor(chatItem, theme)) + .background(backgroundColor) + .environment(\.containerBackground, UIColor(backgroundColor)) if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { @@ -308,6 +310,7 @@ struct FramedItemView: View { rightToLeft: rtl, prefix: txtPrefix ) + .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) .padding(.horizontal, 12) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index d8dbd673f4..aab4177cbf 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -26,6 +26,7 @@ private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont. struct MsgContentView: View { @ObservedObject var chat: Chat @Environment(\.showTimestamp) var showTimestamp: Bool + @Environment(\.containerBackground) var containerBackground: UIColor @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil @@ -92,7 +93,8 @@ struct MsgContentView: View { @inline(__always) private func msgContentView() -> some View { - let s = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) + let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix) + let s = r.string let t: Text if let mt = meta { if mt.isLive { @@ -102,7 +104,7 @@ struct MsgContentView: View { } else { t = Text(AttributedString(s)) } - return t.overlay(handleTextLinks(s, showSecrets: $showSecrets)) + return msgTextResultView(r, t, showSecrets: $showSecrets) } @inline(__always) @@ -118,7 +120,13 @@ struct MsgContentView: View { } } -func handleTextLinks(_ s: NSAttributedString, showSecrets: Binding>? = nil) -> some View { +func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding>? = nil) -> some View { + t.if(r.hasSecrets, transform: hiddenSecretsView) + .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) } +} + +@inline(__always) +private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding>? = nil) -> some View { return GeometryReader { g in Rectangle() .fill(Color.clear) @@ -174,13 +182,43 @@ func handleTextLinks(_ s: NSAttributedString, showSecrets: Binding>? = } } +func hiddenSecretsView(_ v: V) -> some View { + v.overlay( + GeometryReader { g in + let size = (g.size.width + g.size.height) / 1.4142 + Image("vertical_logo") + .resizable(resizingMode: .tile) + .frame(width: size, height: size) + .rotationEffect(.degrees(45), anchor: .center) + .position(x: g.size.width / 2, y: g.size.height / 2) + .clipped() + .saturation(0.65) + .opacity(0.35) + } + .mask(v) + ) +} + private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link") private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink") private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") -func messageText(_ text: String, _ formattedText: [FormattedText]?, textStyle: UIFont.TextStyle = .body, sender: String?, preview: Bool = false, mentions: [String: CIMention]?, userMemberId: String?, showSecrets: Set?, secondaryColor: Color, prefix: NSAttributedString? = nil) -> NSMutableAttributedString { +typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool) + +func messageText( + _ text: String, + _ formattedText: [FormattedText]?, + textStyle: UIFont.TextStyle = .body, + sender: String?, + preview: Bool = false, + mentions: [String: CIMention]?, + userMemberId: String?, + showSecrets: Set?, + backgroundColor: UIColor, + prefix: NSAttributedString? = nil +) -> MsgTextResult { let res = NSMutableAttributedString() let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) let font = UIFont.preferredFont(forTextStyle: textStyle) @@ -188,7 +226,10 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, textStyle: U .font: font, .foregroundColor: UIColor.label ] + let secretColor = backgroundColor.withAlphaComponent(1) var link: [NSAttributedString.Key: Any]? + var hasSecrets = false + var handleTaps = false if let sender { if preview { @@ -230,14 +271,16 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, textStyle: U if let showSecrets { if !showSecrets.contains(secretIdx) { attrs[.foregroundColor] = UIColor.clear - attrs[.backgroundColor] = UIColor.secondarySystemFill // secretColor + attrs[.backgroundColor] = secretColor } attrs[secretAttrKey] = secretIdx secretIdx += 1 + handleTaps = true } else { attrs[.foregroundColor] = UIColor.clear - attrs[.backgroundColor] = UIColor.secondarySystemFill + attrs[.backgroundColor] = secretColor } + hasSecrets = true case let .colored(color): if let c = color.uiColor { attrs[.foregroundColor] = UIColor(c) @@ -247,11 +290,13 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, textStyle: U if !preview { attrs[linkAttrKey] = NSURL(string: ft.text) attrs[webLinkAttrKey] = true + handleTaps = true } case let .simplexLink(linkType, simplexUri, smpHosts): attrs = linkAttrs() if !preview { attrs[linkAttrKey] = NSURL(string: simplexUri) + handleTaps = true } if case .description = privacySimplexLinkModeDefault.get() { t = simplexLinkText(linkType, smpHosts) @@ -278,11 +323,13 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, textStyle: U attrs = linkAttrs() if !preview { attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text) + handleTaps = true } case .phone: attrs = linkAttrs() if !preview { attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: "")) + handleTaps = true } case .none: () } @@ -292,7 +339,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, textStyle: U res.append(NSMutableAttributedString(string: text, attributes: plain)) } - return res + return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps) func linkAttrs() -> [NSAttributedString.Key: Any] { link = link ?? [ diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index 587957cd5d..dfc620c402 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -41,7 +41,7 @@ struct ChatItemForwardingView: View { .alert(item: $alert) { $0.alert } } - @ViewBuilder private func forwardListView() -> some View { + private func forwardListView() -> some View { VStack(alignment: .leading) { if !chatsToForwardTo.isEmpty { List { diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 1b840a1547..cd75d1b0cd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -131,9 +131,9 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func details() -> some View { + private func details() -> some View { let meta = ci.meta - VStack(alignment: .leading, spacing: 16) { + return VStack(alignment: .leading, spacing: 16) { Text(title) .font(.largeTitle) .bold() @@ -197,7 +197,7 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func historyTab() -> some View { + private func historyTab() -> some View { GeometryReader { g in let maxWidth = (g.size.width - 32) * 0.84 ScrollView { @@ -227,12 +227,13 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { - VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil) + private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { + let backgroundColor = chatItemFrameColor(ci, theme) + return VStack(alignment: .leading, spacing: 4) { + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: UIColor(backgroundColor)) .padding(.horizontal, 12) .padding(.vertical, 6) - .background(chatItemFrameColor(ci, theme)) + .background(backgroundColor) .modifier(ChatItemClipped()) .contextMenu { if itemVersion.msgContent.text != "" { @@ -257,9 +258,9 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId) + TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) } else { Text("no text") .italic() @@ -274,15 +275,16 @@ struct ChatItemInfoView: View { var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? + var backgroundColor: UIColor @State private var showSecrets: Set = [] var body: some View { - let s = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) - Text(AttributedString(s)).overlay(handleTextLinks(s, showSecrets: $showSecrets)) + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor) + return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) } } - @ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View { + private func quoteTab(_ qi: CIQuote) -> some View { GeometryReader { g in let maxWidth = (g.size.width - 32) * 0.84 ScrollView { @@ -300,9 +302,10 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { - VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil)) + private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { + let backgroundColor = quotedMsgFrameColor(qi, theme) + return VStack(alignment: .leading, spacing: 4) { + textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: UIColor(backgroundColor)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) @@ -335,7 +338,7 @@ struct ChatItemInfoView: View { : theme.appColors.receivedMessage } - @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { + private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { ScrollView { VStack(alignment: .leading, spacing: 16) { details() @@ -373,7 +376,7 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { + private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { HStack { ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48) .padding(.trailing, 6) @@ -404,7 +407,7 @@ struct ChatItemInfoView: View { } } - @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { + private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { ScrollView { VStack(alignment: .leading, spacing: 16) { details() @@ -419,7 +422,7 @@ struct ChatItemInfoView: View { .frame(maxHeight: .infinity, alignment: .top) } - @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { + private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { LazyVStack(alignment: .leading, spacing: 12) { let mss = membersStatuses(memberDeliveryStatuses) if !mss.isEmpty { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5d09e153d5..f5558bcd93 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -18,6 +18,10 @@ extension EnvironmentValues { static let defaultValue: Bool = true } + struct ContainerBackground: EnvironmentKey { + static let defaultValue: UIColor = .clear + } + var showTimestamp: Bool { get { self[ShowTimestamp.self] } set { self[ShowTimestamp.self] = newValue } @@ -27,6 +31,11 @@ extension EnvironmentValues { get { self[Revealed.self] } set { self[Revealed.self] = newValue } } + + var containerBackground: UIColor { + get { self[ContainerBackground.self] } + set { self[ContainerBackground.self] = newValue } + } } struct ChatItemView: View { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1349996683..fc80eb6dec 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -71,10 +71,9 @@ struct ChatView: View { } } - @ViewBuilder private var viewBody: some View { let cInfo = chat.chatInfo - ZStack { + return ZStack { let wallpaperImage = theme.wallpaper.type.image let wallpaperType = theme.wallpaper.type let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background) @@ -1528,9 +1527,9 @@ struct ChatView: View { } } - @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { + func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - VStack(alignment: alignment.horizontal, spacing: 3) { + return VStack(alignment: alignment.horizontal, spacing: 3) { HStack { if ci.chatDir.sent { goToItemButton(true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index d45cc9abc4..845442c75f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -70,8 +70,10 @@ struct ContextItemView: View { .lineLimit(lines) } - private func contextMsgPreview(_ contextItem: ChatItem) -> Text { - return attachment() + Text(AttributedString(messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, secondaryColor: theme.colors.secondary))) + private func contextMsgPreview(_ contextItem: ChatItem) -> some View { + let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background)) + let t = attachment() + Text(AttributedString(r.string)) + return t.if(r.hasSecrets, transform: hiddenSecretsView) func attachment() -> Text { let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) { diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 66fe67a29e..7cd543af10 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -145,9 +145,9 @@ struct AddGroupMembersViewCommon: View { return dummy }() - @ViewBuilder private func inviteMembersButton() -> some View { + private func inviteMembersButton() -> some View { let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat" - Button { + return Button { inviteMembers() } label: { HStack { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 9fa07bc391..96a4981be0 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -292,9 +292,9 @@ struct GroupChatInfoView: View { .disabled(!groupInfo.ready || chat.chatItems.isEmpty) } - @ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View { - if chat.chatInfo.incognito { - ZStack { + private func addMembersActionButton(width: CGFloat) -> some View { + ZStack { + if chat.chatInfo.incognito { InfoViewButton(image: "link.badge.plus", title: "invite", width: width) { groupLinkNavLinkActive = true } @@ -306,10 +306,7 @@ struct GroupChatInfoView: View { } .frame(width: 1, height: 1) .hidden() - } - .disabled(!groupInfo.ready) - } else { - ZStack { + } else { InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) { addMembersNavLinkActive = true } @@ -322,8 +319,8 @@ struct GroupChatInfoView: View { .frame(width: 1, height: 1) .hidden() } - .disabled(!groupInfo.ready) } + .disabled(!groupInfo.ready) } private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { @@ -569,9 +566,9 @@ struct GroupChatInfoView: View { } } - @ViewBuilder private func leaveGroupButton() -> some View { + private func leaveGroupButton() -> some View { let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" - Button(role: .destructive) { + return Button(role: .destructive) { alert = .leaveGroupAlert } label: { Label(label, systemImage: "rectangle.portrait.and.arrow.right") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index 4dd2b9e683..97bff70efb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -18,6 +18,7 @@ struct GroupWelcomeView: View { @State private var editMode = true @FocusState private var keyboardVisible: Bool @State private var showSaveDialog = false + @State private var showSecrets: Set = [] let maxByteCount = 1200 @@ -58,9 +59,8 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - let s = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: nil, secondaryColor: theme.colors.secondary) - return Text(AttributedString(s)) - .overlay(handleTextLinks(s)) + let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background)) + return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f5234ed331..f9cf5e98e4 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -90,7 +90,7 @@ struct ChatListNavLink: View { .actionSheet(item: $actionSheet) { $0.actionSheet } } - @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { + private func contactNavLink(_ contact: Contact) -> some View { Group { if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) @@ -243,7 +243,7 @@ struct ChatListNavLink: View { } } - @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { + private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { NavLinkPlain( chatId: chat.chatInfo.id, selection: $chatModel.chatId, diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 93c18f28cc..5c491b6303 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -335,9 +335,9 @@ struct ChatListView: View { } } - @ViewBuilder private var chatList: some View { + private var chatList: some View { let cs = filteredChats() - ZStack { + return ZStack { ScrollViewReader { scrollProxy in List { if !chatModel.chats.isEmpty { @@ -804,7 +804,7 @@ struct TagsView: View { } } - @ViewBuilder private func expandedPresetTagsFiltersView() -> some View { + private func expandedPresetTagsFiltersView() -> some View { ForEach(PresetTag.allCases, id: \.id) { tag in if (chatTagsModel.presetTags[tag] ?? 0) > 0 { expandedTagFilterView(tag) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index c4b584061d..b8c8233e6e 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -187,13 +187,14 @@ struct ChatPreviewView: View { .kerning(-2) } - private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View { + private func chatPreviewLayout(_ text: Text?, draft: Bool = false, hasFilePreview: Bool = false, hasSecrets: Bool) -> some View { ZStack(alignment: .topTrailing) { let s = chat.chatStats let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 } let t = text .lineLimit(userFont <= .xxxLarge ? 2 : 1) .multilineTextAlignment(.leading) + .if(hasSecrets, transform: hiddenSecretsView) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, hasFilePreview ? 0 : 8) .padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36)) @@ -259,11 +260,13 @@ struct ChatPreviewView: View { } } - private func messageDraft(_ draft: ComposeState) -> Text { + private func messageDraft(_ draft: ComposeState) -> (Text, Bool) { let msg = draft.message - return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) - + attachment() - + Text(AttributedString(messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, secondaryColor: theme.colors.secondary))) + let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background)) + return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) + + attachment() + + Text(AttributedString(r.string)), + r.hasSecrets) func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { Text(Image(systemName: s)).foregroundColor(color) + textSpace @@ -279,10 +282,11 @@ struct ChatPreviewView: View { } } - func chatItemPreview(_ cItem: ChatItem) -> Text { + func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return Text(AttributedString(messageText(itemText, itemFormattedText, sender: cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, secondaryColor: theme.colors.secondary, prefix: prefix()))) + let r = messageText(itemText, itemFormattedText, sender: cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) + return (Text(AttributedString(r.string)), r.hasSecrets) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type @@ -319,9 +323,11 @@ struct ChatPreviewView: View { @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { if chatModel.draftChatId == chat.id, let draft = chatModel.draft { - chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview) + let (t, hasSecrets) = messageDraft(draft) + chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets) } else if let cItem = cItem { - chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview) + let (t, hasSecrets) = chatItemPreview(cItem) + chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets) } else { switch (chat.chatInfo) { case let .direct(contact): @@ -399,7 +405,7 @@ struct ChatPreviewView: View { : chatPreviewInfoText("you are invited to group") } - @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { + private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { Text(text) .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding([.leading, .trailing], 8) @@ -479,7 +485,7 @@ struct ChatPreviewView: View { } } -@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View { +func groupReportsIcon(size: CGFloat) -> some View { Image(systemName: "flag") .resizable() .scaledToFit() diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index 4dbdc81620..8b0a8af888 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -245,7 +245,7 @@ struct ServersSummaryView: View { } } - @ViewBuilder private func smpServersListView( + private func smpServersListView( _ servers: [SMPServerSummary], _ statsStartedAt: Date, _ header: LocalizedStringKey? = nil, @@ -256,7 +256,7 @@ struct ServersSummaryView: View { ? serverAddress($0.smpServer) < serverAddress($1.smpServer) : $0.hasSubs && !$1.hasSubs } - Section { + return Section { ForEach(sortedServers) { server in smpServerView(server, statsStartedAt) } @@ -318,14 +318,14 @@ struct ServersSummaryView: View { return onionHosts == .require ? .indigo : .accentColor } - @ViewBuilder private func xftpServersListView( + private func xftpServersListView( _ servers: [XFTPServerSummary], _ statsStartedAt: Date, _ header: LocalizedStringKey? = nil, _ footer: LocalizedStringKey? = nil ) -> some View { let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) } - Section { + return Section { ForEach(sortedServers) { server in xftpServerView(server, statsStartedAt) } diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift index 74ed9534e0..2063fe15de 100644 --- a/apps/ios/Shared/Views/ChatList/TagListView.swift +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -138,7 +138,7 @@ struct TagListView: View { } } - @ViewBuilder private func radioButton(selected: Bool) -> some View { + private func radioButton(selected: Bool) -> some View { Image(systemName: selected ? "checkmark.circle.fill" : "circle") .imageScale(.large) .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index fe840006cd..456c46d318 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -140,9 +140,9 @@ struct ContactListNavLink: View { } } - @ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { + private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) - ( + return ( contact.verified == true ? verifiedIcon + t : t diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 1ded0acc90..02a1b87826 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -28,7 +28,7 @@ struct DatabaseErrorView: View { } } - @ViewBuilder private func databaseErrorView() -> some View { + private func databaseErrorView() -> some View { VStack(alignment: .center, spacing: 20) { switch status { case let .errorNotADatabase(dbFile): diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift index 609943bcb6..4a6f8e7549 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift @@ -28,7 +28,7 @@ struct PasscodeEntry: View { } } - @ViewBuilder private func passwordView() -> some View { + private func passwordView() -> some View { Text( password == "" ? " " diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 39656c1534..e5263813fa 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -85,7 +85,7 @@ struct NewChatSheet: View { } } - @ViewBuilder private func viewBody(_ showArchive: Bool) -> some View { + private func viewBody(_ showArchive: Bool) -> some View { List { HStack { ContactsListSearchBar( @@ -258,7 +258,7 @@ struct ContactsList: View { } } - @ViewBuilder private func noResultSection(text: String) -> some View { + private func noResultSection(text: String) -> some View { Section { Text(text) .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 2524b5e682..110eda7882 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -506,7 +506,7 @@ private struct ActiveProfilePicker: View { } } - @ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View { + private func profilerPickerUserOption(_ user: User) -> some View { Button { if selectedProfile == user && incognitoEnabled { incognitoEnabled = false diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 45ef186671..17e1735472 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -304,11 +304,11 @@ struct ChooseServerOperators: View { } } - @ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View { + private func operatorCheckView(_ serverOperator: ServerOperator) -> some View { let checked = selectedOperatorIds.contains(serverOperator.operatorId) let icon = checked ? "checkmark.circle.fill" : "circle" let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) - HStack(spacing: 10) { + return HStack(spacing: 10) { Image(serverOperator.largeLogo(colorScheme)) .resizable() .scaledToFit() diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index 24da6a94a8..afbccc109c 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -38,9 +38,9 @@ struct OperatorView: View { .allowsHitTesting(!testing) } - @ViewBuilder private func operatorView() -> some View { + private func operatorView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) - VStack { + return VStack { List { Section { infoViewLink() @@ -500,14 +500,14 @@ struct SingleOperatorUsageConditionsView: View { } } - @ViewBuilder private func acceptConditionsButton() -> some View { + private func acceptConditionsButton() -> some View { let operatorIds = ChatModel.shared.conditions.serverOperators .filter { $0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator ($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted } .map { $0.operatorId } - Button { + return Button { acceptForOperators(operatorIds, operatorIndex) } label: { Text("Accept conditions") diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index ed3c5c773c..b9737914ec 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -38,9 +38,9 @@ struct YourServersView: View { .allowsHitTesting(!testing) } - @ViewBuilder private func yourServersView() -> some View { + private func yourServersView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) - List { + return List { if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { ForEach($userServers[operatorIndex].smpServers) { srv in diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 3321b25793..e06b1c4dd3 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -280,159 +280,159 @@ struct SettingsView: View { } } - @ViewBuilder func settingsView() -> some View { - let user = chatModel.currentUser - List { - Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { - NavigationLink { - NotificationsView() - .navigationTitle("Notifications") - .modifier(ThemedBackground(grouped: true)) - } label: { - HStack { - notificationsIcon() - Text("Notifications") - } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - NetworkAndServers() - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - CallSettings() - .navigationTitle("Your calls") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - PrivacySettings() - .navigationTitle("Your privacy") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") } - } - .disabled(chatModel.chatRunning != true) - - if UIApplication.shared.supportsAlternateIcons { - NavigationLink { - AppearanceSettings() - .navigationTitle("Appearance") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("sun.max", color: theme.colors.secondary) { Text("Appearance") } - } - .disabled(chatModel.chatRunning != true) + func settingsView() -> some View { + List { + let user = chatModel.currentUser + Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { + NavigationLink { + NotificationsView() + .navigationTitle("Notifications") + .modifier(ThemedBackground(grouped: true)) + } label: { + HStack { + notificationsIcon() + Text("Notifications") } } + .disabled(chatModel.chatRunning != true) - Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) { - chatDatabaseRow() - NavigationLink { - MigrateFromDevice(showProgressOnSettings: $showProgress) - .toolbar { - // Redaction broken for `.navigationTitle` - using a toolbar item instead. - ToolbarItem(placement: .principal) { - Text("Migrate device").font(.headline) - } - } - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - } label: { - settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") } - } + NavigationLink { + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } } - - Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { - if let user = user { - NavigationLink { - ChatHelp(dismissSettingsSheet: dismiss) - .navigationTitle("Welcome \(user.displayName)!") - .modifier(ThemedBackground()) - .frame(maxHeight: .infinity, alignment: .top) - } label: { - settingsRow("questionmark", color: theme.colors.secondary) { Text("How to use it") } - } - } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + CallSettings() + .navigationTitle("Your calls") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + PrivacySettings() + .navigationTitle("Your privacy") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") } + } + .disabled(chatModel.chatRunning != true) + + if UIApplication.shared.supportsAlternateIcons { NavigationLink { - WhatsNewView(viaSettings: true, updatedConditions: false) - .modifier(ThemedBackground()) - .navigationBarTitleDisplayMode(.inline) + AppearanceSettings() + .navigationTitle("Appearance") + .modifier(ThemedBackground(grouped: true)) } label: { - settingsRow("plus", color: theme.colors.secondary) { Text("What's new") } + settingsRow("sun.max", color: theme.colors.secondary) { Text("Appearance") } } + .disabled(chatModel.chatRunning != true) + } + } + + Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) { + chatDatabaseRow() + NavigationLink { + MigrateFromDevice(showProgressOnSettings: $showProgress) + .toolbar { + // Redaction broken for `.navigationTitle` - using a toolbar item instead. + ToolbarItem(placement: .principal) { + Text("Migrate device").font(.headline) + } + } + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") } + } + } + + Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { + if let user = user { NavigationLink { - SimpleXInfo(onboarding: false) - .navigationBarTitle("", displayMode: .inline) + ChatHelp(dismissSettingsSheet: dismiss) + .navigationTitle("Welcome \(user.displayName)!") .modifier(ThemedBackground()) .frame(maxHeight: .infinity, alignment: .top) } label: { - settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") } + settingsRow("questionmark", color: theme.colors.secondary) { Text("How to use it") } } - settingsRow("number", color: theme.colors.secondary) { - Button("Send questions and ideas") { - dismiss() - DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) - } + } + NavigationLink { + WhatsNewView(viaSettings: true, updatedConditions: false) + .modifier(ThemedBackground()) + .navigationBarTitleDisplayMode(.inline) + } label: { + settingsRow("plus", color: theme.colors.secondary) { Text("What's new") } + } + NavigationLink { + SimpleXInfo(onboarding: false) + .navigationBarTitle("", displayMode: .inline) + .modifier(ThemedBackground()) + .frame(maxHeight: .infinity, alignment: .top) + } label: { + settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") } + } + settingsRow("number", color: theme.colors.secondary) { + Button("Send questions and ideas") { + dismiss() + DispatchQueue.main.async { + UIApplication.shared.open(simplexTeamURL) } } - .disabled(chatModel.chatRunning != true) - settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") } } + .disabled(chatModel.chatRunning != true) + settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") } + } - Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) { - settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") } - settingsRow("star", color: theme.colors.secondary) { - Button("Rate the app") { - if let scene = sceneDelegate.windowScene { - SKStoreReviewController.requestReview(in: scene) - } + Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) { + settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") } + settingsRow("star", color: theme.colors.secondary) { + Button("Rate the app") { + if let scene = sceneDelegate.windowScene { + SKStoreReviewController.requestReview(in: scene) } } - ZStack(alignment: .leading) { - Image(colorScheme == .dark ? "github_light" : "github") - .resizable() - .frame(width: 24, height: 24) - .opacity(0.5) - .colorMultiply(theme.colors.secondary) - Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)") - .padding(.leading, indent) - } } + ZStack(alignment: .leading) { + Image(colorScheme == .dark ? "github_light" : "github") + .resizable() + .frame(width: 24, height: 24) + .opacity(0.5) + .colorMultiply(theme.colors.secondary) + Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)") + .padding(.leading, indent) + } + } - Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) { - NavigationLink { - DeveloperView() - .navigationTitle("Developer tools") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") } - } - NavigationLink { - VersionView() - .navigationBarTitle("App version") - .modifier(ThemedBackground()) - } label: { - Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") - } + Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) { + NavigationLink { + DeveloperView() + .navigationTitle("Developer tools") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") } + } + NavigationLink { + VersionView() + .navigationBarTitle("App version") + .modifier(ThemedBackground()) + } label: { + Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") } } - .navigationTitle("Your settings") - .modifier(ThemedBackground(grouped: true)) - .onDisappear { - chatModel.showingTerminal = false - chatModel.terminalItems = [] - } + } + .navigationTitle("Your settings") + .modifier(ThemedBackground(grouped: true)) + .onDisappear { + chatModel.showingTerminal = false + chatModel.terminalItems = [] + } } private func chatDatabaseRow() -> some View { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 8a70efbe82..9aa42930bf 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -133,7 +133,6 @@ struct UserProfile: View { .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } } - @ViewBuilder private func overlayButton( _ systemName: String, edge: Edge.Set, diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 781ea4bc34..887023b670 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -221,11 +221,11 @@ struct UserProfilesView: View { !user.hidden ? nil : trimmedSearchTextOrPassword } - @ViewBuilder private func profileActionView(_ action: UserProfileAction) -> some View { + private func profileActionView(_ action: UserProfileAction) -> some View { let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces) let passwordField = PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid) let actionEnabled: (User) -> Bool = { user in actionPassword != "" && passwordValid && correctPassword(user, actionPassword) } - List { + return List { switch action { case let .deleteUser(user, delSMPQueues): actionHeader("Delete profile", user) 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 926eb7e20f..56553b3283 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -5290,8 +5290,8 @@ Requires compatible VPN. Отвори група No comment provided by engineer. - - Open in browser? + + Open link? alert 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 32403dba44..21bf0aef60 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -5093,8 +5093,8 @@ Vyžaduje povolení sítě VPN. Open group No comment provided by engineer. - - Open in browser? + + Open link? alert 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 b4751e1014..6e834157df 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -5572,8 +5572,8 @@ Dies erfordert die Aktivierung eines VPNs. Gruppe öffnen No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 a4a0c07410..641af86c2a 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -5573,9 +5573,9 @@ Requires compatible VPN. Open group No comment provided by engineer. - - Open in browser? - Open in browser? + + Open link? + Open link? alert title 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 849cff5ebc..f5226df190 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -5572,8 +5572,8 @@ Requiere activación de la VPN. Grupo abierto No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 7e493d5f73..5281fbc701 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -5069,8 +5069,8 @@ Edellyttää VPN:n sallimista. Open group No comment provided by engineer. - - Open in browser? + + Open link? alert 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 80d5ae5f55..1e5da0b0ed 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -5537,8 +5537,8 @@ Nécessite l'activation d'un VPN. Ouvrir le groupe No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 3d3d8c6383..c8a29ede41 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -5572,8 +5572,8 @@ VPN engedélyezése szükséges. Csoport megnyitása No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 c672096082..1bfccb3b06 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -5572,8 +5572,8 @@ Richiede l'attivazione della VPN. Apri gruppo No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 4dd0d467a1..26f415dd13 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -5146,8 +5146,8 @@ VPN を有効にする必要があります。 Open group No comment provided by engineer. - - Open in browser? + + Open link? alert 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 43d568f747..681502e255 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -5572,8 +5572,8 @@ Vereist het inschakelen van VPN. Open groep No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 f8a73a220d..01bc0b8508 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -5449,8 +5449,8 @@ Wymaga włączenia VPN. Grupa otwarta No comment provided by engineer. - - Open in browser? + + Open link? alert 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 2b4a426130..a7b63e38ba 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -5538,8 +5538,8 @@ Requires compatible VPN. Открыть группу No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 0398c37c8c..be68dc9780 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -5048,8 +5048,8 @@ Requires compatible VPN. Open group No comment provided by engineer. - - Open in browser? + + Open link? alert 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 b1f65a1791..6eb1daf84b 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -5462,8 +5462,8 @@ VPN'nin etkinleştirilmesi gerekir. Grubu aç No comment provided by engineer. - - Open in browser? + + Open link? alert 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 784505ee62..7c8c6f4254 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -5479,8 +5479,8 @@ Requires compatible VPN. Відкрита група No comment provided by engineer. - - Open in browser? + + Open link? alert title 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 a1ca7d430a..31a333085f 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 @@ -5373,8 +5373,8 @@ Requires compatible VPN. 打开群 No comment provided by engineer. - - Open in browser? + + Open link? alert title diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift index f2b9de9f72..07180ffa1b 100644 --- a/apps/ios/SimpleX SE/ShareView.swift +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -160,7 +160,7 @@ struct ShareView: View { } } - @ViewBuilder private func linkPreview(_ linkPreview: LinkPreview) -> some View { + private func linkPreview(_ linkPreview: LinkPreview) -> some View { previewArea { HStack(alignment: .center, spacing: 8) { if let uiImage = imageFromBase64(linkPreview.image) { From e1aa32952ef94228302e391ed34344a2743ef8a1 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 11 May 2025 15:42:09 +0100 Subject: [PATCH 230/289] ios: unblur media on tap, open/play on the second tap; handle link preview errors (#5886) * ios: unblur media on tap, open/play on the second tap (Xcode 16 regression) * disable link preview spinner on link loading error --- .../Views/Chat/ChatItem/CIImageView.swift | 4 +++- .../Views/Chat/ChatItem/CILinkView.swift | 16 ++++++------- .../Views/Chat/ChatItem/CIVideoView.swift | 24 ++++++++++--------- .../Chat/ComposeMessage/ComposeLinkView.swift | 2 +- .../Chat/ComposeMessage/ComposeView.swift | 9 ++++--- .../Shared/Views/Helpers/ViewModifiers.swift | 4 ++-- 6 files changed, 33 insertions(+), 26 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index ba6a2bd200..d30369339d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -31,7 +31,9 @@ struct CIImageView: View { .if(!smallView) { view in view.modifier(PrivacyBlur(blurred: $blurred)) } - .simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true }) + .if(!blurred) { v in + v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true }) + } .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenImage = false } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 273c9de408..f9dbaede63 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -21,15 +21,15 @@ struct CILinkView: View { .resizable() .scaledToFit() .modifier(PrivacyBlur(blurred: $blurred)) + .if(!blurred) { v in + v.simultaneousGesture(TapGesture().onEnded { + openBrowserAlert(uri: linkPreview.uri) + }) + } } VStack(alignment: .leading, spacing: 6) { Text(linkPreview.title) .lineLimit(3) -// if linkPreview.description != "" { -// Text(linkPreview.description) -// .font(.subheadline) -// .lineLimit(12) -// } Text(linkPreview.uri.absoluteString) .font(.caption) .lineLimit(1) @@ -37,10 +37,10 @@ struct CILinkView: View { } .padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: .leading) + .simultaneousGesture(TapGesture().onEnded { + openBrowserAlert(uri: linkPreview.uri) + }) } - .simultaneousGesture(TapGesture().onEnded { - openBrowserAlert(uri: linkPreview.uri) - }) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 74bce5e583..eacbe9360a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -193,18 +193,20 @@ struct CIVideoView: View { } } .modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) - .simultaneousGesture(TapGesture().onEnded { - switch player.timeControlStatus { - case .playing: - player.pause() - videoPlaying = false - case .paused: - if canBePlayed { - showFullScreenPlayer = true + .if(!blurred) { v in + v.simultaneousGesture(TapGesture().onEnded { + switch player.timeControlStatus { + case .playing: + player.pause() + videoPlaying = false + case .paused: + if canBePlayed { + showFullScreenPlayer = true + } + default: () } - default: () - } - }) + }) + } .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenPlayer = false } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift index 6c44aeea83..e629a984df 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift @@ -18,7 +18,7 @@ struct ComposeLinkView: View { var body: some View { HStack(alignment: .center, spacing: 8) { - if let linkPreview = linkPreview { + if let linkPreview { linkPreviewView(linkPreview) } else { ProgressView() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 6ded9cae72..3e9c340266 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1254,11 +1254,14 @@ struct ComposeView: View { if pendingLinkUrl == url { composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) getLinkPreview(url: url) { linkPreview in - if let linkPreview = linkPreview, - pendingLinkUrl == url { + if let linkPreview, pendingLinkUrl == url { composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) - pendingLinkUrl = nil + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + composeState = composeState.copy(preview: .noPreview) + } } + pendingLinkUrl = nil } } } diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift index 3a10cf84d7..85ef85c611 100644 --- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift +++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift @@ -37,9 +37,9 @@ struct PrivacyBlur: ViewModifier { .overlay { if (blurred && enabled) { Color.clear.contentShape(Rectangle()) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { blurred = false - } + }) } } .onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in From 2a43a02af3570bedce68b15b7dd63632d26613cf Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 May 2025 11:22:35 +0100 Subject: [PATCH 231/289] core, ui: support trailing punctuation for mentions, URIs (also support domains), and email addresses (#5888) * core: improve markdown parser for mentions, URIs, and email addresses * ui --- .../Views/Chat/ChatItem/MsgContentView.swift | 15 ++++-- .../Views/Chat/Group/GroupMentions.swift | 4 +- .../chat/simplex/common/model/ChatModel.kt | 2 +- .../common/views/chat/group/GroupMentions.kt | 10 +++- .../src/Directory/Events.hs | 4 -- .../src/Directory/Service.hs | 2 +- src/Simplex/Chat/Markdown.hs | 52 +++++++++++++------ .../SQLite/Migrations/agent_query_plans.txt | 2 +- tests/Bots/DirectoryTests.hs | 4 +- tests/MarkdownTests.hs | 28 ++++++++-- 10 files changed, 89 insertions(+), 34 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index aab4177cbf..e04584dfff 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -288,7 +288,11 @@ func messageText( case .uri: attrs = linkAttrs() if !preview { - attrs[linkAttrKey] = NSURL(string: ft.text) + let s = t.lowercased() + let link = s.hasPrefix("http://") || s.hasPrefix("https://") + ? t + : "https://" + t + attrs[linkAttrKey] = NSURL(string: link) attrs[webLinkAttrKey] = true handleTaps = true } @@ -314,9 +318,9 @@ func messageText( if m.memberId == userMemberId { attrs[.foregroundColor] = UIColor.tintColor } - t = "@'\(name)'" + t = mentionText(name) } else { - t = "@'\(memberName)'" + t = mentionText(memberName) } } case .email: @@ -351,6 +355,11 @@ func messageText( } } +@inline(__always) +private func mentionText(_ name: String) -> String { + name.contains(" @") ? "@'\(name)'" : "@\(name)" +} + func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { linkType.description + " " + "(via \(smpHosts.first ?? "?"))" } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index 168f0490c3..9bb4a0cc35 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -196,7 +196,9 @@ struct GroupMentionsView: View { newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName) } mentions[newName] = CIMention(groupMember: member.wrapped) - var msgMention = "@" + (newName.contains(" ") ? "'\(newName)'" : newName) + var msgMention = newName.contains(" ") || newName.last?.isPunctuation == true + ? "@'\(newName)'" + : "@\(newName)" var newPos = r.location + msgMention.count let newMsgLength = composeState.message.count + msgMention.count - r.length print(newPos) 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 6660cbbb93..6ee609020a 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 @@ -3902,7 +3902,7 @@ enum class MsgContentTag { class FormattedText(val text: String, val format: Format? = null) { // TODO make it dependent on simplexLinkMode preference fun link(mode: SimplexLinkMode): String? = when (format) { - is Format.Uri -> text + is Format.Uri -> if (text.startsWith("http://", ignoreCase = true) || text.startsWith("https://", ignoreCase = true)) text else "https://$text" is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri is Format.Email -> "mailto:$text" is Format.Phone -> "tel:$text" 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..91f4f5173c 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 @@ -23,6 +23,12 @@ import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch +import kotlin.text.CharCategory.* + +val punctuation = setOf( + DASH_PUNCTUATION, START_PUNCTUATION, END_PUNCTUATION, + CONNECTOR_PUNCTUATION, OTHER_PUNCTUATION +) private val PICKER_ROW_SIZE = MEMBER_ROW_AVATAR_SIZE + (MEMBER_ROW_VERTICAL_PADDING * 2f) private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE + MEMBER_ROW_VERTICAL_PADDING - 4.dp) @@ -126,7 +132,9 @@ fun GroupMentions( } val newName = existingMention?.key ?: composeState.value.mentionMemberName(member.memberProfile.displayName) mentions[newName] = CIMention(member) - var msgMention = "@" + if (newName.contains(" ")) "'$newName'" else newName + var msgMention = if (newName.contains(" ") || (newName.lastOrNull()?.category in punctuation)) + "@'$newName'" + else "@$newName" var newPos = range.start + msgMention.length val newMsgLength = composeState.value.message.text.length + msgMention.length - range.length if (newPos == newMsgLength) { diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 37d2b63d2f..faaccbd2bf 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -16,7 +16,6 @@ module Directory.Events SDirectoryRole (..), crDirectoryEvent, directoryCmdTag, - viewName, ) where @@ -291,9 +290,6 @@ directoryCmdP = -- wordP = spacesP *> A.takeTill isSpace spacesP = A.takeWhile1 isSpace -viewName :: Text -> Text -viewName n = if T.any isSpace n then "'" <> n <> "'" else n - directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case DCHelp _ -> "help" diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 4b02e0b294..4517ee9c5b 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -48,7 +48,7 @@ import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core -import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList) +import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index e5de9c408c..9811556fe0 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} @@ -16,7 +17,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A -import Data.Char (isDigit, isPunctuation, isSpace) +import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace) import Data.Either (fromRight) import Data.Functor (($>)) import Data.List (foldl', intercalate) @@ -204,17 +205,18 @@ markdownP = mconcat <$> A.many' fragmentP else pure $ markdown (colored clr) s mentionP = do c <- A.char '@' *> A.peekChar' - name <- displayNameTextP + (name, punct) <- displayNameTextP_ let sName = if c == '\'' then '\'' `T.cons` name `T.snoc` '\'' else name - pure $ markdown (Mention name) ('@' `T.cons` sName) + mention = markdown (Mention name) ('@' `T.cons` sName) + pure $ if T.null punct then mention else mention :|: unmarked punct colorP = A.anyChar >>= \case - 'r' -> "ed" $> Red <|> pure Red - 'g' -> "reen" $> Green <|> pure Green - 'b' -> "lue" $> Blue <|> pure Blue - 'y' -> "ellow" $> Yellow <|> pure Yellow - 'c' -> "yan" $> Cyan <|> pure Cyan - 'm' -> "agenta" $> Magenta <|> pure Magenta + 'r' -> optional "ed" $> Red + 'g' -> optional "reen" $> Green + 'b' -> optional "lue" $> Blue + 'y' -> optional "ellow" $> Yellow + 'c' -> optional "yan" $> Cyan + 'm' -> optional "agenta" $> Magenta '1' -> pure Red '2' -> pure Green '3' -> pure Blue @@ -236,12 +238,14 @@ markdownP = mconcat <$> A.many' fragmentP wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s - | isUri s = - let t = T.takeWhileEnd isPunctuation' s - uri = uriMarkdown $ T.dropWhileEnd isPunctuation' s - in if T.null t then uri else uri :|: unmarked t - | isEmail s = markdown Email s + | isUri s' = res $ uriMarkdown s' + | isDomain s' = res $ markdown Uri s' + | isEmail s' = res $ markdown Email s' | otherwise = unmarked s + where + punct = T.takeWhileEnd isPunctuation' s + s' = T.dropWhileEnd isPunctuation' s + res md' = if T.null punct then md' else md' :|: unmarked punct isPunctuation' = \case '/' -> False ')' -> False @@ -250,6 +254,16 @@ markdownP = mconcat <$> A.many' fragmentP Right cLink -> markdown (simplexUriFormat cLink) s _ -> markdown Uri s isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] + -- matches what is likely to be a domain, not all valid domain names + isDomain s = case T.splitOn "." s of + [name, tld] -> isDomain_ name tld + [sub, name, tld] -> T.length sub >= 3 && T.length sub <= 8 && isDomain_ name tld + _ -> False + where + isDomain_ name tld = + (let n = T.length name in n >= 1 && n <= 24) + && (let n = T.length tld in n >= 2 && n <= 8) + && (let p c = isAscii c && isAlpha c in T.all p name && T.all p tld) isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked simplexUriFormat :: AConnectionLink -> Format @@ -307,16 +321,22 @@ markdownText (FormattedText f_ t) = case f_ of White -> Nothing displayNameTextP :: Parser Text -displayNameTextP = quoted '\'' <|> takeNameTill (== ' ') +displayNameTextP = displayNameTextP_ >>= \(t, sfx) -> if T.null sfx then pure t else fail "Name ends with punctuation" +{-# INLINE displayNameTextP #-} + +displayNameTextP_ :: Parser (Text, Text) +displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill isSpace where takeNameTill p = A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" + splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s) quoted c = A.char c *> takeNameTill (== c) <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' +-- quotes names that contain spaces or end on punctuation viewName :: Text -> Text -viewName s = if T.any isSpace s then "'" <> s <> "'" else s +viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s) then "'" <> s <> "'" else s $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) 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/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0877a48daa..f8a5aa8b80 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -14,7 +14,6 @@ import Control.Exception (finally) import Control.Monad (forM_, when) import qualified Data.Text as T import Directory.Captcha -import qualified Directory.Events as DE import Directory.Options import Directory.Service import Directory.Store @@ -22,6 +21,7 @@ import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) import Simplex.Chat.Core +import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Types (Profile (..)) @@ -111,7 +111,7 @@ serviceDbPrefix :: FilePath serviceDbPrefix = "directory_service" viewName :: String -> String -viewName = T.unpack . DE.viewName . T.pack +viewName = T.unpack . MD.viewName . T.pack testDirectoryService :: HasCallStack => TestParams -> IO () testDirectoryService ps = diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index fc872f05b1..05d5362cf1 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -192,10 +192,23 @@ textWithUri = describe "text with Uri" do "https://github.com/simplex-chat/ - SimpleX on GitHub" <==> uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" -- "SimpleX on GitHub (https://github.com/simplex-chat/)" <==> "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" "https://en.m.wikipedia.org/wiki/Servo_(software)" <==> uri "https://en.m.wikipedia.org/wiki/Servo_(software)" + "example.com" <==> uri "example.com" + "example.com." <==> uri "example.com" <> "." + "example.com..." <==> uri "example.com" <> "..." + "www.example.com" <==> uri "www.example.com" + "example.academy" <==> uri "example.academy" + "this is example.com" <==> "this is " <> uri "example.com" + "x.com" <==> uri "x.com" it "ignored as markdown" do "_https://simplex.chat" <==> "_https://simplex.chat" "this is _https://simplex.chat" <==> "this is _https://simplex.chat" "this is https://" <==> "this is https://" + "example.c" <==> "example.c" + "www.www.example.com" <==> "www.www.example.com" + "www.example1.com" <==> "www.example1.com" + "www." <==> "www." + ".com" <==> ".com" + "example.academytoolong" <==> "example.academytoolong" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" ("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) @@ -220,12 +233,14 @@ textWithEmail = describe "text with Email" do "test chat.chat+123@simplex.chat" <==> "test " <> email "chat.chat+123@simplex.chat" "chat@simplex.chat test" <==> email "chat@simplex.chat" <> " test" "test1 chat@simplex.chat test2" <==> "test1 " <> email "chat@simplex.chat" <> " test2" - it "ignored as markdown" do + "test chat@simplex.chat." <==> "test " <> email "chat@simplex.chat" <> "." + "test chat@simplex.chat..." <==> "test " <> email "chat@simplex.chat" <> "..." + it "ignored as email markdown" do "chat @simplex.chat" <==> "chat " <> mention "simplex.chat" "@simplex.chat" "this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat" - "this is chat@ simplex.chat" <==> "this is chat@ simplex.chat" - "this is chat @ simplex.chat" <==> "this is chat @ simplex.chat" - "*this* is chat @ simplex.chat" <==> bold "this" <> " is chat @ simplex.chat" + "this is chat@ simplex.chat" <==> "this is chat@ " <> uri "simplex.chat" + "this is chat @ simplex.chat" <==> "this is chat @ " <> uri "simplex.chat" + "*this* is chat @ simplex.chat" <==> bold "this" <> " is chat @ " <> uri "simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -258,8 +273,13 @@ textWithMentions = describe "text with mentions" do "@alice" <==> mention "alice" "@alice" "hello @alice" <==> "hello " <> mention "alice" "@alice" "hello @alice !" <==> "hello " <> mention "alice" "@alice" <> " !" + "hello @alice!" <==> "hello " <> mention "alice" "@alice" <> "!" + "hello @alice..." <==> "hello " <> mention "alice" "@alice" <> "..." + "hello @alice@example.com" <==> "hello " <> mention "alice@example.com" "@alice@example.com" + "hello @'alice @ example.com'" <==> "hello " <> mention "alice @ example.com" "@'alice @ example.com'" "@'alice jones'" <==> mention "alice jones" "@'alice jones'" "hello @'alice jones'!" <==> "hello " <> mention "alice jones" "@'alice jones'" <> "!" + "hello @'a.j.'!" <==> "hello " <> mention "a.j." "@'a.j.'" <> "!" it "ignored as markdown" $ do "hello @'alice jones!" <==> "hello @'alice jones!" "hello @bob @'alice jones!" <==> "hello " <> mention "bob" "@bob" <> " @'alice jones!" From bb2e7baaa8b560a7525d2c43fc1b0524f6f5c6a8 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 May 2025 12:59:03 +0100 Subject: [PATCH 232/289] ios: fix taps on reactions, member profile images, selecting items, icons to navigate to found and forwarded items (Xcode 16 regressions) (#5890) --- apps/ios/Shared/Views/Chat/ChatView.swift | 27 ++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index fc80eb6dec..9e648ef98c 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1276,10 +1276,10 @@ struct ChatView: View { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { Color.clear .contentShape(Rectangle()) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { let checked = selected.contains(chatItem.id) selectUnselectChatItem(select: !checked, chatItem) - } + }) } } if let date = itemSeparation.date { @@ -1468,7 +1468,7 @@ struct ChatView: View { } HStack(alignment: .top, spacing: 10) { MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background) - .onTapGesture { + .simultaneousGesture(TapGesture().onEnded { if let mem = m.getGroupMember(member.groupMemberId) { selectedMember = mem } else { @@ -1477,7 +1477,7 @@ struct ChatView: View { m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1 selectedMember = mem } - } + }) chatItemWithMenu(ci, range, maxWidth, itemSeparation) .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } } @@ -1621,9 +1621,9 @@ struct ChatView: View { .padding(.horizontal, 6) .padding(.vertical, 4) .if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in - v.onTapGesture { + v.simultaneousGesture(TapGesture().onEnded { setReaction(ci, add: !r.userReacted, reaction: r.reaction) - } + }) } switch chat.chatInfo { case let .group(groupInfo): @@ -2213,15 +2213,12 @@ struct ChatView: View { } func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View { - Button { - onClick() - } label: { - Image(systemName: image) - .resizable() - .frame(width: 13, height: 13) - .padding([alignStart ? .trailing : .leading], 10) - .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4)) - } + Image(systemName: image) + .resizable() + .frame(width: 13, height: 13) + .padding([alignStart ? .trailing : .leading], 10) + .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4)) + .simultaneousGesture(TapGesture().onEnded(onClick)) } @ViewBuilder 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 233/289] 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 9e60ce7a600184f5397336505f1d231ec1150d95 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 May 2025 13:33:49 +0100 Subject: [PATCH 234/289] ui: translations (#5891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Italian) 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/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (2058 of 2058 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% (2356 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Chinese (Simplified Han script)) 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/zh_Hans/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 85.6% (1762 of 2058 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 73.6% (1735 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hant/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 56.5% (1164 of 2058 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hant/ * Translated using Weblate (Spanish) 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/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2058 of 2058 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * 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% (2058 of 2058 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% (2058 of 2058 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% (2356 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * 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 (Chinese (Simplified Han script)) 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/zh_Hans/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 94.8% (1949 of 2054 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/ * export/import localizations --------- Co-authored-by: Random Co-authored-by: dns Co-authored-by: 大王叫我来巡山 Co-authored-by: No name Co-authored-by: summoner001 Co-authored-by: Ghost of Sparta Co-authored-by: mlanp --- .../de.xcloc/Localized Contents/de.xliff | 5 +- .../es.xcloc/Localized Contents/es.xliff | 5 +- .../hu.xcloc/Localized Contents/hu.xliff | 17 +- .../it.xcloc/Localized Contents/it.xliff | 3 + .../Localized Contents/zh-Hans.xliff | 234 ++++++- .../Localized Contents/zh-Hant.xliff | 410 ++++++++++- .../SimpleX NSE/de.lproj/Localizable.strings | 3 + .../SimpleX NSE/es.lproj/Localizable.strings | 3 + .../SimpleX NSE/hu.lproj/Localizable.strings | 3 + .../SimpleX NSE/it.lproj/Localizable.strings | 3 + apps/ios/bg.lproj/Localizable.strings | 2 +- apps/ios/cs.lproj/Localizable.strings | 2 +- apps/ios/de.lproj/Localizable.strings | 25 +- apps/ios/es.lproj/Localizable.strings | 25 +- apps/ios/fr.lproj/Localizable.strings | 5 +- apps/ios/hu.lproj/Localizable.strings | 37 +- apps/ios/it.lproj/Localizable.strings | 23 +- apps/ios/ja.lproj/Localizable.strings | 2 +- apps/ios/nl.lproj/Localizable.strings | 17 +- apps/ios/pl.lproj/Localizable.strings | 5 +- apps/ios/ru.lproj/Localizable.strings | 17 +- apps/ios/tr.lproj/Localizable.strings | 2 +- apps/ios/uk.lproj/Localizable.strings | 2 +- apps/ios/zh-Hans.lproj/Localizable.strings | 659 +++++++++++++++++- .../commonMain/resources/MR/de/strings.xml | 4 +- .../commonMain/resources/MR/es/strings.xml | 6 +- .../commonMain/resources/MR/hu/strings.xml | 14 +- .../commonMain/resources/MR/it/strings.xml | 2 +- .../resources/MR/zh-rCN/strings.xml | 34 +- .../resources/MR/zh-rTW/strings.xml | 4 +- 30 files changed, 1370 insertions(+), 203 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 6e834157df..a113d35bbd 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -798,6 +798,7 @@ swipe action All servers + Alle Server No comment provided by engineer. @@ -2194,7 +2195,7 @@ Das ist Ihr eigener Einmal-Link! Current profile - Aktueller Profil + Aktuelles Profil No comment provided by engineer. @@ -8293,6 +8294,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Use TCP port 443 for preset servers only. + TCP-Port 443 nur für voreingestellte Server verwenden. No comment provided by engineer. @@ -10185,6 +10187,7 @@ Zuletzt empfangene Nachricht: %2$@ From %d chat(s) + Von %d Chat(s) notification body 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 f5226df190..73f88e1cab 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -798,6 +798,7 @@ swipe action All servers + Todos los servidores No comment provided by engineer. @@ -8086,7 +8087,7 @@ Se te pedirá que completes la autenticación antes de activar esta función.
Unblock member for all? - ¿Desbloquear miembro para todos? + ¿Desbloquear el miembro para todos? No comment provided by engineer. @@ -8293,6 +8294,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use TCP port 443 for preset servers only. + Usar puerto TCP 443 solo en servidores predefinidos. No comment provided by engineer. @@ -10185,6 +10187,7 @@ last received msg: %2$@ From %d chat(s) + De %d chat(s) notification body 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 c8a29ede41..9be5879eb6 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -768,7 +768,7 @@ swipe action All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. - Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek. + Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek. No comment provided by engineer. @@ -798,6 +798,7 @@ swipe action All servers + Összes kiszolgáló No comment provided by engineer. @@ -4941,12 +4942,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 üzeneteket, fájlokat és hívásokat **végpontok közötti titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi. + 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. 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, fájlok és hívások **végpontok közötti kvantumálló titkosítással** sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve. + 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. No comment provided by engineer. @@ -6076,7 +6077,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Quantum resistant encryption - Kvantumálló titkosítás + Kvantumbiztos titkosítás No comment provided by engineer. @@ -7854,7 +7855,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This chat is protected by quantum resistant end-to-end encryption. - Ez a csevegés végpontok közötti kvantumálló titkosítással védett. + Ez a csevegés végpontok közötti kvantumbiztos titkosítással védett. E2EE info chat item @@ -8293,6 +8294,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Use TCP port 443 for preset servers only. + A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. No comment provided by engineer. @@ -8437,7 +8439,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Via secure quantum resistant protocol. - Biztonságos kvantumálló-protokollon keresztül. + Biztonságos kvantumbiztos protokollon keresztül. No comment provided by engineer. @@ -9800,7 +9802,7 @@ time to disappear quantum resistant e2e encryption - végpontok közötti kvantumálló titkosítás + végpontok közötti kvantumbiztos titkosítás chat item text @@ -10185,6 +10187,7 @@ utoljára fogadott üzenet: %2$@ From %d chat(s) + %d csevegésből notification body 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 1bfccb3b06..0c0c12004e 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -798,6 +798,7 @@ swipe action All servers + Tutti i server No comment provided by engineer. @@ -8293,6 +8294,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Use TCP port 443 for preset servers only. + Usa la porta TCP 443 solo per i server preimpostati. No comment provided by engineer. @@ -10185,6 +10187,7 @@ ultimo msg ricevuto: %2$@ From %d chat(s) + Da %d chat notification body 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 31a333085f..03e053326a 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 @@ -122,6 +122,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -146,18 +147,22 @@ %d file(s) are still being downloaded. + 仍在下载 %d 个文件。 forward confirmation reason %d file(s) failed to download. + %d 个文件下载失败。 forward confirmation reason %d file(s) were deleted. + 已刪除 %d 个文件。 forward confirmation reason %d file(s) were not downloaded. + 未能下载 %d 个文件。 forward confirmation reason @@ -167,6 +172,7 @@ %d messages not forwarded + 未转发 %d 条消息 alert title @@ -186,11 +192,12 @@ %d seconds(s) + %d 秒 delete after time %d skipped message(s) - %d 跳过消息 + 跳过的 %d 条消息 integrity error chat item @@ -355,6 +362,7 @@ **Scan / Paste link**: to connect via a link you received. + **扫描/粘贴链接**:用您收到的链接连接。 No comment provided by engineer. @@ -458,14 +466,17 @@ time interval 1 year + 1 年 delete after time 1-time link + 一次性链接 No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + 一次性链接*只能给一名联系人*使用。当面或使用聊天应用分享链接。 No comment provided by engineer. @@ -539,6 +550,7 @@ time interval About operators + 关于运营方 No comment provided by engineer. @@ -555,6 +567,7 @@ swipe action Accept conditions + 接受条款 No comment provided by engineer. @@ -575,6 +588,7 @@ swipe action Accepted conditions + 已接受的条款 No comment provided by engineer. @@ -589,6 +603,7 @@ swipe action Active + 活跃 token status text @@ -603,10 +618,12 @@ swipe action Add friends + 添加好友 No comment provided by engineer. Add list + 添加列表 No comment provided by engineer. @@ -626,6 +643,7 @@ swipe action Add team members + 添加团队成员 No comment provided by engineer. @@ -635,6 +653,7 @@ swipe action Add to list + 添加到列表 No comment provided by engineer. @@ -644,14 +663,17 @@ swipe action Add your team members to the conversations. + 将你的团队成员加入对话。 No comment provided by engineer. Added media & file servers + 已添加媒体和文件服务器 No comment provided by engineer. Added message servers + 已添加消息服务器 No comment provided by engineer. @@ -681,10 +703,12 @@ swipe action Address or 1-time link? + 地址还是一次性链接? No comment provided by engineer. Address settings + 地址设置 No comment provided by engineer. @@ -709,6 +733,7 @@ swipe action All + 全部 No comment provided by engineer. @@ -723,6 +748,7 @@ swipe action All chats will be removed from the list %@, and the list deleted. + 列表 %@ 和其中全部聊天将被删除。 alert message @@ -742,6 +768,7 @@ swipe action All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + 所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。 No comment provided by engineer. @@ -766,10 +793,12 @@ swipe action All reports will be archived for you. + 将为你存档所有举报。 No comment provided by engineer. All servers + 全部服务器 No comment provided by engineer. @@ -849,6 +878,7 @@ swipe action Allow to report messsages to moderators. + 允许向 moderators 举报消息。 No comment provided by engineer. @@ -933,6 +963,7 @@ swipe action Another reason + 另一个理由 report reason @@ -962,6 +993,7 @@ swipe action App group: + 应用组: No comment provided by engineer. @@ -981,6 +1013,7 @@ swipe action App session + 应用会话 No comment provided by engineer. @@ -1010,14 +1043,17 @@ swipe action Archive + 存档 No comment provided by engineer. Archive %lld reports? + 存档 %lld 个举报? No comment provided by engineer. Archive all reports? + 存档所有举报? No comment provided by engineer. @@ -1032,14 +1068,17 @@ swipe action Archive report + 存档举报 No comment provided by engineer. Archive report? + 存档举报? No comment provided by engineer. Archive reports + 存档举报 swipe action @@ -1114,6 +1153,7 @@ swipe action Auto-accept settings + 自动接受设置 alert title @@ -1143,6 +1183,7 @@ swipe action Better calls + 更佳的通话 No comment provided by engineer. @@ -1152,10 +1193,12 @@ swipe action Better groups performance + 更好的群性能 No comment provided by engineer. Better message dates. + 更好的消息日期。 No comment provided by engineer. @@ -1170,18 +1213,22 @@ swipe action Better notifications + 更佳的通知 No comment provided by engineer. Better privacy and security + 更好的隐私和安全 No comment provided by engineer. Better security ✅ + 更佳的安全性✅ No comment provided by engineer. Better user experience + 更佳的使用体验 No comment provided by engineer. @@ -1266,14 +1313,17 @@ swipe action Business address + 企业地址 No comment provided by engineer. Business chats + 企业聊天 No comment provided by engineer. Businesses + 企业 No comment provided by engineer. @@ -1378,10 +1428,12 @@ alert button Change automatic message deletion? + 更改消息自动删除设置? alert title Change chat profiles + 更改聊天资料 authentication reason @@ -1432,14 +1484,17 @@ set passcode view Chat + 聊天 No comment provided by engineer. Chat already exists + 聊天已存在 No comment provided by engineer. Chat already exists! + 聊天已存在! No comment provided by engineer. @@ -1504,6 +1559,7 @@ set passcode view Chat preferences were changed. + 聊天偏好设置已修改。 alert message @@ -1518,10 +1574,12 @@ set passcode view Chat will be deleted for all members - this cannot be undone! + 将为所有成员删除聊天 - 此操作无法撤销! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + 将为你删除聊天 - 此操作无法撤销! No comment provided by engineer. @@ -1531,10 +1589,12 @@ set passcode view Check messages every 20 min. + 每 20 分钟检查消息。 No comment provided by engineer. Check messages when allowed. + 在被允许时检查消息。 No comment provided by engineer. @@ -1594,10 +1654,12 @@ set passcode view Clear group? + 清除群? No comment provided by engineer. Clear or delete group? + 清除还是删除群? No comment provided by engineer. @@ -1622,6 +1684,7 @@ set passcode view Community guidelines violation + 违反社区指导方针 report reason @@ -1641,14 +1704,17 @@ set passcode view Conditions accepted on: %@. + 已于 %@ 接受条款。 No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + 已接受运营方 **%@** 的条款。 No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + 已经接受下列运营方的条款:**%@**。 No comment provided by engineer. @@ -1658,14 +1724,17 @@ set passcode view Conditions will be accepted for the operator(s): **%@**. + 将接受下列运营方的条款:**%@**。 No comment provided by engineer. Conditions will be accepted on: %@. + 将于 %@ 接受条款。 No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + 将在 %@ 自动接受启用的运营方的条款。 No comment provided by engineer. @@ -1730,6 +1799,7 @@ set passcode view Confirmed + 已确定 token status text @@ -1853,6 +1923,7 @@ This is your own one-time link! Connection blocked + 连接被阻止 No comment provided by engineer. @@ -1868,10 +1939,12 @@ This is your own one-time link! Connection is blocked by server operator: %@ + 连接被运营方 %@ 阻止 No comment provided by engineer. Connection not ready. + 连接未就绪。 No comment provided by engineer. @@ -1886,10 +1959,12 @@ This is your own one-time link! Connection requires encryption renegotiation. + 连接需要加密重协商。 No comment provided by engineer. Connection security + 连接安全性 No comment provided by engineer. @@ -1969,6 +2044,7 @@ This is your own one-time link! Content violates conditions of use + 内容违反使用条款 blocking reason @@ -1998,6 +2074,7 @@ This is your own one-time link! Corner + 拐角 No comment provided by engineer. @@ -2012,6 +2089,7 @@ This is your own one-time link! Create 1-time link + 创建一次性链接 No comment provided by engineer. @@ -2046,6 +2124,7 @@ This is your own one-time link! Create list + 创建列表 No comment provided by engineer. @@ -2105,6 +2184,7 @@ This is your own one-time link! Current conditions text couldn't be loaded, you can review conditions via this link: + 无法加载当前条款文本,你可以通过此链接审阅条款: No comment provided by engineer. @@ -2129,6 +2209,7 @@ This is your own one-time link! Customizable message shape. + 可自定义消息形状。 No comment provided by engineer. @@ -2302,10 +2383,12 @@ swipe action Delete chat + 删除聊天 No comment provided by engineer. Delete chat messages from your device. + 从你的设备删除聊天消息。 No comment provided by engineer. @@ -2320,6 +2403,7 @@ swipe action Delete chat? + 删除聊天? No comment provided by engineer. @@ -2399,6 +2483,7 @@ swipe action Delete list? + 删除列表? alert title @@ -2433,6 +2518,7 @@ swipe action Delete or moderate up to 200 messages. + 允许自行删除或管理员移除最多200条消息。 No comment provided by engineer. @@ -2452,6 +2538,7 @@ swipe action Delete report + 删除举报 No comment provided by engineer. @@ -2491,6 +2578,7 @@ swipe action Delivered even when Apple drops them. + 已送达,即使苹果已将其删除。 No comment provided by engineer. @@ -2575,7 +2663,7 @@ swipe action Device authentication is disabled. Turning off SimpleX Lock. - 设备验证被禁用。关闭 SimpleX 锁定。 + 设备验证已禁用。 SimpleX 已解锁。 No comment provided by engineer. @@ -2595,11 +2683,12 @@ swipe action 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. @@ -2614,10 +2703,12 @@ swipe action Disable automatic message deletion? + 禁用消息自动销毁? alert title Disable delete messages + 停用消息删除 alert button @@ -2707,10 +2798,12 @@ swipe action Do not use credentials with proxy. + 代理不使用身份验证凭据。 No comment provided by engineer. Documents: + 文档: No comment provided by engineer. @@ -2725,6 +2818,7 @@ swipe action Don't miss important messages. + 不错过重要消息。 No comment provided by engineer. @@ -2734,6 +2828,7 @@ swipe action Done + 完成 No comment provided by engineer. @@ -2764,6 +2859,7 @@ chat item action Download files + 下载文件 alert action @@ -2798,6 +2894,7 @@ chat item action E2E encrypted notifications. + 端到端加密的通知。 No comment provided by engineer. @@ -2822,6 +2919,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + 在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。 No comment provided by engineer. @@ -2966,6 +3064,7 @@ chat item action Encryption renegotiation in progress. + 正进行加密重协商。 No comment provided by engineer. @@ -3035,6 +3134,7 @@ chat item action Error accepting conditions + 接受条款出错 alert title @@ -3049,6 +3149,7 @@ chat item action Error adding server + 添加服务器出错 alert title @@ -3058,6 +3159,7 @@ chat item action Error changing connection profile + 更改连接资料出错 No comment provided by engineer. @@ -3072,6 +3174,7 @@ chat item action Error changing to incognito! + 切换至隐身聊天出错! No comment provided by engineer. @@ -3100,6 +3203,7 @@ chat item action Error creating list + 创建列表出错 alert title @@ -3119,6 +3223,7 @@ chat item action Error creating report + 创建举报出错 No comment provided by engineer. @@ -3203,10 +3308,12 @@ chat item action Error loading servers + 加载服务器出错 alert title Error migrating settings + 迁移设置出错 No comment provided by engineer. @@ -3231,6 +3338,7 @@ chat item action Error registering for notifications + 注册消息推送出错 alert title @@ -3240,6 +3348,7 @@ chat item action Error reordering lists + 重排列表出错 alert title @@ -3254,6 +3363,7 @@ chat item action Error saving chat list + 保存聊天列表出错 alert title @@ -3273,6 +3383,7 @@ chat item action Error saving servers + 保存服务器出错 alert title @@ -3322,6 +3433,7 @@ chat item action Error switching profile + 切换配置文件出错 No comment provided by engineer. @@ -3336,6 +3448,7 @@ chat item action Error testing server connection + 检验服务器连接出错 No comment provided by engineer. @@ -3350,6 +3463,7 @@ chat item action Error updating server + 更新服务器出错 alert title @@ -3401,6 +3515,7 @@ snd error text Errors in servers configuration. + 服务器配置有错误。 servers error @@ -3420,6 +3535,7 @@ snd error text Expired + 已过期 token status text @@ -3464,6 +3580,7 @@ snd error text Faster deletion of groups. + 更快地删除群。 No comment provided by engineer. @@ -3473,6 +3590,7 @@ snd error text Faster sending messages. + 更快发送消息。 No comment provided by engineer. @@ -3482,6 +3600,7 @@ snd error text Favorites + 收藏 No comment provided by engineer. @@ -3492,11 +3611,15 @@ snd error text File errors: %@ + 文件错误: +%@ alert message File is blocked by server operator: %@. + 文件被服务器运营方阻止: +%@。 file error text @@ -3626,10 +3749,12 @@ snd error text For all moderators + 所有 moderators No comment provided by engineer. For chat profile %@: + 为聊天资料 %@: servers error @@ -3639,18 +3764,22 @@ snd error text For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + 比如,如果你通过 SimpleX 服务器收到消息,应用会通过 Flux 服务器传送它们。 No comment provided by engineer. For me + 仅自己 No comment provided by engineer. For private routing + 用于私密路由 No comment provided by engineer. For social media + 用于社交媒体 No comment provided by engineer. @@ -3660,6 +3789,7 @@ snd error text Forward %d message(s)? + 转发 %d 条消息? alert title @@ -3669,14 +3799,17 @@ snd error text Forward messages + 已转发的消息 alert action Forward messages without files? + 仅转发消息不转发文件? alert message Forward up to 20 messages at once. + 一次转发最多20条消息。 No comment provided by engineer. @@ -3691,6 +3824,7 @@ snd error text Forwarding %lld messages + 正在转发 %lld 条消息 No comment provided by engineer. @@ -3764,6 +3898,7 @@ Error: %2$@ Get notified when mentioned. + 被提及时收到通知。 No comment provided by engineer. @@ -3873,6 +4008,7 @@ Error: %2$@ Groups + No comment provided by engineer. @@ -3882,6 +4018,7 @@ Error: %2$@ Help admins moderating their groups. + 帮助管理员管理群组。 No comment provided by engineer. @@ -3936,14 +4073,17 @@ Error: %2$@ How it affects privacy + 它如何影响隐私 No comment provided by engineer. How it helps privacy + 它如何帮助隐私 No comment provided by engineer. How it works + 工作原理 alert button @@ -3973,6 +4113,7 @@ Error: %2$@ IP address + IP 地址 No comment provided by engineer. @@ -4053,6 +4194,8 @@ Error: %2$@ Improved delivery, reduced traffic usage. More improvements are coming soon! + 改善传送,降低流量使用。 +更多改进即将推出! No comment provided by engineer. @@ -4087,10 +4230,12 @@ More improvements are coming soon! Inappropriate content + 不当内容 report reason Inappropriate profile + 不当个人资料 report reason @@ -4187,22 +4332,27 @@ More improvements are coming soon! Invalid + 无效 token status text Invalid (bad token) + Token 无效 token status text Invalid (expired) + 无效(已过期) token status text Invalid (unregistered) + 无效(未注册) token status text Invalid (wrong topic) + 无效(话题有误) token status text @@ -4267,6 +4417,7 @@ More improvements are coming soon! Invite to chat + 邀请加入聊天 No comment provided by engineer. @@ -4429,10 +4580,12 @@ This is your link for group %@! Leave chat + 离开聊天 No comment provided by engineer. Leave chat? + 离开聊天? No comment provided by engineer. @@ -4477,14 +4630,17 @@ This is your link for group %@! List + 列表 swipe action List name and emoji should be different for all lists. + 所有列表的名称和表情符号都应不同。 No comment provided by engineer. List name... + 列表名… No comment provided by engineer. @@ -4579,10 +4735,12 @@ This is your link for group %@! Member reports + 成员举报 chat feature Member role will be changed to "%@". All chat members will be notified. + 将变更成员角色为“%@”。所有成员都会收到通知。 No comment provided by engineer. @@ -4597,6 +4755,7 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! + 将从聊天中删除成员 - 此操作无法撤销! No comment provided by engineer. @@ -4616,6 +4775,7 @@ This is your link for group %@! Members can report messsages to moderators. + 成员可以向 moderators 举报消息。 No comment provided by engineer. @@ -4645,6 +4805,7 @@ This is your link for group %@! Mention members 👋 + 提及成员👋 No comment provided by engineer. @@ -4714,6 +4875,7 @@ This is your link for group %@! Message shape + 消息形状 No comment provided by engineer. @@ -4758,6 +4920,7 @@ This is your link for group %@! Messages in this chat will never be deleted. + 此聊天中的消息永远不会被删除。 alert message @@ -4772,6 +4935,7 @@ This is your link for group %@! Messages were deleted after you selected them. + 在你选中消息后这些消息已被删除。 alert message @@ -4861,6 +5025,7 @@ This is your link for group %@! More + 更多 swipe action @@ -4875,6 +5040,7 @@ This is your link for group %@! More reliable notifications + 更可靠的通知 No comment provided by engineer. @@ -4894,6 +5060,7 @@ This is your link for group %@! Mute all + 全部静音 notification label action @@ -4918,6 +5085,7 @@ This is your link for group %@! Network decentralization + 网络去中心化 No comment provided by engineer. @@ -4932,6 +5100,7 @@ This is your link for group %@! Network operator + 网络运营方 No comment provided by engineer. @@ -4946,6 +5115,7 @@ This is your link for group %@! New + token status text @@ -4955,10 +5125,12 @@ This is your link for group %@! New SOCKS credentials will be used every time you start the app. + 每次启动应用都会使用新的 SOCKS 凭据。 No comment provided by engineer. New SOCKS credentials will be used for each server. + 每个服务器都会使用新的 SOCKS 凭据。 No comment provided by engineer. @@ -4993,6 +5165,7 @@ This is your link for group %@! New events + 新事件 notification @@ -5022,6 +5195,7 @@ This is your link for group %@! New server + 新服务器 No comment provided by engineer. @@ -5036,14 +5210,17 @@ This is your link for group %@! No chats + 无聊天 No comment provided by engineer. No chats found + 找不到聊天 No comment provided by engineer. No chats in list %@ + 列表 %@ 中无聊天 No comment provided by engineer. @@ -5093,14 +5270,17 @@ This is your link for group %@! No media & file servers. + 无媒体和文件服务器。 servers error No message + 无消息 No comment provided by engineer. No message servers. + 无消息服务器。 servers error @@ -5110,10 +5290,12 @@ This is your link for group %@! No permission to record speech + 无录音权限 No comment provided by engineer. No permission to record video + 无录像权限 No comment provided by engineer. @@ -5133,26 +5315,32 @@ This is your link for group %@! No servers for private message routing. + 无私密消息路由服务器。 servers error No servers to receive files. + 无文件接收服务器。 servers error No servers to receive messages. + 无消息接收服务器。 servers error No servers to send files. + 无文件发送服务器。 servers error No token! + 无 token! alert title No unread chats + 没有未读聊天 No comment provided by engineer. @@ -5167,6 +5355,7 @@ This is your link for group %@! Notes + 附注 No comment provided by engineer. @@ -5176,6 +5365,7 @@ This is your link for group %@! Nothing to forward! + 无可转发! alert title @@ -5190,14 +5380,17 @@ This is your link for group %@! Notifications error + 通知错误 alert title Notifications privacy + 通知隐私 No comment provided by engineer. Notifications status + 通知状态 alert title @@ -5255,6 +5448,7 @@ Requires compatible VPN. Only chat owners can change preferences. + 仅聊天所有人可更改首选项。 No comment provided by engineer. @@ -5284,10 +5478,12 @@ Requires compatible VPN. Only sender and moderators see it + 仅发送人和moderators能看到 No comment provided by engineer. Only you and moderators see it + 只有你和moderators能看到 No comment provided by engineer. @@ -5352,6 +5548,7 @@ Requires compatible VPN. Open changes + 打开更改 No comment provided by engineer. @@ -5366,6 +5563,7 @@ Requires compatible VPN. Open conditions + 打开条款 No comment provided by engineer. @@ -5389,14 +5587,17 @@ Requires compatible VPN. Operator + 运营方 No comment provided by engineer. Operator server + 运营方服务器 alert title Or import archive file + 或者导入或者导入压缩文件 No comment provided by engineer. @@ -5421,10 +5622,12 @@ Requires compatible VPN. Or to share privately + 或者私下分享 No comment provided by engineer. Organize chats into lists + 将聊天组织到列表 No comment provided by engineer. @@ -5474,6 +5677,7 @@ Requires compatible VPN. Password + 密码 No comment provided by engineer. @@ -6272,21 +6476,22 @@ swipe action Review conditions + 审阅条款 No comment provided by engineer. Revoke - 撤销 + 吊销 No comment provided by engineer. Revoke file - 撤销文件 + 吊销文件 cancel file action Revoke file? - 撤销文件? + 吊销文件? No comment provided by engineer. @@ -6296,7 +6501,7 @@ swipe action Run chat - 运行聊天程序 + 运行聊天 No comment provided by engineer. @@ -6356,6 +6561,7 @@ chat item action Save list + 保存列表 No comment provided by engineer. @@ -6395,6 +6601,7 @@ chat item action Save your profile? + 保存您的个人资料? alert title @@ -6419,6 +6626,7 @@ chat item action Saving %lld messages + 正在保存 %lld 条消息 No comment provided by engineer. @@ -6999,6 +7207,7 @@ chat item action SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat 与 Flux 达成了协议,将由 Flux 控制的服务器纳入 SimpleX 应用。 No comment provided by engineer. @@ -7033,14 +7242,17 @@ chat item action SimpleX address and 1-time links are safe to share via any messenger. + 可以通过任何消息应用安全分享 SimpleX 地址和一次性链接。 No comment provided by engineer. SimpleX address or 1-time link? + SimpleX 地址或一次性链接? No comment provided by engineer. SimpleX channel link + SimpleX 频道链接 simplex link type @@ -7050,12 +7262,12 @@ chat item action SimpleX encrypted message or connection event - SimpleX 加密消息或连接项目 + SimpleX 加密的消息或连接事件 notification SimpleX group link - SimpleX 群组链接 + SimpleX 群链接 simplex link type @@ -7080,6 +7292,7 @@ chat item action SimpleX protocols reviewed by Trail of Bits. + SimpleX 协议由 Trail of Bits 审阅。 No comment provided by engineer. @@ -7114,6 +7327,7 @@ chat item action Some app settings were not migrated. + 部分应用设置未被迁移。 No comment provided by engineer. @@ -9143,7 +9357,7 @@ pref value duplicates - 复本 + 副本 No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff index 93b9725131..8a771369e6 100644 --- a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff @@ -109,7 +109,7 @@ %d skipped message(s) - %d錯過了訊息 + 錯過的 %d 則訊息 integrity error chat item @@ -124,17 +124,17 @@ %lld contact(s) selected - %lld 已選擇聯絡人(s) + 已選擇 %lld 個聯絡人 No comment provided by engineer. %lld file(s) with total size of %@ - %lld 檔案(s) 的總共大小為%@ + %lld 個檔案,總共大小 %@ No comment provided by engineer. %lld members - %lld 成員 + %lld 個成員 No comment provided by engineer. @@ -224,7 +224,7 @@ **Warning**: Instant push notifications require passphrase saved in Keychain. - **警告**:即時推送訊息通知需要數據庫的密碼儲存在資料庫中。 + **警告**:即時推送訊息通知需要將數據庫的密碼儲存在資料庫中。 No comment provided by engineer. @@ -2704,12 +2704,12 @@ We will be adding server redundancy to prevent lost messages. Send link previews - 傳送可以預覽的連結 + 傳送連結預覽 No comment provided by engineer. Send live message - 傳送實況的訊息 + 傳送實時訊息 No comment provided by engineer. @@ -2724,7 +2724,7 @@ We will be adding server redundancy to prevent lost messages. Send questions and ideas - 傳送問題和想法給開發者 + 給開發者提問題和想法 No comment provided by engineer. @@ -2774,7 +2774,7 @@ We will be adding server redundancy to prevent lost messages. Set 1 day - 設定為1天 + 設定為 1 天 No comment provided by engineer. @@ -3022,7 +3022,7 @@ We will be adding server redundancy to prevent lost messages. The connection you accepted will be cancelled! - 你所接受的連接將被取消! + 你接受的連接將被取消! No comment provided by engineer. @@ -3061,7 +3061,7 @@ We will be adding server redundancy to prevent lost messages. The sender will NOT be notified - 發送者不會接收到通知 + 發送者不會收到通知 No comment provided by engineer. @@ -3071,12 +3071,12 @@ We will be adding server redundancy to prevent lost messages. This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - 這操作不能還原 - 所有已經接收和傳送的檔案和媒體檔案將刪除。低解析度圖片將保留。 + 這操作不能還原 - 將刪除所有已經接收和傳送的檔案和媒體。將保留低解析度圖片。 No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - 這操作無法撤銷 - 早於所選擇的時間發送和接收的訊息將被刪除。這可能需要幾分鐘的時間。 + 這操作無法撤銷 - 早於所選時間的收發訊息將被刪除。可能需要幾分鐘。 No comment provided by engineer. @@ -3263,7 +3263,7 @@ To connect, please ask your contact to create another connection link and check Use for new connections - 用於新的連接 + 用於新的連線 No comment provided by engineer. @@ -3283,7 +3283,7 @@ To connect, please ask your contact to create another connection link and check Verify connection security - 驗證連接安全性 + 驗證連線安全性 No comment provided by engineer. @@ -4163,7 +4163,7 @@ SimpleX 伺服器並不會看到你的個人檔案。 via contact address link - 透過聯絡人的邀請連結連接 + 透過聯絡人的邀請連結連線 chat list item description @@ -4173,7 +4173,7 @@ SimpleX 伺服器並不會看到你的個人檔案。 via one-time link - 透過一次性連結連接 + 透過一次性連結連線 chat list item description @@ -4702,7 +4702,7 @@ Available in v5.1 %u messages failed to decrypt. - %u 訊息解密失敗。 + %u 則訊息解密失敗。 No comment provided by engineer. @@ -5152,7 +5152,7 @@ Available in v5.1 Tap to activate profile. - 點擊以激活配置檔案。 + 點擊以激活設定檔。 No comment provided by engineer. @@ -6034,7 +6034,7 @@ It can happen because of some bug or when the connection is compromised. %lld messages marked deleted - %lld 條訊息已刪除 + %lld 則訊息已標記為刪除 Already connecting! @@ -6046,7 +6046,7 @@ It can happen because of some bug or when the connection is compromised. (new) - (新) + (新) %@, %@ and %lld other members connected @@ -6112,6 +6112,374 @@ It can happen because of some bug or when the connection is compromised.Background 後台 + + SimpleX links not allowed + 不允許 SimpleX 連結 + + + Voice messages not allowed + 不允許語音訊息 + + + The text you pasted is not a SimpleX link. + 您貼在這裡的連結不是 SimpleX 連結。 + + + %d file(s) were deleted. + 已刪除 %d 個檔案。 + + + Reset to app theme + 重設至應用程式主題 + + + Retry + 重試 + + + The uploaded database archive will be permanently removed from the servers. + 上傳的資料庫存檔將從伺服器永久移除。 + + + Shape profile images + 塑造個人資料圖片 + + + **Scan / Paste link**: to connect via a link you received. + **掃描/貼上連結**:以透過您收到的連結連線。 + + + Reports + 舉報 + + + Use SOCKS proxy + 使用 SOCKS 代理 + + + Reset all statistics + 重設所有統計數據 + + + SOCKS proxy + SOCKS 代理 + + + Send message to enable calls. + 發送訊息以啟用通話功能。 + + + Send direct message to connect + 直接發送訊息以連結 + + + Scale + 顯示比例 + + + Sent via proxy + 通過代理發送 + + + Servers info + 伺服器訊息 + + + Set message expiration in chats. + 設定聊天中訊息期限。 + + + Share SimpleX address on social media. + 在社交媒體上分享 SimpleX 聯絡地址。 + + + Storage + 存儲 + + + Starting from %@. + 開始於 %@。 + + + The second tick we missed! ✅ + 我們錯過的第二個勾選! ✅ + + + Themes + 主題 + + + %d file(s) failed to download. + %d 個檔案下載失敗。 + + + Session code + 會話代碼 + + + Servers statistics will be reset - this cannot be undone! + 伺服器統計資料將被重設 - 此操作無法撤銷! + + + **Create 1-time link**: to create and share a new invitation link. + **建立一次性連結**:建立並分享新邀請連結。 + + + Set default theme + 設定缺省主題 + + + %lld group events + %lld 個群組事件 + + + Reset all statistics? + 重設所有統計數據? + + + %@ server + %@ 伺服器 + + + %d file(s) were not downloaded. + %d 個檔案未下載。 + + + %d messages not forwarded + %d 則訊息未轉發 + + + Test notifications + 测试通知 + + + (this device v%@) + (此設備 v%@) + + + Settings were changed. + 設定已更改。 + + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + 這操作不能撤銷 - 此聊天中早於所選訊息的收發訊息將被刪除。 + + + Subscription errors + 訂閱錯誤 + + + Report + 舉報 + + + Send messages directly when IP address is protected and your or destination server does not support private routing. + 當 IP 位址受保護且您或目的地伺服器不支援私人路由時,直接傳送訊息。 + + + Reset to user theme + 重設為使用者主題 + + + Use short links (BETA) + 使用短連結(Beta) + + + Up to 100 last messages are sent to new members. + 最多 100 則最後的訊息會傳送至新成員。 + + + %d seconds(s) + %d 秒 + + + %d file(s) are still being downloaded. + 仍在下載 %d 個檔案。 + + + %lld messages blocked by admin + %lld 則訊息被管理員封鎖 + + + Report: %@ + 舉報:%@ + + + Review conditions + 檢視使用條款 + + + Search or paste SimpleX link + 搜尋或貼上 SimpleX 連結 + + + Sent directly + 已直接發送 + + + SimpleX links are prohibited. + 這群組禁止 SimpleX 連結。 + + + Uploaded files + 已上傳的檔案 + + + Use %@ + 使用 %@ + + + Upload errors + 上傳錯誤 + + + Use servers + 使用伺服器 + + + security code changed + 安全碼已變更 + + + These settings are for your current profile **%@**. + 這些設定是針對您目前的設定檔 **%@**。 + + + They can be overridden in contact and group settings. + 您可在連絡人和群組設定中覆寫它們。 + + + %1$@, %2$@ + %1$@, %2$@ + + + Verify connections + 驗證連線 + + + Verify connection + 驗證連線 + + + Verify passphrase + 驗證密碼 + + + Verify code with desktop + 使用桌上電腦驗證代碼 + + + Save list + 儲存列表 + + + Saving %lld messages + 正在儲存 %lld 則訊息 + + + search + 搜尋 + + + requested to connect + 已請求連結 + + + saved + 已儲存 + + + video + 視訊 + + + Tap to Connect + 點擊以連結 + + + Unsupported connection link + 未受支持的連線連結 + + + Saved from + 儲存自 + + + Saved + 已儲存 + + + Scan / Paste link + 掃描/貼上連結 + + + SimpleX + SimpleX + + + Use the app while in the call. + 在通話時使用此應用程式。 + + + v%@ + v%@ + + + Save your profile? + 儲存設定檔? + + + Use for messages + 用於訊息 + + + Uploading archive + 正在上傳檔案庫 + + + Unlink + 從桌上電腦解除連結 + + + %lld messages blocked + 已封鎖 %d 則訊息 + + + The same conditions will apply to operator **%@**. + 相同條件也適用於 **%@** 操作員。 + + + These conditions will also apply for: **%@**. + 這些條件也適用於:**%@**。 + + + Upload failed + 上傳失敗 + + + Use the app with one hand. + 單手使用此應用程式。 + + + Safely receive files + 安全地接收檔案 + + + Saved message + 已儲存的訊息 + + + Use from desktop + 在桌上電腦上使用 + + + Via secure quantum resistant protocol. + 使用量子安全的協定。 + + + Uploaded + 已上傳 + diff --git a/apps/ios/SimpleX NSE/de.lproj/Localizable.strings b/apps/ios/SimpleX NSE/de.lproj/Localizable.strings index d0b2f8bd1b..ec502c53c6 100644 --- a/apps/ios/SimpleX NSE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/de.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d neue Ereignisse"; +/* notification body */ +"From %d chat(s)" = "Von %d Chat(s)"; + /* notification body */ "From: %@" = "Von: %@"; diff --git a/apps/ios/SimpleX NSE/es.lproj/Localizable.strings b/apps/ios/SimpleX NSE/es.lproj/Localizable.strings index 8b43c489b7..685eb3d93d 100644 --- a/apps/ios/SimpleX NSE/es.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/es.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d evento(s) nuevo(s)"; +/* notification body */ +"From %d chat(s)" = "De %d chat(s)"; + /* notification body */ "From: %@" = "De: %@"; diff --git a/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings b/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings index 69456fd177..a6330b93db 100644 --- a/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/hu.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d új esemény"; +/* notification body */ +"From %d chat(s)" = "%d csevegésből"; + /* notification body */ "From: %@" = "Tőle: %@"; diff --git a/apps/ios/SimpleX NSE/it.lproj/Localizable.strings b/apps/ios/SimpleX NSE/it.lproj/Localizable.strings index e22f5aeac3..a6c1ec215b 100644 --- a/apps/ios/SimpleX NSE/it.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/it.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d nuovi eventi"; +/* notification body */ +"From %d chat(s)" = "Da %d chat"; + /* notification body */ "From: %@" = "Da: %@"; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 432bc75894..f241158185 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -2825,7 +2825,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Само вашият контакт може да изпраща гласови съобщения."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Отвори"; /* No comment provided by engineer. */ diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index c150ba5564..003ac23325 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -2217,7 +2217,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Hlasové zprávy může odesílat pouze váš kontakt."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Otevřít"; /* No comment provided by engineer. */ diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 8b0e18ecce..0eab764216 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -505,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Alle Meldungen werden für Sie archiviert."; +/* No comment provided by engineer. */ +"All servers" = "Alle Server"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Alle Ihre Kontakte bleiben verbunden."; @@ -688,9 +691,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Datenbank wird archiviert"; -/* No comment provided by engineer. */ -"Ask" = "Fragen"; - /* No comment provided by engineer. */ "Attach" = "Anhängen"; @@ -1460,7 +1460,7 @@ set passcode view */ "Current passphrase…" = "Aktuelles Passwort…"; /* No comment provided by engineer. */ -"Current profile" = "Aktueller Profil"; +"Current profile" = "Aktuelles Profil"; /* No comment provided by engineer. */ "Currently maximum supported file size is %@." = "Die derzeit maximal unterstützte Dateigröße beträgt %@."; @@ -3677,7 +3677,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Nur Ihr Kontakt kann Sprachnachrichten versenden."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Öffnen"; /* No comment provided by engineer. */ @@ -3695,21 +3695,12 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Gruppe öffnen"; -/* No comment provided by engineer. */ -"Open link" = "Web-Link öffnen"; - -/* No comment provided by engineer. */ -"Open links from chat list" = "Web-Links aus der Chat-Liste öffnen"; - /* authentication reason */ "Open migration to another device" = "Migration auf ein anderes Gerät öffnen"; /* No comment provided by engineer. */ "Open Settings" = "Geräte-Einstellungen öffnen"; -/* No comment provided by engineer. */ -"Open web link?" = "Web-Link öffnen?"; - /* No comment provided by engineer. */ "Opening app…" = "App wird geöffnet…"; @@ -5490,6 +5481,9 @@ report reason */ /* No comment provided by engineer. */ "Use TCP port %@ when no port is specified." = "Solange kein Port konfiguriert ist, wird TCP-Port %@ genutzt."; +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "TCP-Port 443 nur für voreingestellte Server verwenden."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Die App kann während eines Anrufs genutzt werden."; @@ -5709,9 +5703,6 @@ report reason */ /* pref value */ "yes" = "Ja"; -/* No comment provided by engineer. */ -"Yes" = "Ja"; - /* No comment provided by engineer. */ "you" = "Profil"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 9644521723..e797b73b98 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -505,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Todos los informes serán archivados para ti."; +/* No comment provided by engineer. */ +"All servers" = "Todos los servidores"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Todos tus contactos permanecerán conectados."; @@ -688,9 +691,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Archivando base de datos"; -/* No comment provided by engineer. */ -"Ask" = "Preguntar"; - /* No comment provided by engineer. */ "Attach" = "Adjuntar"; @@ -3677,7 +3677,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sólo tu contacto puede enviar mensajes de voz."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Abrir"; /* No comment provided by engineer. */ @@ -3695,21 +3695,12 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Grupo abierto"; -/* No comment provided by engineer. */ -"Open link" = "Abrir enlace"; - -/* No comment provided by engineer. */ -"Open links from chat list" = "Abrir enlaces desde listado de chats"; - /* authentication reason */ "Open migration to another device" = "Abrir menú migración a otro dispositivo"; /* No comment provided by engineer. */ "Open Settings" = "Abrir Configuración"; -/* No comment provided by engineer. */ -"Open web link?" = "¿Abrir enlace web?"; - /* No comment provided by engineer. */ "Opening app…" = "Iniciando aplicación…"; @@ -5305,7 +5296,7 @@ report reason */ "Unblock member" = "Desbloquear miembro"; /* No comment provided by engineer. */ -"Unblock member for all?" = "¿Desbloquear miembro para todos?"; +"Unblock member for all?" = "¿Desbloquear el miembro para todos?"; /* No comment provided by engineer. */ "Unblock member?" = "¿Desbloquear miembro?"; @@ -5490,6 +5481,9 @@ report reason */ /* No comment provided by engineer. */ "Use TCP port %@ when no port is specified." = "Se usa el puerto TCP %@ cuando no se ha especificado otro."; +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Usar puerto TCP 443 solo en servidores predefinidos."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Usar la aplicación durante la llamada."; @@ -5709,9 +5703,6 @@ report reason */ /* pref value */ "yes" = "sí"; -/* No comment provided by engineer. */ -"Yes" = "Si"; - /* No comment provided by engineer. */ "you" = "tu"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index a0145a7f47..1c16f8847d 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -682,9 +682,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Archivage de la base de données"; -/* No comment provided by engineer. */ -"Ask" = "Demander"; - /* No comment provided by engineer. */ "Attach" = "Attacher"; @@ -3566,7 +3563,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Seul votre contact peut envoyer des messages vocaux."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Ouvrir"; /* No comment provided by engineer. */ diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 23ff6fc0bf..c190cccaba 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -488,7 +488,7 @@ swipe action */ "all members" = "összes tag"; /* No comment provided by engineer. */ -"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek."; +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek."; /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza!"; @@ -505,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Az összes jelentés archiválva lesz az Ön számára."; +/* No comment provided by engineer. */ +"All servers" = "Összes kiszolgáló"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Az összes partnerével kapcsolatban marad."; @@ -688,9 +691,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Adatbázis archiválása"; -/* No comment provided by engineer. */ -"Ask" = "Mindig kérdezzen rá"; - /* No comment provided by engineer. */ "Attach" = "Mellékelés"; @@ -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 üzeneteket, fájlokat és hívásokat **végpontok közötti titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi."; +"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."; /* 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, fájlok és hívások **végpontok közötti kvantumálló titkosítással** sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással 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**, sérülés utáni titkosságvédelemmel és -helyreállítással, továbbá letagadhatósággal vannak védve."; /* No comment provided by engineer. */ "Migrate device" = "Eszköz átköltöztetése"; @@ -3677,7 +3677,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Csak a partnere tud hangüzeneteket küldeni."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Megnyitás"; /* No comment provided by engineer. */ @@ -3695,21 +3695,12 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Csoport megnyitása"; -/* No comment provided by engineer. */ -"Open link" = "Hivatkozás megnyitása"; - -/* No comment provided by engineer. */ -"Open links from chat list" = "Hivatkozás megnyitása a csevegési listából"; - /* authentication reason */ "Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; /* No comment provided by engineer. */ "Open Settings" = "Beállítások megnyitása"; -/* No comment provided by engineer. */ -"Open web link?" = "Megnyitja a webhivatkozást?"; - /* No comment provided by engineer. */ "Opening app…" = "Az alkalmazás megnyitása…"; @@ -4023,10 +4014,10 @@ time to disappear */ "Push server" = "Push-kiszolgáló"; /* chat item text */ -"quantum resistant e2e encryption" = "végpontok közötti kvantumálló titkosítás"; +"quantum resistant e2e encryption" = "végpontok közötti kvantumbiztos titkosítás"; /* No comment provided by engineer. */ -"Quantum resistant encryption" = "Kvantumálló titkosítás"; +"Quantum resistant encryption" = "Kvantumbiztos titkosítás"; /* No comment provided by engineer. */ "Rate the app" = "Értékelje az alkalmazást"; @@ -5164,7 +5155,7 @@ report reason */ "This chat is protected by end-to-end encryption." = "Ez a csevegés végpontok közötti titkosítással védett."; /* E2EE info chat item */ -"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumálló titkosítással védett."; +"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumbiztos titkosítással védett."; /* notification title */ "this contact" = "ez a partner"; @@ -5490,6 +5481,9 @@ report reason */ /* No comment provided by engineer. */ "Use TCP port %@ when no port is specified." = "A következő TCP-port használata, amikor nincs port megadva: %@."; +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Használja az alkalmazást hívás közben."; @@ -5551,7 +5545,7 @@ report reason */ "via relay" = "egy továbbítókiszolgálón keresztül"; /* No comment provided by engineer. */ -"Via secure quantum resistant protocol." = "Biztonságos kvantumálló-protokollon keresztül."; +"Via secure quantum resistant protocol." = "Biztonságos kvantumbiztos protokollon keresztül."; /* No comment provided by engineer. */ "video" = "videó"; @@ -5709,9 +5703,6 @@ report reason */ /* pref value */ "yes" = "igen"; -/* No comment provided by engineer. */ -"Yes" = "Igen"; - /* No comment provided by engineer. */ "you" = "Ön"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index f68424325f..f67a492cc4 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -505,6 +505,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Tutte le segnalazioni verranno archiviate per te."; +/* No comment provided by engineer. */ +"All servers" = "Tutti i server"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Tutti i tuoi contatti resteranno connessi."; @@ -688,9 +691,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Archiviazione del database"; -/* No comment provided by engineer. */ -"Ask" = "Chiedi"; - /* No comment provided by engineer. */ "Attach" = "Allega"; @@ -3677,7 +3677,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Solo il tuo contatto può inviare messaggi vocali."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Apri"; /* No comment provided by engineer. */ @@ -3695,21 +3695,12 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Apri gruppo"; -/* No comment provided by engineer. */ -"Open link" = "Apri link"; - -/* No comment provided by engineer. */ -"Open links from chat list" = "Apri i link dall'elenco delle chat"; - /* authentication reason */ "Open migration to another device" = "Apri migrazione ad un altro dispositivo"; /* No comment provided by engineer. */ "Open Settings" = "Apri le impostazioni"; -/* No comment provided by engineer. */ -"Open web link?" = "Aprire il link?"; - /* No comment provided by engineer. */ "Opening app…" = "Apertura dell'app…"; @@ -5490,6 +5481,9 @@ report reason */ /* No comment provided by engineer. */ "Use TCP port %@ when no port is specified." = "Usa la porta TCP %@ quando non è specificata alcuna porta."; +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Usa la porta TCP 443 solo per i server preimpostati."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Usa l'app mentre sei in chiamata."; @@ -5709,9 +5703,6 @@ report reason */ /* pref value */ "yes" = "sì"; -/* No comment provided by engineer. */ -"Yes" = "Sì"; - /* No comment provided by engineer. */ "you" = "tu"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 89934d67ce..9d0cccf591 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -2367,7 +2367,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "音声メッセージを送れるのはあなたの連絡相手だけです。"; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "開く"; /* No comment provided by engineer. */ diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index cd026361e0..d2cfcba0de 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -688,9 +688,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Database archiveren"; -/* No comment provided by engineer. */ -"Ask" = "Vragen"; - /* No comment provided by engineer. */ "Attach" = "Bijvoegen"; @@ -3677,7 +3674,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Alleen uw contact kan spraak berichten verzenden."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Open"; /* No comment provided by engineer. */ @@ -3695,21 +3692,12 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Open groep"; -/* No comment provided by engineer. */ -"Open link" = "Link openen"; - -/* No comment provided by engineer. */ -"Open links from chat list" = "Open links van chatlijst"; - /* authentication reason */ "Open migration to another device" = "Open de migratie naar een ander apparaat"; /* No comment provided by engineer. */ "Open Settings" = "Open instellingen"; -/* No comment provided by engineer. */ -"Open web link?" = "Weblink openen?"; - /* No comment provided by engineer. */ "Opening app…" = "App openen…"; @@ -5694,9 +5682,6 @@ report reason */ /* pref value */ "yes" = "Ja"; -/* No comment provided by engineer. */ -"Yes" = "Ja"; - /* No comment provided by engineer. */ "you" = "jij"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 82730db52a..867f3beff4 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -682,9 +682,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Archiwizowanie bazy danych"; -/* No comment provided by engineer. */ -"Ask" = "Zapytaj"; - /* No comment provided by engineer. */ "Attach" = "Dołącz"; @@ -3320,7 +3317,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Tylko Twój kontakt może wysyłać wiadomości głosowe."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Otwórz"; /* No comment provided by engineer. */ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 45a2d6db4f..b819d013b9 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -685,9 +685,6 @@ swipe action */ /* No comment provided by engineer. */ "Archiving database" = "Подготовка архива"; -/* No comment provided by engineer. */ -"Ask" = "Спросить"; - /* No comment provided by engineer. */ "Attach" = "Прикрепить"; @@ -3575,7 +3572,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Только Ваш контакт может отправлять голосовые сообщения."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Открыть"; /* No comment provided by engineer. */ @@ -3593,21 +3590,12 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Открыть группу"; -/* No comment provided by engineer. */ -"Open link" = "Открыть ссылку"; - -/* No comment provided by engineer. */ -"Open links from chat list" = "Открыть ссылку из списка чатов"; - /* authentication reason */ "Open migration to another device" = "Открытие миграции на другое устройство"; /* No comment provided by engineer. */ "Open Settings" = "Открыть Настройки"; -/* No comment provided by engineer. */ -"Open web link?" = "Открыть веб-ссылку?"; - /* No comment provided by engineer. */ "Opening app…" = "Приложение отрывается…"; @@ -5544,9 +5532,6 @@ report reason */ /* pref value */ "yes" = "да"; -/* No comment provided by engineer. */ -"Yes" = "Да"; - /* No comment provided by engineer. */ "you" = "Вы"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index 718d547c67..ab0703333e 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -3353,7 +3353,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sadece karşıdaki kişi sesli mesajlar gönderebilir."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Aç"; /* No comment provided by engineer. */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index f54ecec21d..8e2b514ed4 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -3401,7 +3401,7 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Тільки ваш контакт може надсилати голосові повідомлення."; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "Відкрито"; /* No comment provided by engineer. */ diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index b363d25a4c..6ceeeb22d0 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -61,6 +61,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**推荐**:设备令牌和通知会发送至 SimpleX Chat 通知服务器,但是消息内容、大小或者发送人不会。"; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**扫描/粘贴链接**:用您收到的链接连接。"; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**警告**:及时推送通知需要保存在钥匙串的密码。"; @@ -133,6 +136,9 @@ /* notification title */ "%@ wants to connect!" = "%@ 要连接!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@, %@ 和 %lld 成员"; @@ -145,9 +151,24 @@ /* time interval */ "%d days" = "%d 天"; +/* forward confirmation reason */ +"%d file(s) are still being downloaded." = "仍在下载 %d 个文件。"; + +/* forward confirmation reason */ +"%d file(s) failed to download." = "%d 个文件下载失败。"; + +/* forward confirmation reason */ +"%d file(s) were deleted." = "已刪除 %d 个文件。"; + +/* forward confirmation reason */ +"%d file(s) were not downloaded." = "未能下载 %d 个文件。"; + /* time interval */ "%d hours" = "%d 小时"; +/* alert title */ +"%d messages not forwarded" = "未转发 %d 条消息"; + /* time interval */ "%d min" = "%d 分钟"; @@ -157,8 +178,11 @@ /* time interval */ "%d sec" = "%d 秒"; +/* delete after time */ +"%d seconds(s)" = "%d 秒"; + /* integrity error chat item */ -"%d skipped message(s)" = "%d 跳过消息"; +"%d skipped message(s)" = "跳过的 %d 条消息"; /* time interval */ "%d weeks" = "%d 星期"; @@ -262,6 +286,15 @@ time interval */ time interval */ "1 week" = "1周"; +/* delete after time */ +"1 year" = "1 年"; + +/* No comment provided by engineer. */ +"1-time link" = "一次性链接"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "一次性链接*只能给一名联系人*使用。当面或使用聊天应用分享链接。"; + /* No comment provided by engineer. */ "5 minutes" = "5分钟"; @@ -295,6 +328,9 @@ time interval */ /* No comment provided by engineer. */ "Abort changing address?" = "中止地址更改?"; +/* No comment provided by engineer. */ +"About operators" = "关于运营方"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "关于SimpleX Chat"; @@ -309,6 +345,9 @@ accept incoming call via notification swipe action */ "Accept" = "接受"; +/* No comment provided by engineer. */ +"Accept conditions" = "接受条款"; + /* No comment provided by engineer. */ "Accept connection request?" = "接受联系人?"; @@ -322,18 +361,30 @@ swipe action */ /* call status */ "accepted call" = "已接受通话"; +/* No comment provided by engineer. */ +"Accepted conditions" = "已接受的条款"; + /* No comment provided by engineer. */ "Acknowledged" = "确认"; /* No comment provided by engineer. */ "Acknowledgement errors" = "确认错误"; +/* token status text */ +"Active" = "活跃"; + /* No comment provided by engineer. */ "Active connections" = "活动连接"; /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "将地址添加到您的个人资料,以便您的联系人可以与其他人共享。个人资料更新将发送给您的联系人。"; +/* No comment provided by engineer. */ +"Add friends" = "添加好友"; + +/* No comment provided by engineer. */ +"Add list" = "添加列表"; + /* No comment provided by engineer. */ "Add profile" = "添加个人资料"; @@ -343,12 +394,27 @@ swipe action */ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "扫描二维码来添加服务器。"; +/* No comment provided by engineer. */ +"Add team members" = "添加团队成员"; + /* No comment provided by engineer. */ "Add to another device" = "添加另一设备"; +/* No comment provided by engineer. */ +"Add to list" = "添加到列表"; + /* No comment provided by engineer. */ "Add welcome message" = "添加欢迎信息"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "将你的团队成员加入对话。"; + +/* No comment provided by engineer. */ +"Added media & file servers" = "已添加媒体和文件服务器"; + +/* No comment provided by engineer. */ +"Added message servers" = "已添加消息服务器"; + /* No comment provided by engineer. */ "Additional accent" = "附加重音"; @@ -364,6 +430,12 @@ swipe action */ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "将中止地址更改。将使用旧接收地址。"; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "地址还是一次性链接?"; + +/* No comment provided by engineer. */ +"Address settings" = "地址设置"; + /* member role */ "admin" = "管理员"; @@ -388,12 +460,18 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "同意加密…"; +/* No comment provided by engineer. */ +"All" = "全部"; + /* No comment provided by engineer. */ "All app data is deleted." = "已删除所有应用程序数据。"; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "所有聊天记录和消息将被删除——这一行为无法撤销!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "列表 %@ 和其中全部聊天将被删除。"; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "所有数据在输入后将被删除。"; @@ -406,6 +484,9 @@ swipe action */ /* 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." = "所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。"; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "所有消息都将被删除 - 这无法被撤销!"; @@ -418,6 +499,12 @@ swipe action */ /* profile dropdown */ "All profiles" = "所有配置文件"; +/* 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." = "所有联系人会保持连接。"; @@ -463,6 +550,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "允许不可撤回地删除已发送消息"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "允许向 moderators 举报消息。"; + /* No comment provided by engineer. */ "Allow to send files and media." = "允许发送文件和媒体。"; @@ -517,6 +607,9 @@ swipe action */ /* No comment provided by engineer. */ "and %lld other events" = "和 %lld 其他事件"; +/* report reason */ +"Another reason" = "另一个理由"; + /* No comment provided by engineer. */ "Answer call" = "接听来电"; @@ -532,6 +625,9 @@ swipe action */ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "应用程序为新的本地文件(视频除外)加密。"; +/* No comment provided by engineer. */ +"App group:" = "应用组:"; + /* No comment provided by engineer. */ "App icon" = "应用程序图标"; @@ -541,6 +637,9 @@ swipe action */ /* No comment provided by engineer. */ "App passcode is replaced with self-destruct passcode." = "应用程序密码被替换为自毁密码。"; +/* No comment provided by engineer. */ +"App session" = "应用会话"; + /* No comment provided by engineer. */ "App version" = "应用程序版本"; @@ -556,12 +655,30 @@ swipe action */ /* No comment provided by engineer. */ "Apply to" = "应用于"; +/* No comment provided by engineer. */ +"Archive" = "存档"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "存档 %lld 个举报?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "存档所有举报?"; + /* No comment provided by engineer. */ "Archive and upload" = "存档和上传"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "存档联系人以便稍后聊天."; +/* No comment provided by engineer. */ +"Archive report" = "存档举报"; + +/* No comment provided by engineer. */ +"Archive report?" = "存档举报?"; + +/* swipe action */ +"Archive reports" = "存档举报"; + /* No comment provided by engineer. */ "Archived contacts" = "已存档的联系人"; @@ -613,6 +730,9 @@ swipe action */ /* No comment provided by engineer. */ "Auto-accept images" = "自动接受图片"; +/* alert title */ +"Auto-accept settings" = "自动接受设置"; + /* No comment provided by engineer. */ "Back" = "返回"; @@ -634,15 +754,36 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "错误消息 ID"; +/* No comment provided by engineer. */ +"Better calls" = "更佳的通话"; + /* No comment provided by engineer. */ "Better groups" = "更佳的群组"; +/* No comment provided by engineer. */ +"Better groups performance" = "更好的群性能"; + +/* No comment provided by engineer. */ +"Better message dates." = "更好的消息日期。"; + /* No comment provided by engineer. */ "Better messages" = "更好的消息"; /* No comment provided by engineer. */ "Better networking" = "更好的网络"; +/* No comment provided by engineer. */ +"Better notifications" = "更佳的通知"; + +/* No comment provided by engineer. */ +"Better privacy and security" = "更好的隐私和安全"; + +/* No comment provided by engineer. */ +"Better security ✅" = "更佳的安全性✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "更佳的使用体验"; + /* No comment provided by engineer. */ "Black" = "黑色"; @@ -704,6 +845,15 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "企业地址"; + +/* No comment provided by engineer. */ +"Business chats" = "企业聊天"; + +/* No comment provided by engineer. */ +"Businesses" = "企业"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。"; @@ -777,6 +927,12 @@ alert button */ /* No comment provided by engineer. */ "Change" = "更改"; +/* alert title */ +"Change automatic message deletion?" = "更改消息自动删除设置?"; + +/* authentication reason */ +"Change chat profiles" = "更改聊天资料"; + /* No comment provided by engineer. */ "Change database passphrase?" = "更改数据库密码?"; @@ -820,6 +976,15 @@ set passcode view */ /* chat item text */ "changing address…" = "更改地址…"; +/* No comment provided by engineer. */ +"Chat" = "聊天"; + +/* No comment provided by engineer. */ +"Chat already exists" = "聊天已存在"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "聊天已存在!"; + /* No comment provided by engineer. */ "Chat colors" = "聊天颜色"; @@ -856,15 +1021,30 @@ set passcode view */ /* No comment provided by engineer. */ "Chat preferences" = "聊天偏好设置"; +/* alert message */ +"Chat preferences were changed." = "聊天偏好设置已修改。"; + /* No comment provided by engineer. */ "Chat profile" = "用户资料"; /* No comment provided by engineer. */ "Chat theme" = "聊天主题"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "将为所有成员删除聊天 - 此操作无法撤销!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "将为你删除聊天 - 此操作无法撤销!"; + /* No comment provided by engineer. */ "Chats" = "聊天"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "每 20 分钟检查消息。"; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "在被允许时检查消息。"; + /* alert title */ "Check server address and try again." = "检查服务器地址并再试一次。"; @@ -898,6 +1078,12 @@ set passcode view */ /* No comment provided by engineer. */ "Clear conversation?" = "清除对话吗?"; +/* No comment provided by engineer. */ +"Clear group?" = "清除群?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "清除还是删除群?"; + /* No comment provided by engineer. */ "Clear private notes?" = "清除私密笔记?"; @@ -913,6 +1099,9 @@ set passcode view */ /* No comment provided by engineer. */ "colored" = "彩色"; +/* report reason */ +"Community guidelines violation" = "违反社区指导方针"; + /* server test step */ "Compare file" = "对比文件"; @@ -925,9 +1114,27 @@ set passcode view */ /* No comment provided by engineer. */ "Completed" = "已完成"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "已于 %@ 接受条款。"; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "已接受运营方 **%@** 的条款。"; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "已经接受下列运营方的条款:**%@**。"; + /* No comment provided by engineer. */ "Conditions of use" = "使用条款"; +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "将接受下列运营方的条款:**%@**。"; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "将于 %@ 接受条款。"; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "将在 %@ 自动接受启用的运营方的条款。"; + /* No comment provided by engineer. */ "Configure ICE servers" = "配置 ICE 服务器"; @@ -964,6 +1171,9 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm upload" = "确认上传"; +/* token status text */ +"Confirmed" = "已确定"; + /* server test step */ "Connect" = "连接"; @@ -1063,6 +1273,9 @@ set passcode view */ /* No comment provided by engineer. */ "Connection and servers status." = "连接和服务器状态。"; +/* No comment provided by engineer. */ +"Connection blocked" = "连接被阻止"; + /* No comment provided by engineer. */ "Connection error" = "连接错误"; @@ -1072,12 +1285,24 @@ set passcode view */ /* chat list item title (it should not be shown */ "connection established" = "连接已建立"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "连接被运营方 %@ 阻止"; + +/* No comment provided by engineer. */ +"Connection not ready." = "连接未就绪。"; + /* No comment provided by engineer. */ "Connection notifications" = "连接通知"; /* No comment provided by engineer. */ "Connection request sent!" = "已发送连接请求!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "连接需要加密重协商。"; + +/* No comment provided by engineer. */ +"Connection security" = "连接安全性"; + /* No comment provided by engineer. */ "Connection terminated" = "连接被终止"; @@ -1135,6 +1360,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "联系人可以将信息标记为删除;您将可以查看这些信息。"; +/* blocking reason */ +"Content violates conditions of use" = "内容违反使用条款"; + /* No comment provided by engineer. */ "Continue" = "继续"; @@ -1150,12 +1378,18 @@ set passcode view */ /* No comment provided by engineer. */ "Core version: v%@" = "核心版本: v%@"; +/* No comment provided by engineer. */ +"Corner" = "拐角"; + /* No comment provided by engineer. */ "Correct name to %@?" = "将名称更正为 %@?"; /* No comment provided by engineer. */ "Create" = "创建"; +/* No comment provided by engineer. */ +"Create 1-time link" = "创建一次性链接"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "使用随机身份创建群组."; @@ -1171,6 +1405,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create link" = "创建链接"; +/* No comment provided by engineer. */ +"Create list" = "创建列表"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻"; @@ -1207,6 +1444,9 @@ set passcode view */ /* No comment provided by engineer. */ "creator" = "创建者"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "无法加载当前条款文本,你可以通过此链接审阅条款:"; + /* No comment provided by engineer. */ "Current Passcode" = "当前密码"; @@ -1225,6 +1465,9 @@ set passcode view */ /* No comment provided by engineer. */ "Custom time" = "自定义时间"; +/* No comment provided by engineer. */ +"Customizable message shape." = "可自定义消息形状。"; + /* No comment provided by engineer. */ "Customize theme" = "自定义主题"; @@ -1341,12 +1584,21 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "删除并通知联系人"; +/* No comment provided by engineer. */ +"Delete chat" = "删除聊天"; + +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "从你的设备删除聊天消息。"; + /* No comment provided by engineer. */ "Delete chat profile" = "删除聊天资料"; /* No comment provided by engineer. */ "Delete chat profile?" = "删除聊天资料?"; +/* No comment provided by engineer. */ +"Delete chat?" = "删除聊天?"; + /* No comment provided by engineer. */ "Delete connection" = "删除连接"; @@ -1392,6 +1644,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete link?" = "删除链接?"; +/* alert title */ +"Delete list?" = "删除列表?"; + /* No comment provided by engineer. */ "Delete member message?" = "删除成员消息?"; @@ -1410,6 +1665,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete old database?" = "删除旧数据库吗?"; +/* No comment provided by engineer. */ +"Delete or moderate up to 200 messages." = "允许自行删除或管理员移除最多200条消息。"; + /* No comment provided by engineer. */ "Delete pending connection?" = "删除待定连接?"; @@ -1419,6 +1677,9 @@ swipe action */ /* server test step */ "Delete queue" = "删除队列"; +/* No comment provided by engineer. */ +"Delete report" = "删除举报"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "一次最多删除 20 条信息。"; @@ -1449,6 +1710,9 @@ swipe action */ /* No comment provided by engineer. */ "Deletion errors" = "删除错误"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "已送达,即使苹果已将其删除。"; + /* No comment provided by engineer. */ "Delivery" = "传送"; @@ -1498,7 +1762,7 @@ swipe action */ "Device" = "设备"; /* No comment provided by engineer. */ -"Device authentication is disabled. Turning off SimpleX Lock." = "设备验证被禁用。关闭 SimpleX 锁定。"; +"Device authentication is disabled. Turning off SimpleX Lock." = "设备验证已禁用。 SimpleX 已解锁。"; /* No comment provided by engineer. */ "Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "没有启用设备验证。一旦启用设备验证,您可以通过设置打开 SimpleX 锁定。"; @@ -1516,11 +1780,20 @@ swipe action */ "Direct messages" = "私信"; /* No comment provided by engineer. */ -"Direct messages between members are prohibited." = "此群中禁止成员之间私信。"; +"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)" = "禁用(保留覆盖)"; +/* alert title */ +"Disable automatic message deletion?" = "禁用消息自动销毁?"; + +/* alert button */ +"Disable delete messages" = "停用消息删除"; + /* No comment provided by engineer. */ "Disable for all" = "全部禁用"; @@ -1572,21 +1845,33 @@ swipe action */ /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "请勿直接发送消息,即使您的服务器或目标服务器不支持私有路由。"; +/* No comment provided by engineer. */ +"Do not use credentials with proxy." = "代理不使用身份验证凭据。"; + /* No comment provided by engineer. */ "Do NOT use private routing." = "不要使用私有路由。"; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "请勿使用 SimpleX 进行紧急通话。"; +/* No comment provided by engineer. */ +"Documents:" = "文档:"; + /* No comment provided by engineer. */ "Don't create address" = "不创建地址"; /* No comment provided by engineer. */ "Don't enable" = "不要启用"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "不错过重要消息。"; + /* No comment provided by engineer. */ "Don't show again" = "不再显示"; +/* No comment provided by engineer. */ +"Done" = "完成"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "降级并打开聊天"; @@ -1603,6 +1888,9 @@ chat item action */ /* server test step */ "Download file" = "下载文件"; +/* alert action */ +"Download files" = "下载文件"; + /* No comment provided by engineer. */ "Downloaded" = "已下载"; @@ -1622,7 +1910,7 @@ chat item action */ "duplicate message" = "重复的消息"; /* No comment provided by engineer. */ -"duplicates" = "复本"; +"duplicates" = "副本"; /* No comment provided by engineer. */ "Duration" = "时长"; @@ -1630,6 +1918,9 @@ chat item action */ /* No comment provided by engineer. */ "e2e encrypted" = "端到端加密"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "端到端加密的通知。"; + /* chat item action */ "Edit" = "编辑"; @@ -1648,6 +1939,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" = "全部启用"; @@ -1759,6 +2053,9 @@ chat item action */ /* chat item text */ "encryption re-negotiation required for %@" = "需要为 %@ 重新进行加密协商"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "正进行加密重协商。"; + /* No comment provided by engineer. */ "ended" = "已结束"; @@ -1807,21 +2104,33 @@ chat item action */ /* No comment provided by engineer. */ "Error aborting address change" = "中止地址更改错误"; +/* alert title */ +"Error accepting conditions" = "接受条款出错"; + /* No comment provided by engineer. */ "Error accepting contact request" = "接受联系人请求错误"; /* No comment provided by engineer. */ "Error adding member(s)" = "添加成员错误"; +/* alert title */ +"Error adding server" = "添加服务器出错"; + /* No comment provided by engineer. */ "Error changing address" = "更改地址错误"; +/* No comment provided by engineer. */ +"Error changing connection profile" = "更改连接资料出错"; + /* No comment provided by engineer. */ "Error changing role" = "更改角色错误"; /* No comment provided by engineer. */ "Error changing setting" = "更改设置错误"; +/* No comment provided by engineer. */ +"Error changing to incognito!" = "切换至隐身聊天出错!"; + /* No comment provided by engineer. */ "Error connecting to forwarding server %@. Please try later." = "连接到转发服务器 %@ 时出错。请稍后尝试。"; @@ -1834,6 +2143,9 @@ chat item action */ /* No comment provided by engineer. */ "Error creating group link" = "创建群组链接错误"; +/* alert title */ +"Error creating list" = "创建列表出错"; + /* No comment provided by engineer. */ "Error creating member contact" = "创建成员联系人时出错"; @@ -1843,6 +2155,9 @@ chat item action */ /* No comment provided by engineer. */ "Error creating profile!" = "创建资料错误!"; +/* No comment provided by engineer. */ +"Error creating report" = "创建举报出错"; + /* No comment provided by engineer. */ "Error decrypting file" = "解密文件时出错"; @@ -1891,6 +2206,12 @@ chat item action */ /* No comment provided by engineer. */ "Error joining group" = "加入群组错误"; +/* alert title */ +"Error loading servers" = "加载服务器出错"; + +/* No comment provided by engineer. */ +"Error migrating settings" = "迁移设置出错"; + /* No comment provided by engineer. */ "Error opening chat" = "打开聊天时出错"; @@ -1903,12 +2224,21 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "重新连接服务器时出错"; +/* alert title */ +"Error registering for notifications" = "注册消息推送出错"; + /* No comment provided by engineer. */ "Error removing member" = "删除成员错误"; +/* alert title */ +"Error reordering lists" = "重排列表出错"; + /* No comment provided by engineer. */ "Error resetting statistics" = "重置统计信息时出错"; +/* alert title */ +"Error saving chat list" = "保存聊天列表出错"; + /* No comment provided by engineer. */ "Error saving group profile" = "保存群组资料错误"; @@ -1921,6 +2251,9 @@ chat item action */ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "保存密码到钥匙串错误"; +/* alert title */ +"Error saving servers" = "保存服务器出错"; + /* when migrating */ "Error saving settings" = "保存设置出错"; @@ -1948,18 +2281,27 @@ chat item action */ /* No comment provided by engineer. */ "Error stopping chat" = "停止聊天错误"; +/* No comment provided by engineer. */ +"Error switching profile" = "切换配置文件出错"; + /* alertTitle */ "Error switching profile!" = "切换资料错误!"; /* No comment provided by engineer. */ "Error synchronizing connection" = "同步连接错误"; +/* No comment provided by engineer. */ +"Error testing server connection" = "检验服务器连接出错"; + /* No comment provided by engineer. */ "Error updating group link" = "更新群组链接错误"; /* No comment provided by engineer. */ "Error updating message" = "更新消息错误"; +/* alert title */ +"Error updating server" = "更新服务器出错"; + /* No comment provided by engineer. */ "Error updating settings" = "更新设置错误"; @@ -1989,6 +2331,9 @@ snd error text */ /* No comment provided by engineer. */ "Errors" = "错误"; +/* servers error */ +"Errors in servers configuration." = "服务器配置有错误。"; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "即使在对话中被禁用。"; @@ -2001,6 +2346,9 @@ snd error text */ /* No comment provided by engineer. */ "expired" = "过期"; +/* token status text */ +"Expired" = "已过期"; + /* No comment provided by engineer. */ "Export database" = "导出数据库"; @@ -2025,15 +2373,30 @@ snd error text */ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "快速且无需等待发件人在线!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "更快地删除群。"; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "加入速度更快、信息更可靠。"; +/* No comment provided by engineer. */ +"Faster sending messages." = "更快发送消息。"; + /* swipe action */ "Favorite" = "最喜欢"; +/* No comment provided by engineer. */ +"Favorites" = "收藏"; + /* file error alert title */ "File error" = "文件错误"; +/* alert message */ +"File errors:\n%@" = "文件错误:\n%@"; + +/* file error text */ +"File is blocked by server operator:\n%@." = "文件被服务器运营方阻止:\n%@。"; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "找不到文件 - 很可能文件已被删除或取消。"; @@ -2109,15 +2472,45 @@ snd error text */ /* No comment provided by engineer. */ "Fix not supported by group member" = "修复群组成员不支持的问题"; +/* No comment provided by engineer. */ +"For all moderators" = "所有 moderators"; + +/* servers error */ +"For chat profile %@:" = "为聊天资料 %@:"; + /* No comment provided by engineer. */ "For console" = "用于控制台"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "比如,如果你通过 SimpleX 服务器收到消息,应用会通过 Flux 服务器传送它们。"; + +/* No comment provided by engineer. */ +"For me" = "仅自己"; + +/* No comment provided by engineer. */ +"For private routing" = "用于私密路由"; + +/* No comment provided by engineer. */ +"For social media" = "用于社交媒体"; + /* chat item action */ "Forward" = "转发"; +/* alert title */ +"Forward %d message(s)?" = "转发 %d 条消息?"; + /* No comment provided by engineer. */ "Forward and save messages" = "转发并保存消息"; +/* alert action */ +"Forward messages" = "已转发的消息"; + +/* alert message */ +"Forward messages without files?" = "仅转发消息不转发文件?"; + +/* No comment provided by engineer. */ +"Forward up to 20 messages at once." = "一次转发最多20条消息。"; + /* No comment provided by engineer. */ "forwarded" = "已转发"; @@ -2127,6 +2520,9 @@ snd error text */ /* No comment provided by engineer. */ "Forwarded from" = "转发自"; +/* No comment provided by engineer. */ +"Forwarding %lld messages" = "正在转发 %lld 条消息"; + /* No comment provided by engineer. */ "Forwarding server %@ failed to connect to destination server %@. Please try later." = "转发服务器 %@ 无法连接到目标服务器 %@。请稍后尝试。"; @@ -2163,6 +2559,9 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "进一步减少电池使用"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "被提及时收到通知。"; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF 和贴纸"; @@ -2235,9 +2634,15 @@ snd error text */ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "将为您删除群组——此操作无法撤消!"; +/* No comment provided by engineer. */ +"Groups" = "群"; + /* No comment provided by engineer. */ "Help" = "帮助"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "帮助管理员管理群组。"; + /* No comment provided by engineer. */ "Hidden" = "隐藏"; @@ -2268,6 +2673,15 @@ snd error text */ /* time unit */ "hours" = "小时"; +/* No comment provided by engineer. */ +"How it affects privacy" = "它如何影响隐私"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "它如何帮助隐私"; + +/* alert button */ +"How it works" = "工作原理"; + /* No comment provided by engineer. */ "How SimpleX works" = "SimpleX的工作原理"; @@ -2331,6 +2745,9 @@ snd error text */ /* No comment provided by engineer. */ "Importing archive" = "正在导入存档"; +/* No comment provided by engineer. */ +"Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" = "改善传送,降低流量使用。\n更多改进即将推出!"; + /* No comment provided by engineer. */ "Improved message delivery" = "改进了消息传递"; @@ -2352,6 +2769,12 @@ snd error text */ /* No comment provided by engineer. */ "inactive" = "无效"; +/* report reason */ +"Inappropriate content" = "不当内容"; + +/* report reason */ +"Inappropriate profile" = "不当个人资料"; + /* No comment provided by engineer. */ "Incognito" = "隐身聊天"; @@ -2418,6 +2841,21 @@ snd error text */ /* No comment provided by engineer. */ "Interface colors" = "界面颜色"; +/* token status text */ +"Invalid" = "无效"; + +/* token status text */ +"Invalid (bad token)" = "Token 无效"; + +/* token status text */ +"Invalid (expired)" = "无效(已过期)"; + +/* token status text */ +"Invalid (unregistered)" = "无效(未注册)"; + +/* token status text */ +"Invalid (wrong topic)" = "无效(话题有误)"; + /* invalid chat data */ "invalid chat" = "无效聊天"; @@ -2469,6 +2907,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite members" = "邀请成员"; +/* No comment provided by engineer. */ +"Invite to chat" = "邀请加入聊天"; + /* No comment provided by engineer. */ "Invite to group" = "邀请加入群组"; @@ -2490,6 +2931,9 @@ snd error text */ /* No comment provided by engineer. */ "iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "在您重启应用或改变密码后,iOS钥匙串将被用来安全地存储密码——它将允许接收推送通知。"; +/* No comment provided by engineer. */ +"IP address" = "IP 地址"; + /* No comment provided by engineer. */ "Irreversible message deletion" = "不可撤回消息移除"; @@ -2580,6 +3024,12 @@ snd error text */ /* swipe action */ "Leave" = "离开"; +/* No comment provided by engineer. */ +"Leave chat" = "离开聊天"; + +/* No comment provided by engineer. */ +"Leave chat?" = "离开聊天?"; + /* No comment provided by engineer. */ "Leave group" = "离开群组"; @@ -2607,6 +3057,15 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "已链接桌面"; +/* swipe action */ +"List" = "列表"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "所有列表的名称和表情符号都应不同。"; + +/* No comment provided by engineer. */ +"List name..." = "列表名…"; + /* No comment provided by engineer. */ "LIVE" = "实时"; @@ -2676,12 +3135,21 @@ snd error text */ /* 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!" = "成员将被移出群组——此操作无法撤消!"; @@ -2691,6 +3159,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "群组成员可以不可撤回地删除已发送的消息"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "成员可以向 moderators 举报消息。"; + /* No comment provided by engineer. */ "Members can send direct messages." = "群组成员可以私信。"; @@ -2706,6 +3177,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can send voice messages." = "群组成员可以发送语音消息。"; +/* No comment provided by engineer. */ +"Mention members 👋" = "提及成员👋"; + /* No comment provided by engineer. */ "Menus" = "菜单"; @@ -2751,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Message servers" = "消息服务器"; +/* No comment provided by engineer. */ +"Message shape" = "消息形状"; + /* No comment provided by engineer. */ "Message source remains private." = "消息来源保持私密。"; @@ -2775,12 +3252,18 @@ snd error text */ /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "将显示来自 %@ 的消息!"; +/* alert message */ +"Messages in this chat will never be deleted." = "此聊天中的消息永远不会被删除。"; + /* No comment provided by engineer. */ "Messages received" = "收到的消息"; /* No comment provided by engineer. */ "Messages sent" = "已发送的消息"; +/* alert message */ +"Messages were deleted after you selected them." = "在你选中消息后这些消息已被删除。"; + /* 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." = "消息、文件和通话受到 **端到端加密** 的保护,具有完全正向保密、否认和闯入恢复。"; @@ -2847,12 +3330,18 @@ snd error text */ /* time unit */ "months" = "月"; +/* swipe action */ +"More" = "更多"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "更多改进即将推出!"; /* No comment provided by engineer. */ "More reliable network connection." = "更可靠的网络连接。"; +/* No comment provided by engineer. */ +"More reliable notifications" = "更可靠的通知"; + /* item status description */ "Most likely this connection is deleted." = "此连接很可能已被删除。"; @@ -2862,6 +3351,9 @@ snd error text */ /* notification label action */ "Mute" = "静音"; +/* notification label action */ +"Mute all" = "全部静音"; + /* No comment provided by engineer. */ "Muted when inactive!" = "不活动时静音!"; @@ -2874,12 +3366,18 @@ snd error text */ /* No comment provided by engineer. */ "Network connection" = "网络连接"; +/* No comment provided by engineer. */ +"Network decentralization" = "网络去中心化"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "网络问题 - 消息在多次尝试发送后过期。"; /* No comment provided by engineer. */ "Network management" = "网络管理"; +/* No comment provided by engineer. */ +"Network operator" = "网络运营方"; + /* No comment provided by engineer. */ "Network settings" = "网络设置"; @@ -2889,6 +3387,9 @@ snd error text */ /* delete after time */ "never" = "从不"; +/* token status text */ +"New" = "新"; + /* No comment provided by engineer. */ "New chat" = "新聊天"; @@ -2907,6 +3408,9 @@ snd error text */ /* No comment provided by engineer. */ "New display name" = "新显示名"; +/* notification */ +"New events" = "新事件"; + /* No comment provided by engineer. */ "New in %@" = "%@ 的新内容"; @@ -2928,6 +3432,15 @@ snd error text */ /* No comment provided by engineer. */ "New passphrase…" = "新密码……"; +/* No comment provided by engineer. */ +"New server" = "新服务器"; + +/* No comment provided by engineer. */ +"New SOCKS credentials will be used every time you start the app." = "每次启动应用都会使用新的 SOCKS 凭据。"; + +/* No comment provided by engineer. */ +"New SOCKS credentials will be used for each server." = "每个服务器都会使用新的 SOCKS 凭据。"; + /* pref value */ "no" = "否"; @@ -2937,6 +3450,15 @@ snd error text */ /* Authentication unavailable */ "No app password" = "没有应用程序密码"; +/* No comment provided by engineer. */ +"No chats" = "无聊天"; + +/* No comment provided by engineer. */ +"No chats found" = "找不到聊天"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "列表 %@ 中无聊天"; + /* No comment provided by engineer. */ "No contacts selected" = "未选择联系人"; @@ -2967,9 +3489,24 @@ snd error text */ /* No comment provided by engineer. */ "No info, try to reload" = "无信息,尝试重新加载"; +/* servers error */ +"No media & file servers." = "无媒体和文件服务器。"; + +/* No comment provided by engineer. */ +"No message" = "无消息"; + +/* servers error */ +"No message servers." = "无消息服务器。"; + /* No comment provided by engineer. */ "No network connection" = "无网络连接"; +/* No comment provided by engineer. */ +"No permission to record speech" = "无录音权限"; + +/* No comment provided by engineer. */ +"No permission to record video" = "无录像权限"; + /* No comment provided by engineer. */ "No permission to record voice message" = "没有录制语音消息的权限"; @@ -2979,24 +3516,57 @@ snd error text */ /* No comment provided by engineer. */ "No received or sent files" = "未收到或发送文件"; +/* servers error */ +"No servers for private message routing." = "无私密消息路由服务器。"; + +/* servers error */ +"No servers to receive files." = "无文件接收服务器。"; + +/* servers error */ +"No servers to receive messages." = "无消息接收服务器。"; + +/* servers error */ +"No servers to send files." = "无文件发送服务器。"; + /* copied message info in history */ "no text" = "无文本"; +/* alert title */ +"No token!" = "无 token!"; + +/* No comment provided by engineer. */ +"No unread chats" = "没有未读聊天"; + /* No comment provided by engineer. */ "No user identifiers." = "没有用户标识符。"; /* No comment provided by engineer. */ "Not compatible!" = "不兼容!"; +/* No comment provided by engineer. */ +"Notes" = "附注"; + /* No comment provided by engineer. */ "Nothing selected" = "未选中任何内容"; +/* alert title */ +"Nothing to forward!" = "无可转发!"; + /* No comment provided by engineer. */ "Notifications" = "通知"; /* No comment provided by engineer. */ "Notifications are disabled!" = "通知被禁用!"; +/* alert title */ +"Notifications error" = "通知错误"; + +/* No comment provided by engineer. */ +"Notifications privacy" = "通知隐私"; + +/* alert title */ +"Notifications status" = "通知状态"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "现在管理员可以:\n- 删除成员的消息。\n- 禁用成员(“观察员”角色)"; @@ -3041,6 +3611,9 @@ time to disappear */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "将不会使用 Onion 主机。"; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "仅聊天所有人可更改首选项。"; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "只有客户端设备存储用户资料、联系人、群组和**双层端到端加密**发送的消息。"; @@ -3056,6 +3629,12 @@ time to disappear */ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "只有群主可以启用语音信息。"; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "仅发送人和moderators能看到"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "只有你和moderators能看到"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "只有您可以添加消息回应。"; @@ -3086,15 +3665,21 @@ time to disappear */ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "只有您的联系人可以发送语音消息。"; -/* No comment provided by engineer. */ +/* alert action */ "Open" = "打开"; +/* No comment provided by engineer. */ +"Open changes" = "打开更改"; + /* No comment provided by engineer. */ "Open chat" = "打开聊天"; /* authentication reason */ "Open chat console" = "打开聊天控制台"; +/* No comment provided by engineer. */ +"Open conditions" = "打开条款"; + /* No comment provided by engineer. */ "Open group" = "打开群"; @@ -3107,6 +3692,15 @@ time to disappear */ /* No comment provided by engineer. */ "Opening app…" = "正在打开应用程序…"; +/* No comment provided by engineer. */ +"Operator" = "运营方"; + +/* alert title */ +"Operator server" = "运营方服务器"; + +/* No comment provided by engineer. */ +"Or import archive file" = "或者导入或者导入压缩文件"; + /* No comment provided by engineer. */ "Or paste archive link" = "或粘贴存档链接"; @@ -3119,6 +3713,12 @@ time to disappear */ /* No comment provided by engineer. */ "Or show this code" = "或者显示此码"; +/* No comment provided by engineer. */ +"Or to share privately" = "或者私下分享"; + +/* No comment provided by engineer. */ +"Organize chats into lists" = "将聊天组织到列表"; + /* No comment provided by engineer. */ "other" = "其他"; @@ -3149,6 +3749,9 @@ time to disappear */ /* No comment provided by engineer. */ "Passcode set!" = "密码已设置!"; +/* No comment provided by engineer. */ +"Password" = "密码"; + /* No comment provided by engineer. */ "Password to show" = "显示密码"; @@ -3604,19 +4207,22 @@ swipe action */ "Reveal" = "揭示"; /* No comment provided by engineer. */ -"Revoke" = "撤销"; - -/* cancel file action */ -"Revoke file" = "撤销文件"; +"Review conditions" = "审阅条款"; /* No comment provided by engineer. */ -"Revoke file?" = "撤销文件?"; +"Revoke" = "吊销"; + +/* cancel file action */ +"Revoke file" = "吊销文件"; + +/* No comment provided by engineer. */ +"Revoke file?" = "吊销文件?"; /* No comment provided by engineer. */ "Role" = "角色"; /* No comment provided by engineer. */ -"Run chat" = "运行聊天程序"; +"Run chat" = "运行聊天"; /* No comment provided by engineer. */ "Safely receive files" = "安全接收文件"; @@ -3646,6 +4252,9 @@ chat item action */ /* No comment provided by engineer. */ "Save group profile" = "保存群组资料"; +/* No comment provided by engineer. */ +"Save list" = "保存列表"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "保存密码并打开聊天"; @@ -3667,6 +4276,9 @@ chat item action */ /* No comment provided by engineer. */ "Save welcome message?" = "保存欢迎信息?"; +/* alert title */ +"Save your profile?" = "保存您的个人资料?"; + /* No comment provided by engineer. */ "saved" = "已保存"; @@ -3685,6 +4297,9 @@ chat item action */ /* No comment provided by engineer. */ "Saved WebRTC ICE servers will be removed" = "已保存的WebRTC ICE服务器将被删除"; +/* No comment provided by engineer. */ +"Saving %lld messages" = "正在保存 %lld 条消息"; + /* No comment provided by engineer. */ "Scale" = "规模"; @@ -4025,6 +4640,18 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX Address" = "SimpleX 地址"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "可以通过任何消息应用安全分享 SimpleX 地址和一次性链接。"; + +/* 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 控制的服务器纳入 SimpleX 应用。"; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "SimpleX Chat 的安全性 由 Trail of Bits 审核。"; @@ -4032,10 +4659,10 @@ chat item action */ "SimpleX contact address" = "SimpleX 联系地址"; /* notification */ -"SimpleX encrypted message or connection event" = "SimpleX 加密消息或连接项目"; +"SimpleX encrypted message or connection event" = "SimpleX 加密的消息或连接事件"; /* simplex link type */ -"SimpleX group link" = "SimpleX 群组链接"; +"SimpleX group link" = "SimpleX 群链接"; /* chat feature */ "SimpleX links" = "SimpleX 链接"; @@ -4061,6 +4688,9 @@ chat item action */ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX 一次性邀请"; +/* No comment provided by engineer. */ +"SimpleX protocols reviewed by Trail of Bits." = "SimpleX 协议由 Trail of Bits 审阅。"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "简化的隐身模式"; @@ -4082,6 +4712,9 @@ chat item action */ /* blur media */ "Soft" = "软"; +/* No comment provided by engineer. */ +"Some app settings were not migrated." = "部分应用设置未被迁移。"; + /* No comment provided by engineer. */ "Some file(s) were not exported:" = "某些文件未导出:"; 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 6472faa671..6646720c5c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1145,7 +1145,7 @@ Die ID der nächsten Nachricht ist falsch (kleiner oder gleich der vorherigen). \nDies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompromittiert wurde. %1$d Nachrichten konnten nicht entschlüsselt werden. - Der Hash der vorherigen Nachricht unterscheidet sich.\" + Der Hash der vorherigen Nachricht unterscheidet sich. Sie können die SimpleX-Sperre über die Einstellungen aktivieren. SOCKS-Proxy-Einstellungen System-Authentifizierung @@ -2460,6 +2460,6 @@ Für diesen Link wird eine neuere App-Version benötigt. Bitte aktualisieren Sie die App oder bitten Sie Ihren Kontakt einen kompatiblen Link zu senden. Alle Server Aus - Für voreingestellte Server nur TCP-Port 443 verwenden . + TCP-Port 443 nur für voreingestellte Server verwenden. Voreingestellte Server diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 171b367ca0..5667c42d2d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1068,7 +1068,7 @@ Hash de mensaje incorrecto ID de mensaje incorrecto Puede ocurrir si tu contacto o tu usáis una copia de seguridad antigua de la base de datos. - El hash del mensaje anterior es diferente.\" + El hash del mensaje anterior es diferente. %1$d mensaje(s) no ha(n) podido ser descifrado(s). Detener archivo El archivo será eliminado de los servidores. @@ -1552,7 +1552,7 @@ ¿Bloqear miembro para todos? Creado: %s Bloquear para todos - ¿Desbloquear miembro para todos? + ¿Desbloquear el miembro para todos? Desbloquear para todos bloqueado bloqueado por administrador @@ -2360,7 +2360,7 @@ rechazado ¿Expulsar miembros? ¡Los mensajes de estos miembros serán mostrados! - ¿Desbloquear miembros para todos? + ¿Desbloquear los miembros para todos? ¡Todos los mensajes nuevos de estos miembros estarán ocultos! ¿Bloquear miembros para todos? Los miembros serán expulsados del chat. ¡No puede deshacerse! 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 2776310540..c8897c4063 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1349,7 +1349,7 @@ 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.]]> - Biztonságos kvantumálló-protokollon keresztül. + Biztonságos kvantumbiztos protokollon keresztül. - legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni üzenet-eltűnési időkorlát.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]>
%s ekkor: %s @@ -1652,7 +1652,7 @@ Átköltöztetés egy másik eszközre Figyelmeztetés: az archívum törölve lesz.]]> Átköltöztetés egy másik eszközről - Kvantumálló titkosítás + Kvantumbiztos titkosítás Megpróbálhatja még egyszer. Átköltöztetés befejezve Átköltöztetés egy másik eszközre QR-kód használatával. @@ -1660,12 +1660,12 @@ Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> Megpróbálhatja még egyszer. Érvénytelen hivatkozás - végpontok közötti kvantumálló titkosítás + végpontok közötti kvantumbiztos titkosítás 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 kvantumálló titkosítással védett. - végpontok közötti titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> - végpontok közötti kvantumálló titkosítással sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve.]]> + 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.]]> 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 @@ -2172,7 +2172,7 @@ A küldéshez másolhatja és csökkentheti az üzenet méretét. Adja hozzá a munkatársait a beszélgetésekhez. Üzleti cím - végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek.]]> + végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek.]]> Hogyan segíti az adatvédelmet Nincs háttérszolgáltatás Értesítések és akkumulátor 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 201745f042..6c086835ea 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1069,7 +1069,7 @@ \nPuò accadere a causa di qualche bug o quando la connessione è compromessa.
%1$d messaggi non decifrati. Hash del messaggio errato - L\'hash del messaggio precedente è diverso.\" + L\'hash del messaggio precedente è diverso. Si prega di segnalarlo agli sviluppatori. L\'invio del file verrà interrotto. Ferma file 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 a85bd119c5..28a5f6f50d 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 @@ -1059,7 +1059,7 @@ 当你或你的连接使用旧数据库备份时,可能会发生这种情况。 解密错误 请向开发者报告。 - 上一条消息的散列不同。\" + 上条消息的哈希值不同。 下一条消息的 ID 不正确(小于或等于上一条)。 \n它可能是由于某些错误或连接被破坏才发生。 停止文件 @@ -1068,10 +1068,10 @@ 停止接收文件? 即将停止接收文件。 停止 - 撤销文件 - 撤销文件? + 吊销文件 + 吊销文件? 文件将从服务器中删除。 - 撤销 + 吊销 音频/视频通话 " \n在 v5.1 版本中可用" @@ -1330,7 +1330,7 @@ 更改密码或重启应用后,密码将以明文形式保存在设置中。 粘贴你收到的链接以与你的联系人联系… 送达回执 - 没有选择聊天 + 没有选中的聊天 可以加密 重新协商加密 禁用(保留组覆盖) @@ -2061,7 +2061,7 @@ 用户名 分享配置文件 转发消息出错 - 在你选中消息后这些消息被删除。 + 在你选中消息后这些消息已被删除。 %1$d 个文件错误:\n%2$s 其他 %1$d 个文件错误。 %1$d 个文件未被下载。 @@ -2070,29 +2070,29 @@ 没什么可转发的! 仍有 %1$d 个文件在下载中。 %1$d 个文件下载失败。 - %1$d 个文件被删除了。 + 删除了 %1$d 个文件。 下载 %1$s 条消息未被转发 转发消息… 转发 %1$s 条消息 保存 %1$s 条消息 已静音 - 管理形状 + 消息形状 拐角 尾部 初始化 WebView 出错。确保你安装了 WebView 且其支持的架构为 arm64。\n错误:%s 应用会话 - 每次启动应用都会使用新的 SOCKS5 凭据。 + 每次启动应用都会使用新的 SOCKS 凭据。 服务器 打开 Safari 设置/网站/麦克风,接着在 localhost 选择“允许”。 要进行通话,请允许使用设备麦克风。结束通话并尝试再次呼叫。 单击地址栏附近的“信息”按钮允许使用麦克风。 - 每个服务器都会使用新的 SOCKS5 凭据。 + 每个服务器都会使用新的 SOCKS 凭据。 更好的消息日期。 更佳的安全性✅ 更佳的使用体验 可自定义消息形状。 - 一次性转发最多20条消息。 + 一次转发最多20条消息。 Trail of Bits 审核了 SimpleX 协议。 通话期间切换音频和视频。 对一次性邀请切换聊天配置文件。 @@ -2160,11 +2160,11 @@ 应用通过在每个对话中使用不同运营方保护你的隐私。 接受条款 模糊 - 地址或一次性链接? + 地址还是一次性链接? 已添加消息服务器 已添加媒体和文件服务器 地址设置 - 已接受条款 + 已接受的条款 应用工具栏 仅用于一名联系人 - 面对面或通过任何消息应用分享.]]> %s.]]> @@ -2192,7 +2192,7 @@ 你可以在“网络和服务器”设置中配置运营方。 接受运营方条款的日期:%s 远程移动设备 - 或者导入压缩文件 + 或者导入存档文件 小米设备:请在系统设置中开启“自动启动”让通知正常工作。]]> 消息太大! 你可以复制并减小消息大小来发送它。 @@ -2282,8 +2282,8 @@ 另一个理由 已存档的举报 违反社区指导方针 - 不恰当的内容 - 不恰当的个人资料 + 不当内容 + 不当个人资料 仅发送人和moderators能看到 只有你和moderators能看到 垃圾信息 @@ -2337,7 +2337,7 @@ 更好的隐私和安全 不错过重要消息。 更快地删除群。 - 更快死发送消息。 + 更快发送消息。 被提及时收到通知。 帮助管理员管理群组。 将聊天组织到列表 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 3038dd3d6c..8ae414de00 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1495,7 +1495,7 @@ %s 的版本。請檢察兩台裝置安裝的是否版本相同]]> 更可靠的網路連接 發現和加入群組 - 裝置 + 設備 新行動裝置 保存設定出錯 導出的檔案不存在 @@ -1628,7 +1628,7 @@ 找到桌面 自動連接 與PC版的連接不穩定 - 桌面 + 桌上電腦 已安裝的PC版本不支援。請確認兩台裝置所安裝的版本相同 PC版邀請碼錯誤 通過連結連接? From c0b9a0e0944d79aba366b13fbb73bed8454742ca Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 12 May 2025 14:40:36 +0000 Subject: [PATCH 235/289] android, desktop: narrow condition for showing reported count toolbar (to avoid showing it to regular members who received reports due to a bug in older version) (#5894) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 dc1c0b71a5..b3fdcf79c0 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 @@ -833,7 +833,7 @@ fun ChatLayout( } val reportsCount = reportsCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (chatsCtx.contentTag == null && reportsCount > 0) { + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate && chatsCtx.contentTag == null && reportsCount > 0) { ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) } else { StatusBarBackground() @@ -865,7 +865,7 @@ fun ChatLayout( SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (chatsCtx.contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate && chatsCtx.contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) } } From c822fa53f60a976dfed971959c6d1a1429848d57 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 12 May 2025 14:02:39 +0100 Subject: [PATCH 236/289] ios: 6.3.4 (build 276) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index af1eebd8b3..8a035c70a9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1971,7 +1971,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2021,7 +2021,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2063,7 +2063,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2083,7 +2083,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2108,7 +2108,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2145,7 +2145,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2182,7 +2182,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2233,7 +2233,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 275; + CURRENT_PROJECT_VERSION = 276; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -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 = 275; + CURRENT_PROJECT_VERSION = 276; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -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 = 275; + CURRENT_PROJECT_VERSION = 276; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From a5a9d4f7d5d647631a2c75bc3b0aee94258c5134 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 May 2025 16:27:42 +0100 Subject: [PATCH 237/289] website: translations (#5892) * 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 (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/ --------- Co-authored-by: Bezruchenko Simon Co-authored-by: dns Co-authored-by: 4 Bi 5aYzVk 93FCVjWLWxh44XH3984teVSfjwFYmUGUrbvnHwGirk9 Co-authored-by: summoner001 --- website/langs/hu.json | 4 +- website/langs/uk.json | 40 ++++---- website/langs/zh_Hans.json | 2 +- website/langs/zh_Hant.json | 190 +++++++++++++++++++++++++++++++++++-- 4 files changed, 206 insertions(+), 30 deletions(-) diff --git a/website/langs/hu.json b/website/langs/hu.json index f062899224..8acd7d9a60 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -24,7 +24,7 @@ "simplex-chat-protocol": "A SimpleX Chat-protokoll", "terminal-cli": "Terminál CLI", "terms-and-privacy-policy": "Adatvédelmi irányelvek", - "hero-header": "Újradefiniált adatvédelem", + "hero-header": "Újraértelmezett adatvédelem", "hero-subheader": "Az első üzenetváltó-alkalmazás
felhasználói azonosítók nélkül", "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX azonban nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", "hero-overlay-1-textlink": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", @@ -138,7 +138,7 @@ "donate-here-to-help-us": "Adományozzon és segítsen nekünk", "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ép-alkalmazásának letöltése", + "get-simplex": "A SimpleX számítógépes alkalmazásának letöltése", "why-simplex-is-unique": "A SimpleX mitől egyedülálló", "learn-more": "Tudjon meg többet", "more-info": "További információ", diff --git a/website/langs/uk.json b/website/langs/uk.json index 71b3254e63..1c1780edb3 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -14,19 +14,19 @@ "simplex-network-overlay-card-1-li-6": "P2P-мережі можуть бути вразливими до атаки DRDoS, коли клієнти можуть ребродкастити та збільшувати трафік, що призводить до відмови в обслуговуванні на рівні всієї мережі. Клієнти SimpleX лише пересилають трафік від відомого підключення і не можуть бути використані зловмисником для збільшення трафіку в усій мережі.", "privacy-matters-overlay-card-1-p-2": "Інтернет-роздрібники знають, що люди з низьким доходом частіше роблять термінові покупки, тому вони можуть встановлювати вищі ціни або скасовувати знижки.", "privacy-matters-overlay-card-1-p-3": "Деякі фінансові та страхові компанії використовують соціальні графи для визначення ставок та страхових премій. Це часто змушує людей з низькими доходами платити більше — це відомо як 'поширений преміум'.", - "privacy-matters-overlay-card-1-p-4": "Платформа SimpleX захищає конфіденційність ваших з'єднань краще, ніж будь-яка альтернатива, повністю запобігаючи доступу вашого соціального графа будь-яким компаніям чи організаціям. Навіть коли люди використовують сервери, надані SimpleX Chat, ми не знаємо кількість користувачів чи їх зв'язки.", + "privacy-matters-overlay-card-1-p-4": "Мережа SimpleX краще захищає конфіденційність ваших з'єднань, ніж будь-яка інша альтернатива, повністю запобігаючи тому, щоб ваш соціальний граф став доступним для будь-яких компаній чи організацій. Навіть коли люди використовують сервери, попередньо налаштовані в додатках SimpleX Chat, оператори серверів не знають кількості користувачів або їхніх з'єднань.", "privacy-matters-overlay-card-2-p-1": "Не так давно ми спостерігали, як великі вибори маніпулювалися поважною консалтинговою компанією, яка використовувала наші соціальні графи для спотворення нашого уявлення про реальний світ та маніпулювання нашими голосами.", - "privacy-matters-overlay-card-2-p-2": "Щоб бути об'єктивним та приймати незалежні рішення, вам потрібно контролювати ваш інформаційний простір. Це можливо лише за умови використання приватної платформи для спілкування, яка не має доступу до вашого соціального графа.", - "privacy-matters-overlay-card-2-p-3": "SimpleX - це перша платформа, яка не має жодних ідентифікаторів користувачів за своїм дизайном, таким чином, захищаючи ваш графік з'єднань краще, ніж будь-яка відома альтернатива.", + "privacy-matters-overlay-card-2-p-2": "Щоб бути об'єктивним та приймати незалежні рішення, вам потрібно контролювати ваш інформаційний простір. Це можливо лише за умови використання приватної мережі для спілкування, яка не має доступу до вашого соціального графа.", + "privacy-matters-overlay-card-2-p-3": "SimpleX - це перша мережа, яка не має жодних ідентифікаторів користувачів за своїм дизайном, таким чином, захищаючи ваш графік з'єднань краще, ніж будь-яка відома альтернатива.", "privacy-matters-overlay-card-3-p-1": "Кожен повинен турбуватися про конфіденційність та безпеку свого спілкування — безпечні розмови можуть поставити вас під загрозу, навіть якщо у вас немає чого приховувати.", "privacy-matters-overlay-card-3-p-2": "Однією з найшокуючих історій є досвід Мохамеду Ульд Слахі, описаний в його мемуарах і показаний у фільмі \"Мавританець\". Його посадили в табір Гуантанамо без суду і мукили там 15 років після телефонного дзвінка йому родичеві в Афганістані, під підозрою в причетності до атак 11 вересня, навіть не дивлячись на те, що він проживав у Німеччині протягом попередніх 10 років.", "privacy-matters-overlay-card-3-p-4": "Недостатньо використовувати зашифрований від кінця до кінця месенджер; ми всі повинні використовувати месенджери, які захищають конфіденційність наших особистих мереж — з ким ми з'єднані.", - "simplex-unique-overlay-card-1-p-1": "На відміну від інших платформ обміну повідомленнями, у SimpleX не присвоюються ідентифікатори користувачів. Він не покладається на номери телефонів, адреси на основі домену (наприклад, електронна пошта або XMPP), імена користувачів, публічні ключі або навіть випадкові номери для ідентифікації своїх користувачів — ми не знаємо, скільки людей використовує наші сервери SimpleX.", + "simplex-unique-overlay-card-1-p-1": "На відміну від інших мереж для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам. Він не покладається на номери телефонів, адреси на основі доменів (як електронна пошта або XMPP), імена користувачів, публічні ключі або навіть випадкові числа для ідентифікації своїх користувачів — оператори серверів SimpleX не знають, скільки людей використовують їхні сервери.", "simplex-unique-overlay-card-3-p-3": "На відміну від федеративних мереж серверів (електронна пошта, XMPP або Matrix), сервери SimpleX не зберігають облікові записи користувачів, вони лише ретранслюють повідомлення, захищаючи приватність обох сторін.", "simplex-unique-card-3-p-2": "Зашифровані повідомлення від кінця до кінця тимчасово зберігаються на ретрансляційних серверах SimpleX до їх отримання, після чого вони назавжди видаляються.", - "simplex-unique-card-4-p-1": "Мережа SimpleX є повністю децентралізованою та незалежною від будь-якої криптовалюти чи іншої платформи, крім Інтернету.", + "simplex-unique-card-4-p-1": "Мережа SimpleX повністю децентралізована та незалежна від будь-якої криптовалюти або будь-якої іншої мережі, окрім Інтернету.", "simplex-network-overlay-card-1-li-3": "P2P не вирішує проблему атаки MITM, і більшість існуючих реалізацій не використовують поза каналом повідомлень для початкового обміну ключами. SimpleX використовує поза каналом повідомлень або, у деяких випадках, передбачені і безпечні з'єднання для початкового обміну ключами.", - "simplex-network-overlay-card-1-li-4": "Реалізації P2P можуть бути блоковані деякими інтернет-провайдерами (наприклад, BitTorrent). SimpleX є транспортно-агностичним - він може працювати через стандартні веб-протоколи, наприклад, WebSockets.", + "simplex-network-overlay-card-1-li-4": "Реалізації P2P можуть бути заблоковані деякими інтернет-провайдерами (наприклад, BitTorrent). SimpleX є незалежним від транспорту — він може працювати через стандартні веб-протоколи, наприклад, WebSockets.", "simplex-unique-card-4-p-2": "Ви можете використовувати SimpleX з власними серверами або з серверами, які ми надаємо — і все одно підключатися до будь-якого користувача.", "contact-hero-p-1": "Публічні ключі та адреса черги повідомлень в цьому посиланні НЕ відправляються по мережі під час перегляду цієї сторінки — вони містяться в хеш-фрагменті URL-посилання.", "scan-qr-code-from-mobile-app": "Сканувати QR-код з мобільного додатка", @@ -46,7 +46,7 @@ "guide-dropdown-8": "Налаштування додатка", "guide-dropdown-9": "Створення підключень", "guide": "Посібник", - "docs-dropdown-1": "Платформа SimpleX", + "docs-dropdown-1": "SimpleX мережа", "docs-dropdown-2": "Доступ до файлів Android", "docs-dropdown-3": "Доступ до бази даних чату", "docs-dropdown-4": "Хостинг сервера SMP", @@ -71,7 +71,7 @@ "simplex-explained-tab-1-p-1": "Ви можете створювати контакти та групи і вести двосторонні розмови, як у будь-якому іншому месенджері.", "simplex-explained-tab-1-p-2": "Як це може працювати з однобічними чергами та без ідентифікаторів профілю користувача?", "simplex-explained-tab-2-p-1": "Для кожного з'єднання ви використовуєте дві окремі черги обміну повідомленнями для відправки та отримання повідомлень через різні сервери.", - "simplex-explained-tab-2-p-2": "Сервери передають повідомлення тільки в одному напрямку, не маючи повної картини розмови або підключень користувача.", + "simplex-explained-tab-2-p-2": "Сервери передають повідомлення тільки в одному напрямку, не маючи повної картини розмов або підключень користувача.", "simplex-explained-tab-3-p-1": "Сервери мають окремі анонімні облікові дані для кожної черги і не знають, які користувачі до них відносяться.", "simplex-explained-tab-3-p-2": "Користувачі можуть додатково підвищити конфіденційність метаданих, використовуючи Tor для доступу до серверів, що запобігає кореляції за IP-адресою.", "chat-bot-example": "Приклад чат-бота", @@ -116,7 +116,7 @@ "simplex-private-card-4-point-2": "Для використання SimpleX через Tor, будь ласка, встановіть додаток Orbot та активуйте SOCKS5-проксі (або VPN на iOS).", "simplex-private-card-5-point-1": "SimpleX використовує наповнення вмісту для кожного шару шифрування для ускладнення атак за розміром повідомлень.", "simplex-private-card-5-point-2": "Це забезпечує, що повідомлення різних розмірів виглядають однаково для серверів та спостерігачів мережі.", - "simplex-private-card-6-point-1": "Багато комунікаційних платформ вразливі до MITM-атак серверів чи постачальників мережі.", + "simplex-private-card-6-point-1": "Багато комунікаційних мереж вразливі до MITM-атак серверів чи постачальників мережі.", "simplex-private-card-6-point-2": "Щоб запобігти цьому, програми SimpleX передають одноразові ключі позаканально, коли ви ділитесь адресою як посиланням або QR-кодом.", "simplex-private-card-7-point-2": "Якщо будь-яке повідомлення додається, вилучається чи змінюється, отримувач буде проінформований.", "simplex-private-card-8-point-1": "Сервери SimpleX виступають як вузли низької затримки для змішування — вхідні та вихідні повідомлення мають різний порядок.", @@ -140,7 +140,7 @@ "simplex-unique-4-title": "Ви власник мережі SimpleX", "simplex-unique-4-overlay-1-title": "Повністю децентралізована — користувачі володіють мережею SimpleX", "hero-overlay-card-1-p-1": "Багато користувачів запитували: якщо у SimpleX немає ідентифікаторів користувачів, як він може знати, куди відправити повідомлення?", - "hero-overlay-card-1-p-2": "Для доставки повідомлень, замість ідентифікаторів користувачів, які використовують усі інші платформи, SimpleX використовує тимчасові анонімні парні ідентифікатори черг повідомлень, окремі для кожного вашого підключення — тут немає довгострокових ідентифікаторів.", + "hero-overlay-card-1-p-2": "Для доставки повідомлень, замість ідентифікаторів користувачів, які використовують усі інші мережі, SimpleX використовує тимчасові анонімні парні ідентифікатори черг повідомлень, окремі для кожного вашого підключення — тут немає довгострокових ідентифікаторів.", "hero-overlay-card-1-p-3": "Ви визначаєте, який(кі) сервер(и) використовувати для отримання повідомлень, ваші контакти — сервери, які ви використовуєте для відправки повідомлень їм. Кожна розмова, ймовірно, використовує два різних сервери.", "hero-overlay-card-1-p-4": "Цей дизайн запобігає витоку метаданих будь-яких користувачів на рівні додатка. Для подальшого покращення конфіденційності та захисту вашої IP-адреси ви можете підключитися до серверів обміну повідомленнями через Tor.", "hero-overlay-card-1-p-6": "Докладніше читайте у білетному запису SimpleX.", @@ -153,18 +153,18 @@ "privacy-matters-overlay-card-1-p-1": "Багато великих компаній використовують інформацію про те, з ким ви з'єднані, щоб оцінити ваш дохід, продавати вам продукти, які вам дійсно не потрібні, і визначати ціни.", "privacy-matters-overlay-card-3-p-3": "Звичайних людей арештовують за те, що вони публікують онлайн, навіть через свої 'анонімні' облікові записи, навіть у демократичних країнах.", "simplex-unique-overlay-card-1-p-2": "Для доставки повідомлень SimpleX використовує парні анонімні адреси однобічних черг повідомлень, окремо для отриманих та відправлених повідомлень, зазвичай через різні сервери.", - "simplex-unique-overlay-card-1-p-3": "Цей дизайн захищає конфіденційність осіб, з якими ви спілкуєтеся, приховуючи це від серверів платформи SimpleX та будь-яких спостерігачів. Щоб сховати свою IP-адресу від серверів, ви можете підключитися до серверів SimpleX через Tor.", - "simplex-unique-overlay-card-2-p-1": "Оскільки у вас немає ідентифікатора на платформі SimpleX, ніхто не може з вами зв'язатися, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, у вигляді QR-коду або посилання.", + "simplex-unique-overlay-card-1-p-3": "Цей дизайн захищає конфіденційність того, з ким ви спілкуєтесь, приховуючи це від серверів мережі SimpleX та від будь-яких спостерігачів. Щоб приховати вашу IP-адресу від серверів, ви можете підключатися до серверів SimpleX через Tor.", + "simplex-unique-overlay-card-2-p-1": "Оскільки у вас немає ідентифікатора в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", "simplex-unique-overlay-card-2-p-2": "Навіть з необов'язковою адресою користувача, яка може бути використана для відправки спамових запитань на зв'язок, ви можете змінити або повністю видалити її, не втрачаючи жодного з ваших з'єднань.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat зберігає всі дані користувачів лише на пристроях клієнтів за допомогою переносного зашифрованого формату бази даних, який можна експортувати і передавати на будь-який підтримуваний пристрій.", "simplex-unique-overlay-card-3-p-2": "Зашифровані повідомлення від кінця до кінця тимчасово зберігаються на ретрансляційних серверах SimpleX до їх отримання, після чого вони назавжди видаляються.", "simplex-unique-overlay-card-3-p-4": "Між надісланим і отриманим серверним трафіком немає спільних ідентифікаторів чи шифрованого тексту — якщо хтось його спостерігає, він не може легко визначити, хто спілкується з ким, навіть якщо TLS скомпрометовано.", - "simplex-unique-overlay-card-4-p-1": "Ви можете використовувати SimpleX з власними серверами і все одно спілкуватися з людьми, які використовують надані нам сервери заздалегідь налаштовані.", - "simplex-unique-overlay-card-4-p-2": "Платформа SimpleX використовує відкритий протокол та надає SDK для створення чат-ботів, що дозволяє впроваджувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми дійсно чекаємо, які сервіси SimpleX ви зможете побудувати.", - "simplex-unique-overlay-card-4-p-3": "Якщо ви розглядаєте можливість розробки для платформи SimpleX, наприклад, чат-бота для користувачів додатку SimpleX або інтеграції бібліотеки SimpleX Chat у свої мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", - "simplex-unique-card-1-p-1": "SimpleX захищає конфіденційність вашого профілю, контактів та метаданих, приховуючи їх від серверів платформи SimpleX та будь-яких спостерігачів.", - "simplex-unique-card-1-p-2": "На відміну від будь-якої іншої існуючої платформи обміну повідомленнями, SimpleX не має ідентифікаторів, призначених користувачам — навіть випадкових чисел.", - "simplex-unique-card-2-p-1": "Оскільки у вас немає ідентифікатора або фіксованої адреси на платформі SimpleX, ніхто не може з вами зв'язатися, якщо ви не поділитесь одноразовою або тимчасовою адресою користувача, як QR-код або посиланням.", + "simplex-unique-overlay-card-4-p-1": "Ви можете використовувати SimpleX зі своїми власними серверами і при цьому спілкуватися з людьми, які використовують сервери, попередньо налаштовані в додатках.", + "simplex-unique-overlay-card-4-p-2": "Мережа SimpleX використовує відкритий протокол і надає SDK для створення чат-ботів, що дозволяє реалізувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми справді чекаємо, щоб побачити, які сервіси SimpleX ви створите.", + "simplex-unique-overlay-card-4-p-3": "Якщо ви плануєте розробляти для мережі SimpleX, наприклад, чат-бота для користувачів додатка SimpleX або інтеграцію бібліотеки SimpleX Chat у ваші мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", + "simplex-unique-card-1-p-1": "SimpleX захищає конфіденційність вашого профілю, контактів та метаданих, приховуючи їх від серверів мережі SimpleX та будь-яких спостерігачів.", + "simplex-unique-card-1-p-2": "На відміну від будь-якої іншої існуючої мережі для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам — не навіть випадкових чисел.", + "simplex-unique-card-2-p-1": "Оскільки у вас немає ідентифікатора або фіксованої адреси в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", "simplex-unique-card-3-p-1": "SimpleX зберігає всі дані користувачів на пристроях клієнтів у переносному зашифрованому форматі бази даних — його можна передавати на інший пристрій.", "join": "Приєднатися", "we-invite-you-to-join-the-conversation": "Ми запрошуємо вас приєднатися до розмови", @@ -191,7 +191,7 @@ "installing-simplex-chat-to-terminal": "Встановлення SimpleX Chat для терміналу", "use-this-command": "Використовуйте цю команду:", "see-simplex-chat": "Дивіться SimpleX Chat", - "the-instructions--source-code": "інструкції з того, як його завантажити чи скомпілювати з вихідного коду.", + "the-instructions--source-code": "для інструкції з того, як його завантажити чи скомпілювати з вихідного коду.", "if-you-already-installed-simplex-chat-for-the-terminal": "Якщо ви вже встановили SimpleX Chat для терміналу", "if-you-already-installed": "Якщо ви вже встановили", "simplex-chat-for-the-terminal": "SimpleX Chat для терміналу", @@ -227,7 +227,7 @@ "no-decentralized": "Ні - децентралізовано", "comparison-section-list-point-4": "Якщо сервери оператора порушені. Перевірте безпековий код в Signal та деяких інших додатках для зменшення ризику", "comparison-section-list-point-5": "Не захищає конфіденційність метаданих користувачів", - "comparison-section-list-point-6": "Хоча P2P є розподіленими, вони не є федеративними - вони працюють як єдина мережа", + "comparison-section-list-point-6": "Хоча P2P є розподіленими, вони не є федеративними — вони працюють як одна мережа", "guide-dropdown-1": "Швидкий старт", "guide-dropdown-2": "Відправлення повідомлень", "guide-dropdown-3": "Таємні групи", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index aa51a7cff5..c0b843ca9e 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -49,7 +49,7 @@ "simplex-explained": "SimpleX 简述", "simplex-explained-tab-3-text": "3. 服务器能看到什么", "hero-header": "重新定义隐私", - "simplex-explained-tab-1-p-2": "它是如何在单向消息队列与没有用户识别符的情况下工作的?", + "simplex-explained-tab-1-p-2": "它是如何利用单向消息队列并不利用用户识别符工作的?", "simplex-explained-tab-2-p-2": "服务器只会单向传输消息,而无法掌握用户的对话或连接的全貌。", "simplex-explained-tab-3-p-1": "服务器对每个队列都有单独的匿名凭证,并且不知道这些凭证属于哪些用户。", "donate": "捐赠", diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index d95c6259c7..324360cb19 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -1,13 +1,13 @@ { - "home": "家", + "home": "开始", "developers": "開發人員", "reference": "參考", - "blog": "博客", + "blog": "部落格", "features": "特徵", "why-simplex": "為什麼選擇SimpleX", "simplex-privacy": "SimpleX 隱私", "simplex-network": "SimpleX 網路", - "simplex-explained": "Simplex 解釋", + "simplex-explained": "SimpleX 解釋", "simplex-explained-tab-1-text": "1. 用戶體驗", "simplex-explained-tab-2-text": "2. 它是如何工作的", "simplex-explained-tab-3-text": "3. 伺服器可以看到什麼", @@ -16,15 +16,191 @@ "hero-subheader": "第一個沒有 User ID 的 Messenger", "simplex-explained-tab-3-p-2": "用戶可以通過使用 Tor 訪問伺服器來進一步提高元數據隱私,防止按 IP 位址進行序列化。", "smp-protocol": "SMP 協定", - "simplex-explained-tab-2-p-2": "伺服器僅以一種方式傳遞消息,而無法全面瞭解使用者的對話或連接。", + "simplex-explained-tab-2-p-2": "伺服器僅單向傳遞消息,無法全面瞭解使用者的對話記錄或連接。", "simplex-explained-tab-2-p-1": "對於每個連接,您可以使用兩個單獨的消息佇列通過不同的伺服器發送和接收消息。", "chat-protocol": "聊天協定", - "copyright-label": "© 2020-2024 單工 |開源專案", - "donate": "捐", + "copyright-label": "© 2020-2025 SimpleX |開源專案", + "donate": "捐助", "simplex-explained-tab-1-p-1": "您可以創建聯繫人和群組,並進行雙向對話,就像在任何其他 Messenger 中一樣。", "simplex-explained-tab-1-p-2": "它如何在沒有使用者配置檔標識符的情況下使用單向佇列?", "simplex-explained-tab-3-p-1": "伺服器對每個佇列都有單獨的匿名憑證,並且不知道它們屬於哪些使用者。", "chat-bot-example": "聊天機器人示例", "simplex-chat-protocol": "SimpleX Chat 協定", - "terms-and-privacy-policy": "隱私策略" + "terms-and-privacy-policy": "隱私策略", + "hero-overlay-card-3-p-3": "Trail of Bits 在 2024 年 7 月檢閱了 SimpleX 網路通訊協定的加密設計。閱讀更多。", + "hero-overlay-3-textlink": "安全評估", + "hero-overlay-card-3-p-2": "Trail of Bits 在 2022 年 11 月檢閱了 SimpleX 網路加密和網路元件。閱讀更多。", + "hero-overlay-1-textlink": "用戶ID為何有害私隱?", + "hero-overlay-2-textlink": "SimpleX如何工作?", + "simplex-private-card-5-point-2": "它讓不同長度的訊息在伺服器和網路觀察者看來相同。", + "simplex-private-card-10-point-1": "對用戶與群組成員,SimpleX使用臨時、匿名的地址對與憑證對。", + "privacy-matters-1-title": "廣告投放與價格歧視", + "privacy-matters-1-overlay-1-linkText": "私隱為您省錢", + "privacy-matters-2-title": "操縱選舉", + "privacy-matters-2-overlay-1-title": "私隱給您權力", + "privacy-matters-2-overlay-1-linkText": "私隱給您權力", + "privacy-matters-3-overlay-1-title": "私隱保護您的自由", + "privacy-matters-3-overlay-1-linkText": "私隱保護您的自由", + "simplex-unique-4-title": "您擁有SimpleX網路", + "simplex-unique-4-overlay-1-title": "完全去中心化—用戶擁有 SimpleX 網路", + "hero-overlay-card-1-p-4": "此設計可防止在應用程式層級洩漏任何使用者' 元資料。為了進一步改善隱私並保護您的 IP 位址,您可以透過 Tor 連接到訊息伺服器。", + "hero-overlay-card-1-p-5": "只有用戶端會儲存使用者設定檔、聯絡人和群組;訊息傳送採用雙層端對端加密。", + "hero-overlay-card-2-p-2": "然後他們可以將這些資訊與現有的公開社交網路進行關聯,並確定一些真實身份。", + "simplex-unique-card-1-p-1": "SimpleX 保護您的個人資料、聯絡人和元資料的隱私,使其對 SimpleX 網路伺服器和任何觀察者隱藏。", + "scan-qr-code-from-mobile-app": "用手機應用程式掃描二維碼", + "scan-the-qr-code-with-the-simplex-chat-app": "使用SimpleX Chat應用程式掃描二維碼", + "protocol-3-text": "點對點協議", + "hero-p-1": "其他聊天應用,如Signal、Matrix、Session、Briar、Jami、Cwtch等,使用用戶ID;SimpleX不這樣做。它甚至不使用隨機數。這種做法極大地保護用戶私隱。", + "hero-2-header-desc": "此視訊教您以一次性QR碼、親身或以視訊連結的方式與聯繫人連線。您也可以通過分享邀請連結與人連線。", + "feature-2-title": "端到端加密的圖像、視訊、檔案", + "simplex-private-card-9-point-2": "與傳統的訊息代理相比,它可減小攻擊媒介及可見的詮釋資料量。", + "simplex-private-10-title": "臨時匿名標識符對", + "simplex-private-card-1-point-1": "雙棘輪協定—帶有前向保密、入侵恢復特質的不留記錄即時通訊協定。", + "privacy-matters-3-title": "因無辜關聯而被起訴", + "signing-key-fingerprint": "簽名密鑰指紋(SHA-256)", + "hero-overlay-card-1-p-3": "您決定用哪個/哪些伺服器接收訊息。您的聯絡人用此伺服器向您傳送訊息。往往每個對話都使用兩台不同的伺服器。", + "docs-dropdown-2": "訪問Android檔案", + "simplex-unique-card-2-p-1": "由於您在 SimpleX 網路上沒有任何標識符或固定地址,因此除非您分享一次性或臨時地址 (如 二維碼或連結),任何人都無法與您聯絡。", + "see-here": "見此", + "privacy-matters-overlay-card-3-p-4": "僅使用端對端加密的通訊工具是不夠的,我們都應使用能保護個人網路隱私的通訊工具。", + "hero-overlay-card-1-p-1": "很多用戶問: 如果 SimpleX 沒有用戶標識符,它何以知道向何處傳送訊息?", + "simplex-unique-overlay-card-4-p-2": "SimpleX網路使用開源協議並提供開發包以編寫聊天機器人,允許使用者透過SimpleX Chat應用程式與服務互動—我們非常期待看到您的SimpleX服務之作!", + "hero-overlay-card-1-p-2": "為傳送訊息,SimpleX 使用消息佇列中的臨時匿名標識符對,每連線各異,而不像其他網路一樣使用用戶ID— SimpleX 沒有長期標識符。", + "privacy-matters-overlay-card-3-p-2": "最令人震驚的故事之一是 Mohamedou Ould Salahi 的經歷,描述於他的回憶錄中,並在《The Mauritanian movie》中呈現。他在未經審訊的情況下被送進關塔納摩集中營,並在那裏遭受了長達 15 年的酷刑,原因是他被懷疑參與了 9/11 襲擊事件,而此前 10 年他一直住在德國。", + "simplex-private-2-title": "伺服器附加加密層", + "hero-overlay-card-2-p-1": "如使用者擁有持久性身份,即使只是一個隨機數,例如 Session ID,也會有服務提供者或攻擊者可以觀察使用者如何連線以及傳送了多少條訊息的風險。", + "simplex-unique-overlay-card-4-p-3": "如您正考慮針對 SimpleX 網路進行開發,例如針對 SimpleX 使用者的聊天機器人,或將 SimpleX 聊天函式庫整合至您的手機應用程式,請與我們聯絡以獲取支持與建議。", + "simplex-network-overlay-card-1-li-1": "點對點網路依賴 DHT 的某些變體來路由訊息。DHT 設計必須平衡傳送保證和延遲。與點對點相比,SimpleX 具有更好的傳送保證和更低的延遲,因為訊息可以使用收件者選擇的伺服器,經由多個伺服器並行冗餘地傳送。在點對點網路中,訊息依次經由O(log N)個由演算法選擇的節點。", + "simplex-private-card-3-point-3": "連線恢復被停用,以防止會話攻擊。", + "simplex-private-card-2-point-1": "傳送至收件者的伺服器加密附加層,以防止 TLS 遭到攻擊時,接收和傳送的伺服器流量之間的關聯。", + "simplex-private-card-5-point-1": "SimpleX 為每個加密層使用內容填充,以挫敗通過監控訊息長度的攻擊。", + "comparison-section-list-point-4a": "SimpleX中繼不可能威脅端到端加密。由其他方式驗證安全碼以杜絕攻擊之可能", + "docs-dropdown-6": "WebRTC伺服器", + "feature-3-title": "端到端加密的、去中心化的群組—存在只有用戶自己知道", + "feature-7-title": "可攜、加密存儲—將設定檔移至另一設備", + "back-to-top": "回到頂部", + "click-to-see": "點擊查看", + "menu": "功能表", + "on-this-page": "此頁中", + "docs-dropdown-14": "在商業中使用SimpleX", + "glossary": "詞彙表", + "simplex-chat-repo": "SimpleX Chat儲存庫", + "newer-version-of-eng-msg": "本頁面有更新的英文版本。", + "f-droid-page-simplex-chat-repo-section-text": "掃描二維碼或使用此URL以添加SimpleX儲存庫至您的F-Droid客戶端:", + "jobs": "加入團隊", + "please-use-link-in-mobile-app": "請使用手機應用程式中的連結", + "stable-versions-built-by-f-droid-org": "由F-Droid.org編譯的穩定版", + "please-enable-javascript": "啟用JavaScript以查看二維碼。", + "docs-dropdown-8": "SimpleX通訊錄服務", + "hero-2-header": "創建私密連線", + "hero-overlay-1-title": "SimpleX如何工作?", + "hero-overlay-2-title": "用戶ID為何有害私隱?", + "hero-overlay-3-title": "安全評估", + "feature-1-title": "支持Markdown與編輯的、端到端加密的訊息", + "feature-4-title": "端到端加密的語音訊息", + "feature-5-title": "自刪除訊息", + "feature-6-title": "端到端加密的語音、視訊通話", + "simplex-network-overlay-1-title": "與點對點訊息傳輸協定的比較", + "simplex-private-1-title": "雙層端到端加密", + "simplex-private-3-title": "經安全鑑權的TLS 傳送", + "simplex-private-4-title": "可選的經由Tor訪問", + "simplex-private-5-title": "多層內容填充", + "feature-8-title": "隱身模式—SimpleX獨有", + "simplex-private-6-title": "頻帶外密鑰交換", + "simplex-private-7-title": "訊息完整性驗證", + "simplex-private-8-title": "訊息混雜以降低關聯性", + "simplex-private-card-1-point-2": "如TLS安全受威脅,每個隊列中的NaCL cryptobox可防止關聯訊息隊列間的通訊。", + "simplex-private-9-title": "單向訊息隊列", + "simplex-private-card-10-point-2": "SimpleX使不用用戶設定檔傳輸訊息成為可能,與其他軟體相比提供更強的詮釋資料私隱性。", + "privacy-matters-1-overlay-1-title": "私隱為您省錢", + "simplex-unique-1-title": "您擁有完整的私隱權", + "simplex-unique-1-overlay-1-title": "您的身份、個人資料、聯絡人與詮釋資料完全隱密", + "simplex-unique-2-title": "SimpleX保護您免受垃圾訊息、濫用之害", + "simplex-private-card-4-point-1": "為保護您的 IP 位址,您可透過 Tor 或其他傳輸覆蓋網路訪問伺服器。", + "simplex-unique-2-overlay-1-title": "防止垃圾郵件和濫用的最佳保護", + "simplex-unique-3-title": "您掌控您的數據", + "simplex-unique-3-overlay-1-title": "您的數據的擁有權、控制與安全", + "hero-overlay-card-2-p-4": "SimpleX 通過在設計中不使用任何使用者 ID以防止這些攻擊。而且,如果您使用匿名聊天模式,每個聯絡人都會有不同的顯示名稱,避免他們之間有任何共用資料。", + "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服務的最隱私的應用程式,如果您透過相同的設定檔與兩個不同的人交談,他們也可以證明他們連線的是同一個人。", + "hero-overlay-card-1-p-6": "請參閱 SimpleX 白皮書以閱讀更多資訊。", + "hero-overlay-card-3-p-1": "Trail of Bits 是一家領先的安全和技術顧問公司,客戶包括大型科技公司、政府機構和主要的區塊鏈專案。", + "simplex-network-overlay-card-1-p-1": "點對點通訊協定和應用程式有多種問題,使得它們不如 SimpleX 可靠、分析起來更複雜,而且易受幾種類型的攻擊。", + "simplex-network-overlay-card-1-li-2": "SimpleX 的設計與大多數點對點網路不同,沒有任何類型的全局使用者標識符,即使是臨時標識符。SimpleX 只使用臨時的標識符對,提供更好的匿名性和元資料保護。", + "privacy-matters-overlay-card-1-p-1": "許多大公司利用與您有聯繫的人的資訊來估計您的收入,向您銷售您並不真正需要的產品,以及定價。", + "privacy-matters-overlay-card-1-p-2": "線上零售商知道收入較低的人更有可能進行緊急購物,因此他們可能收取較高的價格或取消折扣。", + "enter-your-email-address": "輸入您的電郵地址", + "get-simplex": "下載SimpleX 桌面版應用程式", + "join-us-on-GitHub": "在GitHub上加入我們", + "donate-here-to-help-us": "於此捐助以幫助我們", + "sign-up-to-receive-our-updates": "登記以收取更新", + "join": "加入", + "simplex-unique-card-3-p-1": "SimpleX 以 可攜式加密數據庫格式 儲存用戶端裝置上的所有使用者資料;這些資料可傳輸至其他設備。", + "simplex-unique-card-3-p-2": "端對端加密的訊息暫時保留在 SimpleX 中繼伺服器上,收到訊息後會被永久刪除。", + "simplex-unique-card-4-p-2": "您可以使用您自己的伺服器運行SimpleX,或使用我們提供的伺服器並仍連線到任何使用者。", + "simplex-unique-overlay-card-3-p-4": "傳送與接收的伺服器流量之間沒有共同的標識符或密文—如果有人觀察,即使 TLS 遭到破壞,也無法輕易確定誰與誰通訊。", + "join-the-REDDIT-community": "加入Reddit社群", + "privacy-matters-overlay-card-3-p-1": "人人都應關心自己通訊的隱私和安全;無害的對話可能會讓您陷入危險,即使您沒有什麼要隱瞞的。", + "privacy-matters-overlay-card-3-p-3": "普通人會因為他們在網路上分享的內容而被捕,即使是透過他們的「匿名」帳號,即使是在民主國家。", + "privacy-matters-overlay-card-2-p-1": "不久前我們觀察到大選被一家聲譽良好的顧問公司操縱,該公司利用我們的社交圖來扭曲我們對真實世界的看法,並操縱我們的選票。", + "comparison-section-list-point-2": "基於DNS的位址", + "comparison-section-list-point-1": "通常基於電話號碼,有時用戶名", + "simplex-private-card-6-point-1": "許多通訊網路容易受到伺服器或網路供應商的中間人攻擊。", + "privacy-matters-overlay-card-1-p-4": "SimpleX 網路比任何其他方式都能更好地保護您的連線隱私,完全防止您的社交圖表被任何公司或組織獲得。即使使用 SimpleX Chat 應用程式中預設的伺服器,伺服器操作員也不會知道使用者的數量或他們的連線。", + "privacy-matters-overlay-card-2-p-2": "要做到客觀和獨立決策,您需要控制自己的資訊空間。只有當使用無法存取您的社交圖表的私人通訊網路時,您才可能做到這一點。", + "simplex-unique-overlay-card-1-p-1": "與其他通訊網路不同,SimpleX 不為使用者指定標識符。它不依賴電話號碼、網域地址 (如電子郵件或 XMPP)、用戶名、公鑰或甚至隨機數來識別使用者;SimpleX 伺服器操作員不知道有多少人使用他們的伺服器。", + "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一個在設計上不使用任何使用者標識符的網路,因此比任何已知的其他方案都能更好地保護您的社交圖。", + "simplex-unique-overlay-card-4-p-1": "您可以使用您自己的伺服器運行SimpleX,並仍與使用應用程式中預設伺服器的人進行通訊。", + "simplex-unique-card-1-p-2": "與任何其他現有的訊息網路不同,SimpleX 不指定使用者的標識符—甚至沒有隨機數。", + "why-simplex-is-unique": "SimpleX為何與眾不同", + "learn-more": "查看更多", + "more-info": "更多訊息", + "hide-info": "隱藏訊息", + "contact-hero-header": "您收到了SimpleX聯絡地址", + "simplex-unique-card-4-p-1": "SimpleX 網路是完全去中心化的,獨立於任何加密貨幣或任何其他網路(除Internet)。", + "invitation-hero-header": "您收到了SimpleX一次性連結", + "contact-hero-subheader": "使用手提電話或平板電腦中的SimpleX應用程式掃描二維碼。", + "connect-in-app": "在應用中連結", + "contact-hero-p-2": "尚未下載SimpleX?", + "contact-hero-p-1": "當您檢視此頁面時,此連結中的公鑰和訊息佇列位址不透過網路傳送;它們包含在連結 URL 的雜湊片段中。", + "contact-hero-p-3": "使用以下連結下載應用程式。", + "to-make-a-connection": "建立連結:", + "install-simplex-app": "安裝 SimpleX 應用程式", + "use-this-command": "使用此命令:", + "scan-the-qr-code-with-the-simplex-chat-app-description": "當您檢視此頁面時,此連結中的公鑰和訊息佇列位址不會透過網路傳送 —它們包含在連結 URL 的雜湊片段中。", + "installing-simplex-chat-to-terminal": "安裝SimpleX至終端", + "open-simplex-app": "開啟SimpleX應用程式", + "if-you-already-installed": "如已安裝", + "simplex-chat-for-the-terminal": "用於終端的 SimpleX Chat", + "if-you-already-installed-simplex-chat-for-the-terminal": "如您已經安裝了用於終端的 SimpleX Chat", + "tap-to-close": "輕觸以關閉", + "simplex-network-1-desc": "所有訊息都經由伺服器傳送,既能提供更好的元資料隱私和可靠的異步訊息傳送,又能避免許多", + "simplex-network-section-desc": "SinpleX Chat 結合點對點和互聯網路的優點,提供最佳的隱私性。", + "copy-the-command-below-text": "複製下面的命令並用於聊天:", + "privacy-matters-section-header": "隱私為何 重要", + "privacy-matters-section-label": "確保您的通訊應用程式無法竊取您的資料!", + "simplex-network-3-header": "SimpleX網路", + "simplex-network-1-overlay-linktext": "點對點網路的弊病", + "simplex-network-2-desc": "SimpleX 中繼伺服器不儲存使用者個人資料、聯絡人和傳送的訊息,也不連線彼此,也沒有伺服器目錄。", + "simplex-network-2-header": "與互聯網路不同", + "comparison-section-header": "與其他協議之比較", + "protocol-2-text": "XMPP、Matrix", + "comparison-point-1-text": "需要全局身份", + "simplex-network-3-desc": "伺服器提供單向佇列以連接使用者,但伺服器無法看到網路連線圖 —只有使用者能看到。", + "protocol-1-text": "Signal,大平台", + "comparison-point-2-text": "中間人攻擊之可能", + "comparison-point-3-text": "對DNS的依賴", + "comparison-point-4-text": "單一或集中式網路", + "comparison-section-list-point-3": "公鑰或其他某種全局獨一的標識符", + "comparison-section-list-point-5": "不保護使用者的元數據隱私", + "comparison-section-list-point-7": "點對點網路必須有中央權威,否則整個網路都可能受到攻擊", + "comparison-section-list-point-4": "如果運營商的伺服器受到攻擊,用其他應用程式(如 Signal)驗證 SimpleX 安全碼,以減輕風險", + "guide-dropdown-8": "應用程式設定", + "guide-dropdown-6": "音視訊通話", + "docs-dropdown-1": "SimpleX 網路", + "guide": "指南", + "docs-dropdown-9": "下載", + "docs-dropdown-11": "常見問題", + "docs-dropdown-12": "安全性", + "docs-dropdown-7": "翻譯SimpleX", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和F-Droid.org 儲存庫使用不同的金鑰為安裝包簽名。若要切換,請匯出聊天資料庫並重新安裝應用程式。" } From 348961576ba25cdf1d8db47c0e60ab4f0e26688e Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 May 2025 16:36:18 +0100 Subject: [PATCH 238/289] website: translations (#5896) 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: summoner001 --- website/langs/hu.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/langs/hu.json b/website/langs/hu.json index 8acd7d9a60..9dfd3d9f65 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -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": "A SimpleX mitől egyedülálló", + "why-simplex-is-unique": "Mitől egyedülálló a SimpleX", "learn-more": "Tudjon meg többet", "more-info": "További információ", "hide-info": "Információ elrejtése", From 1f8609a31f91cf74a67efb73ee7c05836722de3f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 12 May 2025 15:57:20 +0000 Subject: [PATCH 239/289] core: make member admission forwards compatible (#5893) * core: make member admission forwards compatible * cabal file * schema * plans * inserts * plans --- simplex-chat.cabal | 2 ++ src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 2 +- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 36 +++++++++---------- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 ++- .../Migrations/M20250402_short_links.hs | 2 ++ .../Migrations/M20250512_member_admission.hs | 21 +++++++++++ src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 ++- .../Migrations/M20250512_member_admission.hs | 18 ++++++++++ .../SQLite/Migrations/chat_query_plans.txt | 16 ++++----- .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/Shared.hs | 8 ++--- src/Simplex/Chat/Types.hs | 28 +++++++++++++-- src/Simplex/Chat/View.hs | 9 +++-- tests/ProtocolTests.hs | 2 +- 16 files changed, 117 insertions(+), 42 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20250512_member_admission.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250512_member_admission.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 52e40d6c6f..0ae425be48 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.M20250512_member_admission else exposed-modules: Simplex.Chat.Archive @@ -234,6 +235,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links + Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f21c9526a5..d3b945af4f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4290,7 +4290,7 @@ 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} 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 8158df5c94..bbefbcfde0 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -972,7 +972,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 0a4f5392c0..5c177969b9 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -136,7 +136,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, -- 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, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 0c49338a2e..fc23c9ef44 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -277,7 +277,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, -- 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, @@ -318,7 +318,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 @@ -326,8 +326,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 @@ -387,7 +387,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 @@ -395,8 +395,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 @@ -554,13 +554,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 @@ -763,7 +763,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, 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, @@ -1544,7 +1544,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, -- 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, @@ -1601,7 +1601,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 @@ -1619,14 +1619,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 @@ -1664,14 +1664,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/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index dc7202edc8..c392c17db1 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.M20250512_member_admission 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), + ("20250512_member_admission", m20250512_member_admission, Just down_m20250512_member_admission) ] -- | 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/M20250512_member_admission.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250512_member_admission.hs new file mode 100644 index 0000000000..eb0d73a523 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250512_member_admission.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250512_member_admission where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250512_member_admission :: Text +m20250512_member_admission = + T.pack + [r| +ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; +|] + +down_m20250512_member_admission :: Text +down_m20250512_member_admission = + T.pack + [r| +ALTER TABLE group_profiles DROP COLUMN member_admission; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 81253c5b87..183d699f01 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -129,6 +129,7 @@ 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.M20250402_short_links +import Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -257,7 +258,8 @@ schemaMigrations = ("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), - ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links) + ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links), + ("20250512_member_admission", m20250512_member_admission, Just down_m20250512_member_admission) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250512_member_admission.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250512_member_admission.hs new file mode 100644 index 0000000000..e1f45beef1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250512_member_admission.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250512_member_admission :: Query +m20250512_member_admission = + [sql| +ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; +|] + +down_m20250512_member_admission :: Query +down_m20250512_member_admission = + [sql| +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 e3eff0f6f1..88c6c33b41 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, -- 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, @@ -544,7 +544,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 = ? @@ -805,7 +805,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, -- 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, @@ -850,7 +850,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, 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, @@ -1235,7 +1235,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 @@ -4458,7 +4458,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, -- 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, @@ -4480,7 +4480,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, -- 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, @@ -5387,7 +5387,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/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 33b800f4ef..6fbed97d27 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 c681180759..b32fd07bb5 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -579,16 +579,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) :. 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 ConnLinkContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) 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, 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} + 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} @@ -612,7 +612,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, -- 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, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9d875f5bf4..0063f711c7 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) @@ -616,10 +616,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) @@ -1816,6 +1830,16 @@ $(JQ.deriveJSON defaultJSON ''LocalProfile) $(JQ.deriveJSON defaultJSON ''UserContactRequest) +$(JQ.deriveJSON (enumJSON $ dropPrefix "MC") {J.tagSingleConstructors = True} ''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/View.hs b/src/Simplex/Chat/View.hs index c07fcc952d..42d1132961 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1680,10 +1680,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 @@ -1704,6 +1704,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/ProtocolTests.hs b/tests/ProtocolTests.hs index 50d2c1eef0..1d37a52459 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -107,7 +107,7 @@ 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 9098e22d4be39b4b7882379c93acb0946a174502 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 12 May 2025 16:58:49 +0100 Subject: [PATCH 240/289] core: 6.3.4.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 0ae425be48..a9dca273e3 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.0 +version: 6.3.4.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 021f048d6b..d41957bbac 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, 0] +minRemoteCtrlVersion = AppVersion [6, 3, 4, 1] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 4, 0] +minRemoteHostVersion = AppVersion [6, 3, 4, 1] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version 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 241/289] 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 9b4908c3702740ee36ea49fba30f41dbd402af15 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 12 May 2025 19:34:06 +0100 Subject: [PATCH 242/289] 6.3.4: ios 277, android 287, desktop 100 --- 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 8a035c70a9..5ebc7f9b4b 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.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.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 */; }; 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.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.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 = ""; }; 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.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.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 */, 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.0-47rHZ52LFetD6j9vq8gwHM-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.0-47rHZ52LFetD6j9vq8gwHM.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.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 = 276; + CURRENT_PROJECT_VERSION = 277; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2021,7 +2021,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 276; + CURRENT_PROJECT_VERSION = 277; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2063,7 +2063,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 276; + CURRENT_PROJECT_VERSION = 277; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2083,7 +2083,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 276; + CURRENT_PROJECT_VERSION = 277; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2108,7 +2108,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 276; + CURRENT_PROJECT_VERSION = 277; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2145,7 +2145,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 276; + CURRENT_PROJECT_VERSION = 277; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2182,7 +2182,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 276; + CURRENT_PROJECT_VERSION = 277; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2233,7 +2233,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 276; + CURRENT_PROJECT_VERSION = 277; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -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 = 276; + CURRENT_PROJECT_VERSION = 277; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -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 = 276; + CURRENT_PROJECT_VERSION = 277; 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 2dc5be4210..9258529ecb 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.3 -android.version_code=285 +android.version_name=6.3.4 +android.version_code=287 -desktop.version_name=6.3.3 -desktop.version_code=99 +desktop.version_name=6.3.4 +desktop.version_code=100 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 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 243/289] 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 244/289] 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 245/289] 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 dc35e5f765acd4614f670ddb207c231af37b52f5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 13 May 2025 18:19:22 +0100 Subject: [PATCH 246/289] android, desktop: fix sending reports --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7025e4caf3..979d79c839 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 @@ -964,7 +964,7 @@ object ChatController { suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List? { val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText)) - if (r is API.Result && r.res is CR.NewChatItems) r.res.chatItems + if (r is API.Result && r.res is CR.NewChatItems) return r.res.chatItems apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) return null } From 8af3cb935eeaae2f644ddc9e61ddb314d464eea4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 13 May 2025 18:53:37 +0100 Subject: [PATCH 247/289] 6.3.4: android 288, desktop 101 --- apps/multiplatform/gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 9258529ecb..18add58bcf 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,10 +25,10 @@ kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 android.version_name=6.3.4 -android.version_code=287 +android.version_code=288 desktop.version_name=6.3.4 -desktop.version_code=100 +desktop.version_code=101 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 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 248/289] 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 249/289] 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 a36a6d44db8d8b91ba9ff5a43e00a13578056147 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 14 May 2025 09:55:03 +0000 Subject: [PATCH 250/289] flatpak: update metainfo (#5899) * flatpak: update metainfo * flatpak: rewrite metainfo --- .../flatpak/chat.simplex.simplex.metainfo.xml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 82987e211a..b15e382207 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,32 @@ + + https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +

New in v6.3.1-4:

+
    +
  • fixes mentions with trailing punctuation (e.g., hello @name!).
  • +
  • recognizes domain names as links (e.g., simplex.chat).
  • +
  • forward compatibility with "knocking" (a feature for group admins to review and to chat with the new members prior to admitting them to groups, it will be released in 6.4)
  • +
  • support for connecting via short connection links.
  • +
  • fix related to backward/forward compatibility of the app in some rare cases.
  • +
  • scrolling/navigation improvements.
  • +
  • faster onboarding (conditions and operators are combined to one screen).
  • +
+

New in v6.3.0:

+
    +
  • Mention members and get notified when mentioned.
  • +
  • Send private reports to moderators.
  • +
  • Delete, block and change role for multiple members at once
  • +
  • Faster sending messages and faster deletion.
  • +
  • Organize chats into lists to keep track of what's important.
  • +
  • Jump to found and forwarded messages.
  • +
  • Private media file names.
  • +
  • Message expiration in chats.
  • +
+
+
https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html 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 251/289] 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 252/289] 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 253/289] 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 5dd89fe12760f531bd8e51b06cb4cbaeb76ae960 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 15 May 2025 14:25:46 +0100 Subject: [PATCH 254/289] ios: fix swipe on iOS 15, fix onboarding layout on iOS 15 and small screens (#5913) * ios: fix onboarding layout issues on iOS 15 and small screens * fix swipe on iOS 15 --- .../Views/ChatList/ChatListNavLink.swift | 36 ++++++++++++++----- .../Shared/Views/ChatList/ChatListView.swift | 8 +---- .../Onboarding/ChooseServerOperators.swift | 10 ++++-- .../Views/Onboarding/CreateProfile.swift | 35 ++++++++++-------- .../Onboarding/SetNotificationsMode.swift | 8 ++++- .../Shared/Views/Onboarding/SimpleXInfo.swift | 16 ++++++--- .../ar.xcloc/Localized Contents/ar.xliff | 4 +-- .../bg.xcloc/Localized Contents/bg.xliff | 15 +++----- .../bn.xcloc/Localized Contents/bn.xliff | 4 +-- .../cs.xcloc/Localized Contents/cs.xliff | 16 ++++----- .../de.xcloc/Localized Contents/de.xliff | 15 +++----- .../el.xcloc/Localized Contents/el.xliff | 4 +-- .../en.xcloc/Localized Contents/en.xliff | 15 +++----- .../es.xcloc/Localized Contents/es.xliff | 15 +++----- .../fi.xcloc/Localized Contents/fi.xliff | 16 ++++----- .../fr.xcloc/Localized Contents/fr.xliff | 15 +++----- .../he.xcloc/Localized Contents/he.xliff | 4 +-- .../hr.xcloc/Localized Contents/hr.xliff | 4 +-- .../hu.xcloc/Localized Contents/hu.xliff | 15 +++----- .../it.xcloc/Localized Contents/it.xliff | 15 +++----- .../ja.xcloc/Localized Contents/ja.xliff | 16 ++++----- .../ko.xcloc/Localized Contents/ko.xliff | 4 +-- .../lt.xcloc/Localized Contents/lt.xliff | 4 +-- .../nl.xcloc/Localized Contents/nl.xliff | 15 +++----- .../pl.xcloc/Localized Contents/pl.xliff | 15 +++----- .../Localized Contents/pt-BR.xliff | 4 +-- .../pt.xcloc/Localized Contents/pt.xliff | 4 +-- .../ru.xcloc/Localized Contents/ru.xliff | 15 +++----- .../th.xcloc/Localized Contents/th.xliff | 15 +++----- .../tr.xcloc/Localized Contents/tr.xliff | 15 +++----- .../uk.xcloc/Localized Contents/uk.xliff | 15 +++----- .../Localized Contents/zh-Hans.xliff | 15 +++----- .../Localized Contents/zh-Hant.xliff | 4 +-- apps/ios/bg.lproj/Localizable.strings | 2 +- apps/ios/cs.lproj/Localizable.strings | 5 ++- apps/ios/de.lproj/Localizable.strings | 2 +- apps/ios/es.lproj/Localizable.strings | 2 +- apps/ios/fi.lproj/Localizable.strings | 5 ++- apps/ios/fr.lproj/Localizable.strings | 2 +- apps/ios/hu.lproj/Localizable.strings | 2 +- apps/ios/it.lproj/Localizable.strings | 2 +- apps/ios/ja.lproj/Localizable.strings | 5 ++- apps/ios/nl.lproj/Localizable.strings | 2 +- apps/ios/pl.lproj/Localizable.strings | 2 +- apps/ios/ru.lproj/Localizable.strings | 2 +- 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 +- 49 files changed, 208 insertions(+), 244 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f9cf5e98e4..81d78fbadd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -94,7 +94,7 @@ struct ChatListNavLink: View { Group { if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { deleteContactDialog( @@ -121,6 +121,7 @@ struct ChatListNavLink: View { selection: $chatModel.chatId, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } ) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() @@ -145,7 +146,6 @@ struct ChatListNavLink: View { } .tint(.red) } - .frame(height: dynamicRowHeight) } } .alert(item: $alert) { $0.alert } @@ -163,7 +163,7 @@ struct ChatListNavLink: View { switch (groupInfo.membership.memberStatus) { case .memInvited: ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { joinGroupButton() if groupInfo.canDelete { @@ -183,7 +183,7 @@ struct ChatListNavLink: View { .disabled(inProgress) case .memAccepted: ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) } @@ -203,7 +203,7 @@ struct ChatListNavLink: View { label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !groupInfo.ready ) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() @@ -250,7 +250,7 @@ struct ChatListNavLink: View { label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !noteFolder.ready ) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() } @@ -433,6 +433,7 @@ struct ChatListNavLink: View { private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } @@ -451,7 +452,6 @@ struct ChatListNavLink: View { } .tint(.red) } - .frame(height: dynamicRowHeight) .contentShape(Rectangle()) .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { @@ -463,6 +463,7 @@ struct ChatListNavLink: View { private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { ContactConnectionView(chat: chat) + .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in @@ -480,7 +481,6 @@ struct ChatListNavLink: View { } .tint(theme.colors.primary) } - .frame(height: dynamicRowHeight) .appSheet(isPresented: $showContactConnectionInfo) { Group { if case let .contactConnection(contactConnection) = chat.chatInfo { @@ -583,7 +583,7 @@ struct ChatListNavLink: View { Text("invalid chat data") .foregroundColor(.red) .padding(4) - .frame(height: dynamicRowHeight) + .frameCompat(height: dynamicRowHeight) .onTapGesture { showInvalidJSON = true } .appSheet(isPresented: $showInvalidJSON) { invalidJSONView(dataToString(json)) @@ -603,6 +603,24 @@ struct ChatListNavLink: View { } } +extension View { + @inline(__always) + @ViewBuilder fileprivate func frameCompat(height: CGFloat) -> some View { + if #available(iOS 16, *) { + self.frame(height: height) + } else { + VStack(spacing: 0) { + Divider() + .padding(.leading, 16) + self + .frame(height: height) + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + } + } +} + func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert { Alert( title: Text("Reject contact request"), diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 5c491b6303..f34f930c6f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -367,13 +367,7 @@ struct ChatListView: View { .offset(x: -8) } else { ForEach(cs, id: \.viewId) { chat in - VStack(spacing: .zero) { - Divider() - .padding(.leading, 16) - ChatListNavLink(chat: chat, parentSheet: $sheet) - .padding(.horizontal, 8) - .padding(.vertical, 6) - } + ChatListNavLink(chat: chat, parentSheet: $sheet) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 17e1735472..656cef4a04 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -67,7 +67,7 @@ struct OnboardingConditionsView: View { var body: some View { GeometryReader { g in - ScrollView { + let v = ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Conditions of use") .font(.largeTitle) @@ -107,6 +107,7 @@ struct OnboardingConditionsView: View { .frame(minHeight: 40) } } + .padding(25) .frame(minHeight: g.size.height) } .onAppear { @@ -127,9 +128,14 @@ struct OnboardingConditionsView: View { } } .frame(maxHeight: .infinity, alignment: .top) + if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } } .frame(maxHeight: .infinity, alignment: .top) - .padding(25) + .navigationBarHidden(true) // necessary on iOS 15 } private func continueToNextStep() { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index c022a2a012..ae72cb1be5 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -62,8 +62,7 @@ struct CreateProfile: View { .frame(height: 20) } footer: { VStack(alignment: .leading, spacing: 8) { - Text("Your profile, contacts and delivered messages are stored on your device.") - Text("The profile is only shared with your contacts.") + Text("Your profile is stored on your device and only shared with your contacts.") } .foregroundColor(theme.colors.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -118,25 +117,22 @@ struct CreateFirstProfile: View { @State private var nextStepNavLinkActive = false var body: some View { - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .center, spacing: 20) { - Text("Create your profile") + let v = VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .center, spacing: 16) { + Text("Create profile") .font(.largeTitle) .bold() .multilineTextAlignment(.center) - - Text("Your profile, contacts and delivered messages are stored on your device.") - .font(.callout) - .foregroundColor(theme.colors.secondary) - .multilineTextAlignment(.center) - - Text("The profile is only shared with your contacts.") + + Text("Your profile is stored on your device and only shared with your contacts.") .font(.callout) .foregroundColor(theme.colors.secondary) .multilineTextAlignment(.center) } + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) // Ensures it takes up the full width .padding(.horizontal, 10) + .onTapGesture { focusDisplayName = false } HStack { let name = displayName.trimmingCharacters(in: .whitespaces) @@ -174,12 +170,23 @@ struct CreateFirstProfile: View { } } .onAppear() { - focusDisplayName = true + if #available(iOS 16, *) { + focusDisplayName = true + } else { + // it does not work before animation completes on iOS 15 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + focusDisplayName = true + } + } } .padding(.horizontal, 25) - .padding(.top, 10) .padding(.bottom, 25) .frame(maxWidth: .infinity, alignment: .leading) + if #available(iOS 16, *) { + return v.padding(.top, 10) + } else { + return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top) + } } func createProfileButton() -> some View { diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 97e1f49382..31865e7af9 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -17,7 +17,7 @@ struct SetNotificationsMode: View { var body: some View { GeometryReader { g in - ScrollView { + let v = ScrollView { VStack(alignment: .center, spacing: 20) { Text("Push notifications") .font(.largeTitle) @@ -57,11 +57,17 @@ struct SetNotificationsMode: View { .padding(25) .frame(minHeight: g.size.height) } + if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } } .frame(maxHeight: .infinity) .sheet(isPresented: $showInfo) { NotificationsInfoView() } + .navigationBarHidden(true) // necessary on iOS 15 } private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index e55cc4037a..9f41a37b1d 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -18,7 +18,7 @@ struct SimpleXInfo: View { var body: some View { GeometryReader { g in - ScrollView { + let v = ScrollView { VStack(alignment: .leading) { VStack(alignment: .center, spacing: 10) { Image(colorScheme == .light ? "logo" : "logo-light") @@ -36,7 +36,7 @@ struct SimpleXInfo: View { .font(.headline) } } - + Spacer() VStack(alignment: .leading) { @@ -66,6 +66,9 @@ struct SimpleXInfo: View { } } } + .padding(.horizontal, 25) + .padding(.top, 75) + .padding(.bottom, 25) .frame(minHeight: g.size.height) } .sheet(isPresented: Binding( @@ -88,14 +91,17 @@ struct SimpleXInfo: View { createProfileNavLinkActive: $createProfileNavLinkActive ) } + if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } } .onAppear() { setLastVersionDefault() } .frame(maxHeight: .infinity) - .padding(.horizontal, 25) - .padding(.top, 75) - .padding(.bottom, 25) + .navigationBarHidden(true) // necessary on iOS 15 } private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View { diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index 278b9ec9b2..e965e5a1a5 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -2826,8 +2826,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 56553b3283..776199ac1f 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -7329,11 +7329,6 @@ It can happen because of some bug or when the connection is compromised.Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита. No comment provided by engineer. - - The profile is only shared with your contacts. - Профилът се споделя само с вашите контакти. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8577,6 +8572,11 @@ Repeat connection request? Вашият профил **%@** ще бъде споделен. 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 сървърите не могат да видят вашия профил. @@ -8586,11 +8586,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство. - No comment provided by engineer. - Your random profile Вашият автоматично генериран профил diff --git a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff index 7002f790df..bf7753675e 100644 --- a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff +++ b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff @@ -3422,8 +3422,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 21bf0aef60..0400839cb0 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1942,6 +1942,7 @@ This is your own one-time link! Create profile + Vytvořte si profil No comment provided by engineer. @@ -7080,11 +7081,6 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Stará databáze nebyla během přenášení odstraněna, lze ji smazat. No comment provided by engineer. - - The profile is only shared with your contacts. - Profil je sdílen pouze s vašimi kontakty. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8271,6 +8267,11 @@ Repeat connection request? Váš profil **%@** bude sdílen. 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. + 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. @@ -8280,11 +8281,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - 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í. - No comment provided by engineer. - Your random profile Váš náhodný profil 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 a113d35bbd..06fd7c5a1d 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -7763,11 +7763,6 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Die alte Datenbank wurde während der Migration nicht entfernt. Sie kann gelöscht werden. No comment provided by engineer. - - The profile is 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 **%@**. @@ -9081,6 +9076,11 @@ Verbindungsanfrage wiederholen? Ihr Profil **%@** wird geteilt. 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. + 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. @@ -9091,11 +9091,6 @@ Verbindungsanfrage wiederholen? Ihr Profil wurde geändert. Wenn Sie es speichern, wird das aktualisierte Profil an alle Ihre Kontakte gesendet. alert message - - 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/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff index b601d1fa74..fc1846942c 100644 --- a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff +++ b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff @@ -3043,8 +3043,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 641af86c2a..fd71e0dee6 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -7764,11 +7764,6 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. - The profile is only shared with your contacts. - No comment provided by engineer. - The same conditions will apply to operator **%@**. The same conditions will apply to operator **%@**. @@ -9082,6 +9077,11 @@ Repeat connection request? Your profile **%@** will be shared. No comment provided by engineer. + + Your profile is stored on your device and only shared with your contacts. + 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. Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -9092,11 +9092,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Your profile, contacts and delivered messages are stored on your device. - No comment provided by engineer. - Your random profile Your random profile 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 73f88e1cab..d39fb61249 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -7763,11 +7763,6 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. La base de datos antigua no se eliminó durante la migración, puede eliminarse. No comment provided by engineer. - - The profile is 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 **%@**. @@ -9081,6 +9076,11 @@ Repeat connection request? 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. @@ -9091,11 +9091,6 @@ Repeat connection request? Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos. alert message - - 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/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 5281fbc701..a54666bb10 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1923,6 +1923,7 @@ This is your own one-time link! Create profile + Luo profiilisi No comment provided by engineer. @@ -7054,11 +7055,6 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.
Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa. No comment provided by engineer. - - The profile is only shared with your contacts. - Profiili jaetaan vain kontaktiesi kanssa. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8244,6 +8240,11 @@ Repeat connection request? Profiilisi **%@** jaetaan. 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. + 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. @@ -8253,11 +8254,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi. - No comment provided by engineer. - Your random profile Satunnainen profiilisi 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 1e5da0b0ed..59bde0650e 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -7696,11 +7696,6 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. 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. - - The profile is 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 **%@**. @@ -9003,6 +8998,11 @@ Répéter la demande de connexion ? 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. @@ -9013,11 +9013,6 @@ Répéter la demande de connexion ? Votre profil a été modifié. Si vous l'enregistrez, le profil mis à jour sera envoyé à tous vos contacts. alert message - - 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/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff index 08f46bb056..f76d7eba1e 100644 --- a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff +++ b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff @@ -3569,8 +3569,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index bdb3083f5a..6ad4d159c7 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -2619,8 +2619,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 9be5879eb6..78bee138e4 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -7763,11 +7763,6 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. 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. - - The profile is 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: **%@**. @@ -9081,6 +9076,11 @@ Megismétli a meghívási kérést? 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 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. @@ -9091,11 +9091,6 @@ Megismétli a meghívási kérést? A profilja módosult. Ha elmenti, a profilfrissítés el lesz küldve a partnerei számára. alert message - - 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/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 0c0c12004e..cf5f61918f 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -7763,11 +7763,6 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il database vecchio non è stato rimosso durante la migrazione, può essere eliminato. No comment provided by engineer. - - The profile is 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 **%@**. @@ -9081,6 +9076,11 @@ Ripetere la richiesta di connessione? 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. @@ -9091,11 +9091,6 @@ Ripetere la richiesta di connessione? Il tuo profilo è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato a tutti i tuoi contatti. alert message - - 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/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 26f415dd13..27134216a7 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1990,6 +1990,7 @@ This is your own one-time link! Create profile + プロフィールを作成する No comment provided by engineer. @@ -7125,11 +7126,6 @@ It can happen because of some bug or when the connection is compromised.古いデータベースは移行時に削除されなかったので、削除することができます。 No comment provided by engineer. - - The profile is only shared with your contacts. - プロフィールは連絡先にしか共有されません。 - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8315,6 +8311,11 @@ Repeat connection request? あなたのプロファイル **%@** が共有されます。 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 サーバーはあなたのプロファイルを参照できません。 @@ -8324,11 +8325,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - あなたのプロフィール、連絡先、送信したメッセージがご自分の端末に保存されます。 - No comment provided by engineer. - Your random profile あなたのランダム・プロフィール diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index e35732f046..019f63cbc0 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -2867,8 +2867,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff index 54a713478f..0f795170c6 100644 --- a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff +++ b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff @@ -2631,8 +2631,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 681502e255..4008c57ac0 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -7760,11 +7760,6 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De oude database is niet verwijderd tijdens de migratie, deze kan worden verwijderd. No comment provided by engineer. - - The profile is 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 **%@**. @@ -9074,6 +9069,11 @@ Verbindingsverzoek herhalen? 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. @@ -9084,11 +9084,6 @@ Verbindingsverzoek herhalen? Je profiel is gewijzigd. Als je het opslaat, wordt het bijgewerkte profiel naar al je contacten verzonden. alert message - - 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/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 01bc0b8508..175c8b4112 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -7583,11 +7583,6 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Stara baza danych nie została usunięta podczas migracji, można ją usunąć. No comment provided by engineer. - - The profile is only shared with your contacts. - Profil jest udostępniany tylko Twoim kontaktom. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8870,6 +8865,11 @@ Powtórzyć prośbę połączenia? 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. @@ -8880,11 +8880,6 @@ Powtórzyć prośbę połączenia? Twój profil został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do wszystkich kontaktów. alert message - - 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/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff index 93ba6f357b..bbb6c7d22a 100644 --- a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff +++ b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff @@ -3002,8 +3002,8 @@ We will be adding server redundancy to prevent lost messages. The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. O perfil é compartilhado apenas com seus contatos. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff index de1787bdad..bc8bf79da1 100644 --- a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff +++ b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff @@ -3146,8 +3146,8 @@ It can happen because of some bug or when the connection is compromised.The old database was not removed during the migration, it can be deleted. No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index a7b63e38ba..419fa75375 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -7715,11 +7715,6 @@ It can happen because of some bug or when the connection is compromised.Предыдущая версия данных чата не удалена при перемещении, её можно удалить. No comment provided by engineer. - - The profile is only shared with your contacts. - Профиль отправляется только Вашим контактам. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Те же самые условия будут приняты для оператора **%@**. @@ -9021,6 +9016,11 @@ Repeat connection request? Будет отправлен Ваш профиль **%@**. 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 серверы не могут получить доступ к Вашему профилю. @@ -9031,11 +9031,6 @@ Repeat connection request? Ваш профиль был изменен. Если вы сохраните его, обновленный профиль будет отправлен всем вашим контактам. alert message - - Your profile, contacts and delivered messages are stored on your device. - Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве. - No comment provided by engineer. - Your random profile Случайный профиль 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 be68dc9780..671dd87d7d 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -7028,11 +7028,6 @@ It can happen because of some bug or when the connection is compromised.ฐานข้อมูลเก่าไม่ได้ถูกลบในระหว่างการย้ายข้อมูล แต่สามารถลบได้ No comment provided by engineer. - - The profile is only shared with your contacts. - โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8212,6 +8207,11 @@ Repeat connection request? 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 ไม่สามารถดูโปรไฟล์ของคุณได้ @@ -8221,11 +8221,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - โปรไฟล์ รายชื่อผู้ติดต่อ และข้อความที่ส่งของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณ - No comment provided by engineer. - Your random profile โปรไฟล์แบบสุ่มของคุณ 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 6eb1daf84b..bbee40c2b9 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -7599,11 +7599,6 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Eski veritabanı geçiş sırasında kaldırılmadı, silinebilir. No comment provided by engineer. - - The profile is only shared with your contacts. - Profil sadece kişilerinle paylaşılacak. - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8886,6 +8881,11 @@ Bağlantı isteği tekrarlansın mı? 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. @@ -8896,11 +8896,6 @@ Bağlantı isteği tekrarlansın mı? Profiliniz değiştirildi. Kaydederseniz, güncellenmiş profil tüm kişilerinize gönderilecektir. alert message - - 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/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 7c8c6f4254..c0375e3b02 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -7638,11 +7638,6 @@ It can happen because of some bug or when the connection is compromised.Стара база даних не була видалена під час міграції, її можна видалити. No comment provided by engineer. - - The profile is only shared with your contacts. - Профіль доступний лише вашим контактам. - No comment provided by engineer. - The same conditions will apply to operator **%@**. Такі ж умови діятимуть і для оператора **%@**. @@ -8945,6 +8940,11 @@ Repeat connection request? Ваш профіль **%@** буде опублікований. 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 не бачать ваш профіль. @@ -8955,11 +8955,6 @@ Repeat connection request? Ваш профіль було змінено. Якщо ви збережете його, оновлений профіль буде надіслано всім вашим контактам. alert message - - Your profile, contacts and delivered messages are stored on your device. - Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої. - No comment provided by engineer. - Your random profile Ваш випадковий профіль 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 03e053326a..d5411f86e3 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 @@ -7708,11 +7708,6 @@ It can happen because of some bug or when the connection is compromised.旧数据库在迁移过程中没有被移除,可以删除。 No comment provided by engineer. - - The profile is only shared with your contacts. - 该资料仅与您的联系人共享。 - No comment provided by engineer. - The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -8987,6 +8982,11 @@ Repeat connection request? 您的个人资料 **%@** 将被共享。 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 服务器无法看到您的资料。 @@ -8996,11 +8996,6 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. alert message - - Your profile, contacts and delivered messages are stored on your device. - 您的资料、联系人和发送的消息存储在您的设备上。 - No comment provided by engineer. - Your random profile 您的随机资料 diff --git a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff index 8a771369e6..3ea46ee364 100644 --- a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff @@ -3054,8 +3054,8 @@ We will be adding server redundancy to prevent lost messages. 舊的數據庫在遷移過程中沒有被移除,可以刪除。 No comment provided by engineer. - - The profile is only shared with your contacts. + + Your profile is stored on your device and only shared with your contacts. 你的個人檔案只會和你的聯絡人分享。 No comment provided by engineer. diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index f241158185..e4bc8f2150 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -3777,7 +3777,7 @@ chat item action */ "The old database was not removed during the migration, it can be deleted." = "Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита."; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Профилът се споделя само с вашите контакти."; +"Your profile is stored on your device and only shared with your contacts." = "Профилът се споделя само с вашите контакти."; /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Втората отметка, която пропуснахме! ✅"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 003ac23325..08a94615a3 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -822,6 +822,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻"; +/* No comment provided by engineer. */ +"Create profile" = "Vytvořte si profil"; + /* server test step */ "Create queue" = "Vytvořit frontu"; @@ -2986,7 +2989,7 @@ chat item action */ "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. */ -"The profile is only shared with your contacts." = "Profil je sdílen pouze s vašimi kontakty."; +"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! ✅"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 0eab764216..8da7835c43 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -5101,7 +5101,7 @@ report reason */ "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. */ -"The profile is only shared with your contacts." = "Das Profil wird nur mit Ihren Kontakten geteilt."; +"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 **%@**."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index e797b73b98..28ba0f0642 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -5101,7 +5101,7 @@ report reason */ "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. */ -"The profile is only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; +"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 **%@**."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index c4031adf9a..4891c7fb26 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -768,6 +768,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻"; +/* No comment provided by engineer. */ +"Create profile" = "Luo profiilisi"; + /* server test step */ "Create queue" = "Luo jono"; @@ -2908,7 +2911,7 @@ chat item action */ "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. */ -"The profile is only shared with your contacts." = "Profiili jaetaan vain kontaktiesi kanssa."; +"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! ✅"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 1c16f8847d..4dd75039dc 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -4884,7 +4884,7 @@ chat item action */ "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. */ -"The profile is only shared with your contacts." = "Le profil n'est partagé qu'avec vos contacts."; +"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 **%@**."; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index c190cccaba..5a9b6b4e38 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -5101,7 +5101,7 @@ report reason */ "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. */ -"The profile is only shared with your contacts." = "A profilja csak a partnereivel van megosztva."; +"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: **%@**."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index f67a492cc4..b914a06079 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -5101,7 +5101,7 @@ report reason */ "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. */ -"The profile is only shared with your contacts." = "Il profilo è condiviso solo con i tuoi contatti."; +"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 **%@**."; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 9d0cccf591..d214f88e1c 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -957,6 +957,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "[デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻"; +/* No comment provided by engineer. */ +"Create profile" = "プロフィールを作成する"; + /* server test step */ "Create queue" = "キューの作成"; @@ -3109,7 +3112,7 @@ chat item action */ "The old database was not removed during the migration, it can be deleted." = "古いデータベースは移行時に削除されなかったので、削除することができます。"; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; +"Your profile is stored on your device and only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; /* No comment provided by engineer. */ "The second tick we missed! ✅" = "長らくお待たせしました! ✅"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index d2cfcba0de..232de56641 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -5092,7 +5092,7 @@ report reason */ "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. */ -"The profile is only shared with your contacts." = "Het profiel wordt alleen gedeeld met uw contacten."; +"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 **%@**."; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 867f3beff4..31a9b87662 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -4557,7 +4557,7 @@ chat item action */ "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. */ -"The profile is only shared with your contacts." = "Profil jest udostępniany tylko Twoim kontaktom."; +"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! ✅"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index b819d013b9..cb837836ff 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -4957,7 +4957,7 @@ report reason */ "The old database was not removed during the migration, it can be deleted." = "Предыдущая версия данных чата не удалена при перемещении, её можно удалить."; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Профиль отправляется только Вашим контактам."; +"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 **%@**." = "Те же самые условия будут приняты для оператора **%@**."; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 6b3381922a..57c0466eb9 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -2830,7 +2830,7 @@ chat item action */ "The old database was not removed during the migration, it can be deleted." = "ฐานข้อมูลเก่าไม่ได้ถูกลบในระหว่างการย้ายข้อมูล แต่สามารถลบได้"; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น"; +"Your profile is stored on your device and only shared with your contacts." = "โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น"; /* No comment provided by engineer. */ "The second tick we missed! ✅" = "ขีดที่สองที่เราพลาด! ✅"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index ab0703333e..e3bb11d1cc 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -4602,7 +4602,7 @@ chat item action */ "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. */ -"The profile is only shared with your contacts." = "Profil sadece kişilerinle paylaşılacak."; +"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! ✅"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 8e2b514ed4..734b8dda82 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -4722,7 +4722,7 @@ chat item action */ "The old database was not removed during the migration, it can be deleted." = "Стара база даних не була видалена під час міграції, її можна видалити."; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "Профіль доступний лише вашим контактам."; +"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 **%@**." = "Такі ж умови діятимуть і для оператора **%@**."; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 6ceeeb22d0..e3f9669d9f 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -4923,7 +4923,7 @@ chat item action */ "The old database was not removed during the migration, it can be deleted." = "旧数据库在迁移过程中没有被移除,可以删除。"; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "该资料仅与您的联系人共享。"; +"Your profile is stored on your device and only shared with your contacts." = "该资料仅与您的联系人共享。"; /* No comment provided by engineer. */ "The second tick we missed! ✅" = "我们错过的第二个\"√\"!✅"; 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 255/289] 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 26e5742354f2d9dd87b5c0cf3a4cf8227eec4813 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 15 May 2025 14:58:40 +0100 Subject: [PATCH 256/289] ios: fix swipe in members list for iOS 15 (#5914) * ios: fix swipe in members list for iOS 15 * refactor --- .../Views/Chat/Group/GroupChatInfoView.swift | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 96a4981be0..15749b0761 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -144,17 +144,9 @@ struct GroupChatInfoView: View { let filteredMembers = s == "" ? members : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } - MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) + MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) ForEach(filteredMembers) { member in - ZStack { - NavigationLink { - memberInfoView(member) - } label: { - EmptyView() - } - .opacity(0) - MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) - } + MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, alert: $alert) } } @@ -358,6 +350,7 @@ struct GroupChatInfoView: View { } private struct MemberRowView: View { + var chat: Chat var groupInfo: GroupInfo @ObservedObject var groupMember: GMember @EnvironmentObject var theme: AppTheme @@ -366,7 +359,7 @@ struct GroupChatInfoView: View { var body: some View { let member = groupMember.wrapped - let v = HStack{ + let v1 = HStack{ MemberProfileImage(member, size: 38) .padding(.trailing, 2) // TODO server connection status @@ -383,6 +376,20 @@ struct GroupChatInfoView: View { memberInfo(member) } + let v = ZStack { + if user { + v1 + } else { + NavigationLink { + memberInfoView() + } label: { + EmptyView() + } + .opacity(0) + v1 + } + } + if user { v } else if groupInfo.membership.memberRole >= .admin { @@ -407,6 +414,11 @@ struct GroupChatInfoView: View { } } + private func memberInfoView() -> some View { + GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember) + .navigationBarHidden(false) + } + private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { if member.activeConn?.connDisabled ?? false { return "disabled" @@ -485,11 +497,6 @@ struct GroupChatInfoView: View { .foregroundColor(theme.colors.secondary) } } - - private func memberInfoView(_ groupMember: GMember) -> some View { - GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember) - .navigationBarHidden(false) - } private func groupLinkButton() -> some View { NavigationLink { 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 257/289] 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 258/289] 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 259/289] 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 260/289] 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 261/289] 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 262/289] 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 263/289] 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 264/289] 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 265/289] 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 266/289] 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 267/289] 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 268/289] 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 7b362ff655959a790815931e4db44ced52a9212c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 May 2025 15:50:33 +0100 Subject: [PATCH 269/289] ui: label in compose when user cannot send messages (#5922) * ui: label in compose when user cannot send messages * gray buttons when user cannot send messages * improve * kotlin * fix order * fix alert --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 21 ------- apps/ios/Shared/Views/Chat/ChatView.swift | 12 +++- .../Chat/ComposeMessage/ComposeView.swift | 19 +++--- .../Chat/ComposeMessage/SendMessageView.swift | 8 ++- apps/ios/SimpleXChat/ChatTypes.swift | 50 ++++++++++++--- .../platform/PlatformTextField.android.kt | 13 ++-- .../chat/simplex/common/model/ChatModel.kt | 62 ++++++++++++------- .../common/platform/PlatformTextField.kt | 2 +- .../chat/simplex/common/views/TerminalView.kt | 3 +- .../simplex/common/views/chat/ChatView.kt | 2 +- .../simplex/common/views/chat/ComposeView.kt | 11 ++-- .../simplex/common/views/chat/SendMsgView.kt | 62 +++++++++---------- .../commonMain/resources/MR/base/strings.xml | 15 ++++- .../platform/PlatformTextField.desktop.kt | 12 ++-- 14 files changed, 164 insertions(+), 128 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 63d8b38e3c..9b9fda0397 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -1152,27 +1152,6 @@ final class Chat: ObservableObject, Identifiable, ChatLike { ) } - var userCanSend: Bool { - switch chatInfo { - case .direct: return true - case let .group(groupInfo): - let m = groupInfo.membership - return m.memberActive && m.memberRole >= .member - case .local: - return true - default: return false - } - } - - var userIsObserver: Bool { - switch chatInfo { - case let .group(groupInfo): - let m = groupInfo.membership - return m.memberActive && m.memberRole == .observer - default: return false - } - } - var unreadTag: Bool { switch chatInfo.chatSettings?.enableNtfs { case .all: chatStats.unreadChat || chatStats.unreadCount > 0 diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 9e648ef98c..c136ebc01b 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -98,14 +98,24 @@ struct ChatView: View { } connectingText() if selectedChatItems == nil { + let reason = chat.chatInfo.userCantSendReason ComposeView( chat: chat, composeState: $composeState, keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, - selectedRange: $selectedRange + selectedRange: $selectedRange, + disabledText: reason?.composeLabel ) .disabled(!cInfo.sendMsgEnabled) + .if(!cInfo.sendMsgEnabled) { v in + v.disabled(true).onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: reason?.alertMessage + ) + } + } } else { SelectedItemsBottomToolbar( chatItems: ItemsModel.shared.reversedChatItems, diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3e9c340266..8993de886f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -327,6 +327,7 @@ struct ComposeView: View { @Binding var keyboardVisible: Bool @Binding var keyboardHiddenDate: Date @Binding var selectedRange: NSRange + var disabledText: LocalizedStringKey? = nil @State var linkUrl: URL? = nil @State var hasSimplexLink: Bool = false @@ -391,7 +392,7 @@ struct ComposeView: View { Image(systemName: "paperclip") .resizable() } - .disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) + .disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) .frame(width: 25, height: 25) .padding(.bottom, 16) .padding(.leading, 12) @@ -441,19 +442,13 @@ struct ComposeView: View { : theme.colors.primary ) .padding(.trailing, 12) - .disabled(!chat.userCanSend) + .disabled(!chat.chatInfo.sendMsgEnabled) - if chat.userIsObserver { - Text("you are observer") + if let disabledText { + Text(disabledText) .italic() .foregroundColor(theme.colors.secondary) .padding(.horizontal, 12) - .onTapGesture { - AlertManager.shared.showAlertMsg( - title: "You can't send messages!", - message: "Please contact group admin." - ) - } } } } @@ -479,8 +474,8 @@ struct ComposeView: View { hasSimplexLink = false } } - .onChange(of: chat.userCanSend) { canSend in - if !canSend { + .onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in + if !sendEnabled { cancelCurrentVoiceRecording() clearCurrentDraft() clearState() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index d7b29a0ecb..e7b02c9aea 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -15,6 +15,7 @@ struct SendMessageView: View { @Binding var composeState: ComposeState @Binding var selectedRange: NSRange @EnvironmentObject var theme: AppTheme + @Environment(\.isEnabled) var isEnabled var sendMessage: (Int?) -> Void var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil @@ -255,6 +256,7 @@ struct SendMessageView: View { } private struct RecordVoiceMessageButton: View { + @Environment(\.isEnabled) var isEnabled @EnvironmentObject var theme: AppTheme var startVoiceMessageRecording: (() -> Void)? var finishVoiceMessageRecording: (() -> Void)? @@ -263,11 +265,11 @@ struct SendMessageView: View { @State private var pressed: TimeInterval? = nil var body: some View { - Image(systemName: "mic.fill") + Image(systemName: isEnabled ? "mic.fill" : "mic") .resizable() .scaledToFit() .frame(width: 20, height: 20) - .foregroundColor(theme.colors.primary) + .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary) .opacity(holdingVMR ? 0.7 : 1) .disabled(disabled) .frame(width: 31, height: 31) @@ -352,7 +354,7 @@ struct SendMessageView: View { Image(systemName: "bolt.fill") .resizable() .scaledToFit() - .foregroundColor(theme.colors.primary) + .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary) .frame(width: 20, height: 20) } .frame(width: 29, height: 29) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 960fdd466d..88246465e1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1333,6 +1333,19 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { + get { + switch self { + case let .direct(contact): return contact.userCantSendReason + case let .group(groupInfo): return groupInfo.userCantSendReason + case let .local(noteFolder): return noteFolder.userCantSendReason + case let .contactRequest(contactRequest): return contactRequest.userCantSendReason + case let .contactConnection(contactConnection): return contactConnection.userCantSendReason + case .invalidJSON: return ("can't send messages", nil) + } + } + } + public var sendMsgEnabled: Bool { get { switch self { @@ -1642,15 +1655,16 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var ready: Bool { get { activeConn?.connStatus == .ready } } public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var active: Bool { get { contactStatus == .active } } - public var sendMsgEnabled: Bool { get { - ( - sndReady - && active - && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false) - && !(activeConn?.connDisabled ?? true) - ) - || nextSendGrpInv - } } + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { + // TODO [short links] this will have additional statuses for pending contact requests before they are accepted + if nextSendGrpInv { return nil } + if !active { return ("contact deleted", nil) } + if !sndReady { return ("contact not ready", nil) } + if activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false { return ("not synchronized", nil) } + if activeConn?.connDisabled ?? true { return ("contact disabled", nil) } + return nil + } + public var sendMsgEnabled: Bool { userCantSendReason == nil } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } @@ -1829,6 +1843,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public var id: ChatId { get { "<@\(contactRequestId)" } } public var apiId: Int64 { get { contactRequestId } } var ready: Bool { get { true } } + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { ("can't send messages", nil) } public var sendMsgEnabled: Bool { get { false } } public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } @@ -1861,6 +1876,7 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable { public var id: ChatId { get { ":\(pccConnId)" } } public var apiId: Int64 { get { pccConnId } } var ready: Bool { get { false } } + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { ("can't send messages", nil) } public var sendMsgEnabled: Bool { get { false } } var localDisplayName: String { get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } @@ -1990,7 +2006,20 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var id: ChatId { get { "#\(groupId)" } } public var apiId: Int64 { get { groupId } } public var ready: Bool { get { true } } - public var sendMsgEnabled: Bool { get { membership.memberActive } } + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { + return if membership.memberActive { + membership.memberRole == .observer ? ("you are observer", "Please contact group admin.") : nil + } else { + switch membership.memberStatus { + case .memRejected: ("request to join rejected", nil) + case .memGroupDeleted: ("group is deleted", nil) + case .memRemoved: ("removed from group", nil) + case .memLeft: ("you left", nil) + default: ("can't send messages", nil) + } + } + } + public var sendMsgEnabled: Bool { userCantSendReason == nil } public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } @@ -2357,6 +2386,7 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat, Hashable { public var id: ChatId { get { "*\(noteFolderId)" } } public var apiId: Int64 { get { noteFolderId } } public var ready: Bool { get { true } } + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { nil } public var sendMsgEnabled: Bool { get { true } } public var displayName: String { get { ChatInfo.privateNotesChatName } } public var fullName: String { get { "" } } 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..4f48ccca52 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 @@ -42,7 +42,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import java.lang.reflect.Field import java.net.URI @@ -51,10 +50,10 @@ import java.net.URI actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + disabledText: String?, sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, - userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, @@ -197,16 +196,16 @@ actual fun PlatformTextField( showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress } if (composeState.value.preview is ComposePreview.VoicePreview) { - ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) - } else if (userIsObserver) { - ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) + ComposeOverlay(generalGetString(MR.strings.voice_message_send_text), textStyle, padding) + } else if (disabledText != null) { + ComposeOverlay(disabledText, textStyle, padding) } } @Composable -private fun ComposeOverlay(textId: StringResource, textStyle: MutableState, padding: PaddingValues) { +private fun ComposeOverlay(text: String, textStyle: MutableState, padding: PaddingValues) { Text( - generalGetString(textId), + text, Modifier.padding(padding), color = MaterialTheme.colors.secondary, style = textStyle.value.copy(fontStyle = FontStyle.Italic) 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 6ee609020a..61c20587bf 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 @@ -1204,6 +1204,7 @@ interface SomeChat { val apiId: Long val ready: Boolean val chatDeleted: Boolean + val userCantSendReason: Pair? val sendMsgEnabled: Boolean val incognito: Boolean fun featureEnabled(feature: ChatFeature): Boolean @@ -1228,14 +1229,6 @@ data class Chat( else -> false } - val userIsObserver: Boolean get() = when(chatInfo) { - is ChatInfo.Group -> { - val m = chatInfo.groupInfo.membership - m.memberActive && m.memberRole == GroupMemberRole.Observer - } - else -> false - } - val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) { All -> chatStats.unreadChat || chatStats.unreadCount > 0 Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0 @@ -1282,6 +1275,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contact.apiId override val ready get() = contact.ready override val chatDeleted get() = contact.chatDeleted + override val userCantSendReason get() = contact.userCantSendReason override val sendMsgEnabled get() = contact.sendMsgEnabled override val incognito get() = contact.incognito override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature) @@ -1307,6 +1301,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = groupInfo.apiId override val ready get() = groupInfo.ready override val chatDeleted get() = groupInfo.chatDeleted + override val userCantSendReason get() = groupInfo.userCantSendReason override val sendMsgEnabled get() = groupInfo.sendMsgEnabled override val incognito get() = groupInfo.incognito override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature) @@ -1331,6 +1326,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = noteFolder.apiId override val ready get() = noteFolder.ready override val chatDeleted get() = noteFolder.chatDeleted + override val userCantSendReason get() = noteFolder.userCantSendReason override val sendMsgEnabled get() = noteFolder.sendMsgEnabled override val incognito get() = noteFolder.incognito override fun featureEnabled(feature: ChatFeature) = noteFolder.featureEnabled(feature) @@ -1355,6 +1351,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactRequest.apiId override val ready get() = contactRequest.ready override val chatDeleted get() = contactRequest.chatDeleted + override val userCantSendReason get() = contactRequest.userCantSendReason override val sendMsgEnabled get() = contactRequest.sendMsgEnabled override val incognito get() = contactRequest.incognito override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature) @@ -1379,6 +1376,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready override val chatDeleted get() = contactConnection.chatDeleted + override val userCantSendReason get() = contactConnection.userCantSendReason override val sendMsgEnabled get() = contactConnection.sendMsgEnabled override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) @@ -1408,6 +1406,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = "?$apiId" override val ready get() = false override val chatDeleted get() = false + override val userCantSendReason get() = generalGetString(MR.strings.cant_send_message_generic) to null override val sendMsgEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false @@ -1450,14 +1449,6 @@ sealed class ChatInfo: SomeChat, NamedChat { is InvalidJSON -> updatedAt } - val userCanSend: Boolean - get() = when (this) { - is ChatInfo.Direct -> true - is ChatInfo.Group -> groupInfo.membership.memberRole >= GroupMemberRole.Member - is ChatInfo.Local -> true - else -> false - } - val chatTags: List? get() = when (this) { is Direct -> contact.chatTags @@ -1528,13 +1519,17 @@ data class Contact( override val ready get() = activeConn?.connStatus == ConnStatus.Ready val sndReady get() = ready || activeConn?.connStatus == ConnStatus.SndReady val active get() = contactStatus == ContactStatus.Active - override val sendMsgEnabled get() = ( - sndReady - && active - && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false) - && !(activeConn?.connDisabled ?: true) - ) - || nextSendGrpInv + override val userCantSendReason: Pair? + get() { + // TODO [short links] this will have additional statuses for pending contact requests before they are accepted + if (nextSendGrpInv) return null + if (!active) return generalGetString(MR.strings.cant_send_message_contact_deleted) to null + if (!sndReady) return generalGetString(MR.strings.cant_send_message_contact_not_ready) to null + if (activeConn?.connectionStats?.ratchetSyncSendProhibited == true) return generalGetString(MR.strings.cant_send_message_contact_not_synchronized) to null + if (activeConn?.connDisabled == true) return generalGetString(MR.strings.cant_send_message_contact_disabled) to null + return null + } + override val sendMsgEnabled get() = userCantSendReason == null val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { @@ -1768,7 +1763,23 @@ data class GroupInfo ( override val apiId get() = groupId override val ready get() = membership.memberActive override val chatDeleted get() = false - override val sendMsgEnabled get() = membership.memberActive + override val userCantSendReason: Pair? get() = + if (membership.memberActive) { + if (membership.memberRole == GroupMemberRole.Observer) { + generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + } else { + null + } + } else { + when (membership.memberStatus) { + GroupMemberStatus.MemRejected -> generalGetString(MR.strings.cant_send_message_rejected) to null + GroupMemberStatus.MemGroupDeleted -> generalGetString(MR.strings.cant_send_message_group_deleted) to null + GroupMemberStatus.MemRemoved -> generalGetString(MR.strings.cant_send_message_mem_removed) to null + GroupMemberStatus.MemLeft -> generalGetString(MR.strings.cant_send_message_you_left) to null + else -> generalGetString(MR.strings.cant_send_message_generic) to null + } + } + override val sendMsgEnabled get() = userCantSendReason == null override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -2144,6 +2155,7 @@ class NoteFolder( override val apiId get() = noteFolderId override val chatDeleted get() = false override val ready get() = true + override val userCantSendReason: Pair? = null override val sendMsgEnabled get() = true override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice @@ -2180,6 +2192,7 @@ class UserContactRequest ( override val apiId get() = contactRequestId override val chatDeleted get() = false override val ready get() = true + override val userCantSendReason = generalGetString(MR.strings.cant_send_message_generic) to null override val sendMsgEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false @@ -2219,6 +2232,7 @@ class PendingContactConnection( override val apiId get() = pccConnId override val chatDeleted get() = false override val ready get() = false + override val userCantSendReason = generalGetString(MR.strings.cant_send_message_generic) to null override val sendMsgEnabled get() = false override val incognito get() = customUserProfileId != null override fun featureEnabled(feature: ChatFeature) = false 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..6b301b9df4 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 @@ -12,10 +12,10 @@ import java.net.URI expect fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + disabledText: String?, sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, - userIsObserver: 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 ca4d4fc0da..37aa7fc1d1 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 @@ -99,12 +99,11 @@ fun TerminalLayout( isDirectChat = false, liveMessageAlertShown = SharedPreference(get = { false }, set = {}), sendMsgEnabled = true, + userCantSendReason = null, sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, - userIsObserver = false, - userCanSend = true, allowVoiceToContact = {}, placeholder = "", sendMessage = { sendCommand() }, 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 b3fdcf79c0..6d7cdcdebe 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 @@ -723,7 +723,7 @@ fun ChatLayout( Modifier .fillMaxWidth() .desktopOnExternalDrag( - enabled = remember(attachmentDisabled.value, chatInfo.value?.userCanSend) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.userCanSend == true) }.value, + enabled = remember(attachmentDisabled.value, chatInfo.value?.sendMsgEnabled) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.sendMsgEnabled == true) }.value, onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) }, onImage = { file -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(file.toURI()), null) } }, onText = { 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..894bcf3b37 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 @@ -999,9 +999,8 @@ fun ComposeView( chatModel.sharedContent.value = null } - val userCanSend = rememberUpdatedState(chat.chatInfo.userCanSend) val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled) - val userIsObserver = rememberUpdatedState(chat.userIsObserver) + val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) Column { @@ -1056,7 +1055,6 @@ fun ComposeView( val attachmentEnabled = !composeState.value.attachmentDisabled && sendMsgEnabled.value - && userCanSend.value && !isGroupAndProhibitedFiles && !nextSendGrpInv.value IconButton( @@ -1102,8 +1100,8 @@ fun ComposeView( } } - LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { - if (!chat.chatInfo.userCanSend) { + LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { + if (!chat.chatInfo.sendMsgEnabled) { clearCurrentDraft() clearState() } @@ -1159,13 +1157,12 @@ fun ComposeView( chat.chatInfo is ChatInfo.Direct, liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, sendMsgEnabled = sendMsgEnabled.value, + userCantSendReason = userCantSendReason.value, sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), nextSendGrpInv = nextSendGrpInv.value, needToAllowVoiceToContact, allowedVoiceByPrefs, allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, - userCanSend = userCanSend.value, sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, 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..5710f09ed5 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.* @@ -41,12 +39,11 @@ fun SendMsgView( isDirectChat: Boolean, liveMessageAlertShown: SharedPreference, sendMsgEnabled: Boolean, + userCantSendReason: Pair?, sendButtonEnabled: Boolean, nextSendGrpInv: Boolean, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, - userIsObserver: Boolean, - userCanSend: Boolean, sendButtonColor: Color = MaterialTheme.colors.primary, allowVoiceToContact: () -> Unit, timedMessageAllowed: Boolean = false, @@ -82,14 +79,14 @@ fun SendMsgView( (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || cs.endLiveDisabled || !sendButtonEnabled - val clicksOnTextFieldDisabled = !sendMsgEnabled || cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress + val clicksOnTextFieldDisabled = !sendMsgEnabled || cs.preview is ComposePreview.VoicePreview || cs.inProgress PlatformTextField( composeState, sendMsgEnabled, + disabledText = userCantSendReason?.first, sendMsgButtonDisabled, textStyle, showDeleteTextButton, - userIsObserver, if (clicksOnTextFieldDisabled) "" else placeholder, showVoiceButton, onMessageChange, @@ -102,16 +99,23 @@ fun SendMsgView( } } if (clicksOnTextFieldDisabled) { - Box( - Modifier - .matchParentSize() - .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.observer_cant_send_message_title), - text = generalGetString(MR.strings.observer_cant_send_message_desc) - ) - }) - ) + if (userCantSendReason != null) { + Box( + Modifier + .matchParentSize() + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.cant_send_message_alert_title), + text = userCantSendReason.second + ) + }) + ) + } else { + Box( + Modifier + .matchParentSize() + ) + } } if (showDeleteTextButton.value) { DeleteTextButton(composeState) @@ -135,11 +139,11 @@ fun SendMsgView( Row(verticalAlignment = Alignment.CenterVertically) { val stopRecOnNextClick = remember { mutableStateOf(false) } when { - needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> { - DisallowedVoiceButton(userCanSend) { + needToAllowVoiceToContact || !allowedVoiceByPrefs -> { + DisallowedVoiceButton { if (needToAllowVoiceToContact) { showNeedToAllowVoiceAlert(allowVoiceToContact) - } else if (!allowedVoiceByPrefs) { + } else { showDisabledVoiceAlert(isDirectChat) } } @@ -155,7 +159,7 @@ fun SendMsgView( && cs.contextItem is ComposeContextItem.NoContextItem ) { Spacer(Modifier.width(12.dp)) - StartLiveMessageButton(userCanSend) { + StartLiveMessageButton { if (composeState.value.preview is ComposePreview.NoPreview) { startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) } @@ -343,8 +347,8 @@ private fun RecordVoiceView(recState: MutableState, stopRecOnNex } @Composable -private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) { - IconButton(onClick, Modifier.size(36.dp), enabled = enabled) { +private fun DisallowedVoiceButton(onClick: () -> Unit) { + IconButton(onClick, Modifier.size(36.dp)) { Icon( painterResource(MR.images.ic_keyboard_voice), stringResource(MR.strings.icon_descr_record_voice_message), @@ -460,14 +464,13 @@ private fun SendMsgButton( } @Composable -private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) { +private fun StartLiveMessageButton(onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } val ripple = remember { ripple(bounded = false, radius = 24.dp) } Box( modifier = Modifier.requiredSize(36.dp) .clickable( onClick = onClick, - enabled = enabled, role = Role.Button, interactionSource = interactionSource, indication = ripple @@ -477,7 +480,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) { Icon( BoltFilled, stringResource(MR.strings.icon_descr_send_message), - tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + tint = MaterialTheme.colors.primary, modifier = Modifier .size(36.dp) .padding(4.dp) @@ -576,12 +579,11 @@ fun PreviewSendMsgView() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + userCantSendReason = null, sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, placeholder = "", @@ -612,12 +614,11 @@ fun PreviewSendMsgViewEditing() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + userCantSendReason = null, sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, placeholder = "", @@ -648,12 +649,11 @@ fun PreviewSendMsgViewInProgress() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + userCantSendReason = null, sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, placeholder = "", 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 1bea4c18d4..6726009a5f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -487,8 +487,6 @@ 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 - You can\'t send messages! - Please contact group admin. Files and media prohibited! Only group owners can enable files and media. Send direct message to connect @@ -508,6 +506,19 @@ Report content: only group moderators will see it. Report other: only group moderators will see it. + You can\'t send messages! + contact not ready + contact deleted + not synchronized + contact disabled + you are observer + Please contact group admin. + request to join rejected + group is deleted + removed from group + you left + can\'t send messages + Image Waiting for image 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..41964b7d18 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 @@ -44,10 +44,10 @@ import kotlin.text.substring actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + disabledText: String?, sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, - userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, @@ -203,16 +203,16 @@ 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 (userIsObserver) { - ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) + ComposeOverlay(generalGetString(MR.strings.voice_message_send_text), textStyle, padding) + } else if (disabledText != null) { + ComposeOverlay(disabledText, textStyle, padding) } } @Composable -private fun ComposeOverlay(textId: StringResource, textStyle: MutableState, padding: PaddingValues) { +private fun ComposeOverlay(text: String, textStyle: MutableState, padding: PaddingValues) { Text( - generalGetString(textId), + text, Modifier.padding(padding), color = MaterialTheme.colors.secondary, style = textStyle.value.copy(fontStyle = FontStyle.Italic) 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 270/289] 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 271/289] 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 272/289] 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 273/289] 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 274/289] 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 275/289] 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 276/289] 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 277/289] 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 278/289] 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 279/289] 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 280/289] 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 281/289] 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 e8968653efa5eeaaa961e7ec02cd4ee93a904ce7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 23 May 2025 10:40:04 +0100 Subject: [PATCH 282/289] core: add custom chat event --- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/View.hs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 97bf26fd84..061fbfc8d6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -854,6 +854,7 @@ data ChatEvent | CEvtChatErrors {chatErrors :: [ChatError]} | CEvtTimedAction {action :: String, durationMilliseconds :: Int64} | CEvtTerminalEvent TerminalEvent + | CEvtCustomChatEvent {user_ :: Maybe User, response :: Text} deriving (Show) data TerminalEvent diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c13b164693..8a6e6037af 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -532,6 +532,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] TERcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CEvtCustomChatEvent u r -> ttyUser' u $ map plain $ T.lines r where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss From 6acd239339baf828e7e4fd111ae22850d1df9a21 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 23 May 2025 13:16:12 +0000 Subject: [PATCH 283/289] core: allow to add short link to existing contact link (#5940) --- apps/ios/Shared/Model/AppAPITypes.swift | 6 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 13 +++++ .../Views/Chat/Group/GroupLinkView.swift | 58 ++++++++++++++----- .../Views/UserSettings/UserAddressView.swift | 27 +++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 21 +++++++ .../common/views/chat/group/GroupLinkView.kt | 28 +++++++++ .../views/usersettings/UserAddressView.kt | 30 ++++++++++ .../commonMain/resources/MR/base/strings.xml | 1 + src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Library/Commands.hs | 28 ++++++++- src/Simplex/Chat/Store/Profiles.hs | 49 +++++++++------- .../SQLite/Migrations/chat_query_plans.txt | 26 ++++----- 12 files changed, 239 insertions(+), 50 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 2ddaf1d2af..9d359d9aa6 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -80,6 +80,7 @@ enum ChatCommand: ChatCmdProtocol { case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) + case apiAddShortLinkGroupLink(groupId: Int64) case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) case apiTestProtoServer(userId: Int64, server: String) @@ -134,6 +135,7 @@ enum ChatCommand: ChatCmdProtocol { case apiCreateMyAddress(userId: Int64, short: Bool) case apiDeleteMyAddress(userId: Int64) case apiShowMyAddress(userId: Int64) + case apiAddShortLinkMyAddress(userId: Int64) case apiSetProfileAddress(userId: Int64, on: Bool) case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) case apiAcceptContact(incognito: Bool, contactReqId: Int64) @@ -262,6 +264,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" + case let .apiAddShortLinkGroupLink(groupId): return "/_short link #\(groupId)" case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" @@ -326,6 +329,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))" case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" case let .apiShowMyAddress(userId): return "/_show_address \(userId)" + case let .apiAddShortLinkMyAddress(userId): return "/_short_link_address \(userId)" case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" @@ -438,6 +442,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" case .apiDeleteGroupLink: return "apiDeleteGroupLink" case .apiGetGroupLink: return "apiGetGroupLink" + case .apiAddShortLinkGroupLink: return "apiAddShortLinkGroupLink" case .apiCreateMemberContact: return "apiCreateMemberContact" case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" case .apiTestProtoServer: return "apiTestProtoServer" @@ -491,6 +496,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiCreateMyAddress: return "apiCreateMyAddress" case .apiDeleteMyAddress: return "apiDeleteMyAddress" case .apiShowMyAddress: return "apiShowMyAddress" + case .apiAddShortLinkMyAddress: return "apiAddShortLinkMyAddress" case .apiSetProfileAddress: return "apiSetProfileAddress" case .apiAddressAutoAccept: return "apiAddressAutoAccept" case .apiAcceptContact: return "apiAcceptContact" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6b938aaa4d..952702b8a8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1210,6 +1210,13 @@ private func userAddressResponse(_ r: APIResult) throws -> UserCo } } +func apiAddShortLinkMyAddress() async throws -> UserContactLink { + let userId = try currentUserId("apiAddShortLinkMyAddress") + let r: ChatResponse1 = try await chatSendCmd(.apiAddShortLinkMyAddress(userId: userId)) + if case let .userContactLink(_, contactLink) = r { return contactLink } + throw r.unexpected +} + func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? { let userId = try currentUserId("userAddressAutoAccept") let r: APIResult = await chatApiSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) @@ -1736,6 +1743,12 @@ func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRo } } +func apiAddShortLinkGroupLink(_ groupId: Int64) async throws -> (CreatedConnLink, GroupMemberRole) { + let r: ChatResponse2 = try await chatSendCmd(.apiAddShortLinkGroupLink(groupId: groupId)) + if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } + throw r.unexpected +} + func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact { let r: ChatResponse2 = try await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId)) if case let .newMemberContact(_, contact, _, _) = r { return contact } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index a11c073a42..bfc7c19fa7 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -35,16 +35,23 @@ struct GroupLinkView: View { } var body: some View { - if creatingGroup { - groupLinkView() - .navigationBarBackButtonHidden() - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button ("Continue") { linkCreatedCb?() } + ZStack { + if creatingGroup { + groupLinkView() + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } + } } - } - } else { - groupLinkView() + } else { + groupLinkView() + } + if creatingLink { + ProgressView() + .scaleEffect(2) + .frame(maxWidth: .infinity) + } } } @@ -79,6 +86,14 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } + if (groupLink.connShortLink == nil && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)) { + Button { + addShortLink() + } label: { + Label("Add short link", systemImage: "plus") + } + } + if !creatingGroup { Button(role: .destructive) { alert = .deleteLink } label: { Label("Delete link", systemImage: "trash") @@ -89,11 +104,6 @@ struct GroupLinkView: View { Label("Create link", systemImage: "link.badge.plus") } .disabled(creatingLink) - if creatingLink { - ProgressView() - .scaleEffect(2) - .frame(maxWidth: .infinity) - } } } header: { if let groupLink, groupLink.connShortLink != nil { @@ -160,6 +170,26 @@ struct GroupLinkView: View { } } } + + private func addShortLink() { + Task { + do { + creatingLink = true + let link = try await apiAddShortLinkGroupLink(groupId) + await MainActor.run { + creatingLink = false + (groupLink, groupLinkMemberRole) = link + } + } catch let error { + logger.error("apiAddShortLinkGroupLink: \(responseError(error))") + await MainActor.run { + creatingLink = false + let a = getErrorAlert(error, "Error adding short link") + alert = .error(title: a.title, error: a.message) + } + } + } + } } struct GroupLinkView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 4813edf96c..1140dcf0fc 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -153,6 +153,9 @@ struct UserAddressView: View { } } addressSettingsButton(userAddress) + if (userAddress.connLinkContact.connShortLink == nil && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)) { + addShortLinkButton() + } } header: { ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) } footer: { @@ -209,6 +212,30 @@ struct UserAddressView: View { } } + private func addShortLinkButton() -> some View { + Button { + addShortLink() + } label: { + Label("Add short link", systemImage: "plus") + } + } + + private func addShortLink() { + progressIndicator = true + Task { + do { + let userAddress = try await apiAddShortLinkMyAddress() + await MainActor.run { + chatModel.userAddress = userAddress + } + await MainActor.run { progressIndicator = false } + } catch let error { + logger.error("apiAddShortLinkMyAddress: \(responseError(error))") + await MainActor.run { progressIndicator = false } + } + } + } + private func createOneTimeLinkButton() -> some View { NavigationLink { NewChatView(selection: .invite) 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 7cb2d9fe5e..a334bb9a06 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 @@ -1570,6 +1570,14 @@ object ChatController { return null } + suspend fun apiAddShortLinkMyAddress(rh: Long?): UserContactLinkRec? { + val userId = kotlin.runCatching { currentUserId("apiAddShortLinkMyAddress") }.getOrElse { return null } + val r = sendCmd(rh, CC.ApiAddShortLinkMyAddress(userId)) + if (r is API.Result && r.res is CR.UserContactLink) return r.res.contactLink + Log.e(TAG, "apiAddShortLinkMyAddress bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun userAddressAutoAccept(rh: Long?, autoAccept: AutoAccept?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null } val r = sendCmd(rh, CC.ApiAddressAutoAccept(userId, autoAccept)) @@ -2016,6 +2024,13 @@ object ChatController { return null } + suspend fun apiAddShortLinkGroupLink(rh: Long?, groupId: Long): Pair? { + val r = sendCmd(rh, CC.APIAddShortLinkGroupLink(groupId)) + if (r is API.Result && r.res is CR.GroupLink) return r.res.connLinkContact to r.res.memberRole + Log.e(TAG, "apiAddShortLinkGroupLink bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiCreateMemberContact(rh: Long?, groupId: Long, groupMemberId: Long): Contact? { val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId)) if (r is API.Result && r.res is CR.NewMemberContact) return r.res.contact @@ -3368,6 +3383,7 @@ sealed class CC { class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC() class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() + class APIAddShortLinkGroupLink(val groupId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() class APITestProtoServer(val userId: Long, val server: String): CC() @@ -3422,6 +3438,7 @@ sealed class CC { class ApiCreateMyAddress(val userId: Long, val short: Boolean): CC() class ApiDeleteMyAddress(val userId: Long): CC() class ApiShowMyAddress(val userId: Long): CC() + class ApiAddShortLinkMyAddress(val userId: Long): CC() class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC() class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC() class ApiGetCallInvitations: CC() @@ -3555,6 +3572,7 @@ sealed class CC { is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" + is APIAddShortLinkGroupLink -> "/_short link #$groupId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" is APITestProtoServer -> "/_server test $userId $server" @@ -3609,6 +3627,7 @@ sealed class CC { is ApiCreateMyAddress -> "/_address $userId short=${onOff(short)}" is ApiDeleteMyAddress -> "/_delete_address $userId" is ApiShowMyAddress -> "/_show_address $userId" + is ApiAddShortLinkMyAddress -> "/_short_link_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}" is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId" @@ -3720,6 +3739,7 @@ sealed class CC { is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole" is APIDeleteGroupLink -> "apiDeleteGroupLink" is APIGetGroupLink -> "apiGetGroupLink" + is APIAddShortLinkGroupLink -> "apiAddShortLinkGroupLink" is APICreateMemberContact -> "apiCreateMemberContact" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APITestProtoServer -> "testProtoServer" @@ -3774,6 +3794,7 @@ sealed class CC { is ApiCreateMyAddress -> "apiCreateMyAddress" is ApiDeleteMyAddress -> "apiDeleteMyAddress" is ApiShowMyAddress -> "apiShowMyAddress" + is ApiAddShortLinkMyAddress -> "apiAddShortLinkMyAddress" is ApiSetProfileAddress -> "apiSetProfileAddress" is ApiAddressAutoAccept -> "apiAddressAutoAccept" is ApiAcceptContact -> "apiAcceptContact" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 6e1b9a731d..7e3fd839e2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -49,6 +49,18 @@ fun GroupLinkView( creatingLink = false } } + fun addShortLink() { + creatingLink = true + withBGApi { + val link = chatModel.controller.apiAddShortLinkGroupLink(rhId, groupInfo.groupId) + if (link != null) { + groupLink = link.first + groupLinkMemberRole.value = link.second + onGroupLinkUpdated?.invoke(link) + } + creatingLink = false + } + } LaunchedEffect(Unit) { if (groupLink == null && !creatingLink) { createLink() @@ -60,6 +72,7 @@ fun GroupLinkView( groupLinkMemberRole, creatingLink, createLink = ::createLink, + addShortLink = ::addShortLink, updateLink = { val role = groupLinkMemberRole.value if (role != null) { @@ -105,6 +118,7 @@ fun GroupLinkLayout( groupLinkMemberRole: MutableState, creatingLink: Boolean, createLink: () -> Unit, + addShortLink: () -> Unit, updateLink: () -> Unit, deleteLink: () -> Unit, creatingGroup: Boolean = false, @@ -182,12 +196,26 @@ fun GroupLinkLayout( ) } } + if (groupLink.connShortLink == null && appPreferences.privacyShortLinks.get()) { + AddShortLinkButton(addShortLink) + } } } SectionBottomSpacer() } } +@Composable +private fun AddShortLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.add_short_link), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState, enabled: Boolean = true) { Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 8c7c2d8416..53237f48ac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -43,6 +43,7 @@ fun UserAddressView( KeyChangeEffect(user.value?.remoteHostId, user.value?.userId) { close() } + fun setProfileAddress(on: Boolean) { progressIndicator = true withBGApi { @@ -81,6 +82,17 @@ fun UserAddressView( } } + fun addShortLink() { + withBGApi { + progressIndicator = true + val userAddress = chatModel.controller.apiAddShortLinkMyAddress(user.value?.remoteHostId) + if (userAddress != null) { + chatModel.userAddress.value = userAddress + } + progressIndicator = false + } + } + LaunchedEffect(autoCreateAddress) { if (chatModel.userAddress.value == null && autoCreateAddress) { createAddress() @@ -95,6 +107,7 @@ fun UserAddressView( userAddress = userAddress.value, shareViaProfile, createAddress = { createAddress() }, + addShortLink = { addShortLink() }, learnMore = { ModalManager.start.showModal { UserAddressLearnMore() @@ -169,6 +182,7 @@ private fun UserAddressLayout( userAddress: UserContactLinkRec?, shareViaProfile: MutableState, createAddress: () -> Unit, + addShortLink: () -> Unit, learnMore: () -> Unit, share: (String) -> Unit, sendEmail: (UserContactLinkRec) -> Unit, @@ -211,6 +225,9 @@ private fun UserAddressLayout( // ShareViaEmailButton { sendEmail(userAddress) } BusinessAddressToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAas) + if (userAddress.connLinkContact.connShortLink == null && appPreferences.privacyShortLinks.get()) { + AddShortLinkButton(addShortLink) + } if (autoAcceptState.value.business) { SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) @@ -248,6 +265,17 @@ private fun CreateAddressButton(onClick: () -> Unit) { ) } +@Composable +private fun AddShortLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.add_short_link), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable private fun CreateOneTimeLinkButton() { val closeAll = { ModalManager.start.closeModals() } @@ -559,6 +587,7 @@ fun PreviewUserAddressLayoutNoAddress() { user = User.sampleData, userAddress = null, createAddress = {}, + addShortLink = {}, share = { _ -> }, deleteAddress = {}, saveAas = { _, _ -> }, @@ -592,6 +621,7 @@ fun PreviewUserAddressLayoutAddressCreated() { user = User.sampleData, userAddress = UserContactLinkRec(CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null)), createAddress = {}, + addShortLink = {}, share = { _ -> }, deleteAddress = {}, saveAas = { _, _ -> }, 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 b5bf2efaff..7af75e64e8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1058,6 +1058,7 @@ Address settings Business address Add your team members to the conversations. + Add short link Continue diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 061fbfc8d6..5e15c6fb7a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -373,6 +373,7 @@ data ChatCommand | APIGroupLinkMemberRole GroupId GroupMemberRole | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId + | APIAddShortLinkGroupLink GroupId | APICreateMemberContact GroupId GroupMemberId | APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent} | GetUserProtoServers AProtocolType @@ -461,6 +462,7 @@ data ChatCommand | DeleteMyAddress | APIShowMyAddress UserId | ShowMyAddress + | APIAddShortLinkMyAddress UserId | APISetProfileAddress UserId Bool | SetProfileAddress Bool | APIAddressAutoAccept UserId (Maybe AutoAccept) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 20f9468cd6..a565d90696 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1796,9 +1796,9 @@ processChatCommand' vr = \case CreateMyAddress short -> withUser $ \User {userId} -> processChatCommand $ APICreateMyAddress userId short APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do - conns <- withFastStore $ \db -> getUserAddressConnections db vr user + conn <- withFastStore $ \db -> getUserAddressConnection db vr user withChatLock "deleteMyAddress" $ do - deleteAgentConnectionsAsync $ map aConnId conns + deleteAgentConnectionAsync $ aConnId conn withFastStore' (`deleteUserAddress` user) let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} r <- updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing @@ -1812,6 +1812,17 @@ processChatCommand' vr = \case CRUserContactLink user <$> withFastStore (`getUserAddress` user) ShowMyAddress -> withUser' $ \User {userId} -> processChatCommand $ APIShowMyAddress userId + APIAddShortLinkMyAddress userId -> withUserId' userId $ \user -> do + (ucl@UserContactLink {connLinkContact = CCLink connFullLink sLnk_}, conn) <- + withFastStore $ \db -> (,) <$> getUserAddress db user <*> getUserAddressConnection db vr user + when (isJust sLnk_) $ throwCmdError "address already has short link" + sLnk <- withAgent $ \a -> setContactShortLink a (aConnId conn) "" + case entityId conn of + Just uclId -> do + withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk + let ucl' = (ucl :: UserContactLink) {connLinkContact = CCLink connFullLink (Just sLnk)} + pure $ CRUserContactLink user ucl' + Nothing -> throwChatError $ CEException "no user contact link id" APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing @@ -2403,6 +2414,17 @@ processChatCommand' vr = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId (_, groupLink, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo groupLink mRole + APIAddShortLinkGroupLink groupId -> withUser $ \user -> do + (gInfo, (uclId, _gLink@(CCLink connFullLink sLnk_), mRole), conn) <- withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + gLink <- getGroupLink db user gInfo + conn <- getGroupLinkConnection db vr user gInfo + pure (gInfo, gLink, conn) + when (isJust sLnk_) $ throwCmdError "group link already has short link" + sLnk <- withAgent $ \a -> setContactShortLink a (aConnId conn) "" + withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk + let groupLink' = CCLink connFullLink (Just sLnk) + pure $ CRGroupLink user gInfo groupLink' mRole APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor @@ -4222,6 +4244,7 @@ chatCommandP = "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), + "/_short link #" *> (APIAddShortLinkGroupLink <$> A.decimal), "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember) <*> shortP), "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), "/delete link #" *> (DeleteGroupLink <$> displayNameP), @@ -4277,6 +4300,7 @@ chatCommandP = ("/delete_address" <|> "/da") $> DeleteMyAddress, "/_show_address " *> (APIShowMyAddress <$> A.decimal), ("/show_address" <|> "/sa") $> ShowMyAddress, + "/_short_link_address " *> (APIAddShortLinkMyAddress <$> A.decimal), "/_profile_address " *> (APISetProfileAddress <$> A.decimal <* A.space <*> onOffP), ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index a7dc154d9d..4986ed5140 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -43,7 +43,7 @@ module Simplex.Chat.Store.Profiles setUserProfileContactLink, getUserContactProfiles, createUserContactLink, - getUserAddressConnections, + getUserAddressConnection, getUserContactLinks, deleteUserAddress, getUserAddress, @@ -51,6 +51,7 @@ module Simplex.Chat.Store.Profiles getGroupLinkInfo, getUserContactLinkByConnReq, getUserContactLinkViaShortLink, + setUserContactLinkShortLink, getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, @@ -363,26 +364,21 @@ createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMo userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff -getUserAddressConnections :: DB.Connection -> VersionRangeChat -> User -> ExceptT StoreError IO [Connection] -getUserAddressConnections db vr User {userId} = do - cs <- liftIO getUserAddressConnections_ - if null cs then throwError SEUserContactLinkNotFound else pure cs - where - getUserAddressConnections_ :: IO [Connection] - getUserAddressConnections_ = - map (toConnection vr) - <$> DB.query - db - [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.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 connections c - JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id - WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL - |] - (userId, userId) +getUserAddressConnection :: DB.Connection -> VersionRangeChat -> User -> ExceptT StoreError IO Connection +getUserAddressConnection db vr User {userId} = do + ExceptT . firstRow (toConnection vr) SEUserContactLinkNotFound $ + DB.query + db + [sql| + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.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 connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + |] + (userId, userId) getUserContactLinks :: DB.Connection -> VersionRangeChat -> User -> IO [(Connection, UserContact)] getUserContactLinks db vr User {userId} = @@ -531,6 +527,17 @@ userContactLinkQuery = FROM user_contact_links |] +setUserContactLinkShortLink :: DB.Connection -> Int64 -> ShortLinkContact -> IO () +setUserContactLinkShortLink db userContactLinkId shortLink = + DB.execute + db + [sql| + UPDATE user_contact_links + SET short_link_contact = ? + WHERE user_contact_link_id = ? + |] + (shortLink, userContactLinkId) + getContactWithoutConnViaAddress :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do ctId_ <- 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 b0e98856d9..36833ddd3d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -501,19 +501,6 @@ Query: Plan: SEARCH messages USING INDEX idx_messages_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) -Query: - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.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 connections c - JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id - WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL - -Plan: -SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) -SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) - Query: SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items @@ -2823,6 +2810,19 @@ Plan: SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.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 connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.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, From b2bc4d6a5c364a2b11480444f79813ef9873ed2e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 24 May 2025 13:34:03 +0000 Subject: [PATCH 284/289] core: fix short link type when adding for group link (#5942) * core: fix short link type when adding for group link * shorten, rename * update setContactShortLink * update * fix * plans * simplexmq * ui: show error when setting short link --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/AppAPITypes.swift | 12 ++++---- apps/ios/Shared/Model/SimpleXAPI.swift | 10 +++---- .../Views/Chat/Group/GroupLinkView.swift | 4 +-- .../Views/UserSettings/UserAddressView.swift | 6 ++-- .../chat/simplex/common/model/SimpleXAPI.kt | 30 +++++++++++-------- .../common/views/chat/group/GroupLinkView.kt | 2 +- .../views/usersettings/UserAddressView.kt | 2 +- .../src/Directory/Service.hs | 4 +-- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 4 +-- src/Simplex/Chat/Library/Commands.hs | 30 ++++++++++--------- src/Simplex/Chat/Store/Groups.hs | 6 ++-- .../SQLite/Migrations/chat_query_plans.txt | 2 +- 14 files changed, 62 insertions(+), 54 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 9d359d9aa6..6c47d654e0 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -80,7 +80,7 @@ enum ChatCommand: ChatCmdProtocol { case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) - case apiAddShortLinkGroupLink(groupId: Int64) + case apiAddGroupShortLink(groupId: Int64) case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) case apiTestProtoServer(userId: Int64, server: String) @@ -135,7 +135,7 @@ enum ChatCommand: ChatCmdProtocol { case apiCreateMyAddress(userId: Int64, short: Bool) case apiDeleteMyAddress(userId: Int64) case apiShowMyAddress(userId: Int64) - case apiAddShortLinkMyAddress(userId: Int64) + case apiAddMyAddressShortLink(userId: Int64) case apiSetProfileAddress(userId: Int64, on: Bool) case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) case apiAcceptContact(incognito: Bool, contactReqId: Int64) @@ -264,7 +264,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" - case let .apiAddShortLinkGroupLink(groupId): return "/_short link #\(groupId)" + case let .apiAddGroupShortLink(groupId): return "/_short link #\(groupId)" case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" @@ -329,7 +329,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))" case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" case let .apiShowMyAddress(userId): return "/_show_address \(userId)" - case let .apiAddShortLinkMyAddress(userId): return "/_short_link_address \(userId)" + case let .apiAddMyAddressShortLink(userId): return "/_short_link_address \(userId)" case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" @@ -442,7 +442,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" case .apiDeleteGroupLink: return "apiDeleteGroupLink" case .apiGetGroupLink: return "apiGetGroupLink" - case .apiAddShortLinkGroupLink: return "apiAddShortLinkGroupLink" + case .apiAddGroupShortLink: return "apiAddGroupShortLink" case .apiCreateMemberContact: return "apiCreateMemberContact" case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" case .apiTestProtoServer: return "apiTestProtoServer" @@ -496,7 +496,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiCreateMyAddress: return "apiCreateMyAddress" case .apiDeleteMyAddress: return "apiDeleteMyAddress" case .apiShowMyAddress: return "apiShowMyAddress" - case .apiAddShortLinkMyAddress: return "apiAddShortLinkMyAddress" + case .apiAddMyAddressShortLink: return "apiAddMyAddressShortLink" case .apiSetProfileAddress: return "apiSetProfileAddress" case .apiAddressAutoAccept: return "apiAddressAutoAccept" case .apiAcceptContact: return "apiAcceptContact" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 952702b8a8..4f9d75bada 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1210,9 +1210,9 @@ private func userAddressResponse(_ r: APIResult) throws -> UserCo } } -func apiAddShortLinkMyAddress() async throws -> UserContactLink { - let userId = try currentUserId("apiAddShortLinkMyAddress") - let r: ChatResponse1 = try await chatSendCmd(.apiAddShortLinkMyAddress(userId: userId)) +func apiAddMyAddressShortLink() async throws -> UserContactLink { + let userId = try currentUserId("apiAddMyAddressShortLink") + let r: ChatResponse1 = try await chatSendCmd(.apiAddMyAddressShortLink(userId: userId)) if case let .userContactLink(_, contactLink) = r { return contactLink } throw r.unexpected } @@ -1743,8 +1743,8 @@ func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRo } } -func apiAddShortLinkGroupLink(_ groupId: Int64) async throws -> (CreatedConnLink, GroupMemberRole) { - let r: ChatResponse2 = try await chatSendCmd(.apiAddShortLinkGroupLink(groupId: groupId)) +func apiAddGroupShortLink(_ groupId: Int64) async throws -> (CreatedConnLink, GroupMemberRole) { + let r: ChatResponse2 = try await chatSendCmd(.apiAddGroupShortLink(groupId: groupId)) if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } throw r.unexpected } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index bfc7c19fa7..49e4d49e86 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -175,13 +175,13 @@ struct GroupLinkView: View { Task { do { creatingLink = true - let link = try await apiAddShortLinkGroupLink(groupId) + let link = try await apiAddGroupShortLink(groupId) await MainActor.run { creatingLink = false (groupLink, groupLinkMemberRole) = link } } catch let error { - logger.error("apiAddShortLinkGroupLink: \(responseError(error))") + logger.error("apiAddGroupShortLink: \(responseError(error))") await MainActor.run { creatingLink = false let a = getErrorAlert(error, "Error adding short link") diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 1140dcf0fc..f374995f78 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -224,13 +224,15 @@ struct UserAddressView: View { progressIndicator = true Task { do { - let userAddress = try await apiAddShortLinkMyAddress() + let userAddress = try await apiAddMyAddressShortLink() await MainActor.run { chatModel.userAddress = userAddress } await MainActor.run { progressIndicator = false } } catch let error { - logger.error("apiAddShortLinkMyAddress: \(responseError(error))") + logger.error("apiAddMyAddressShortLink: \(responseError(error))") + let a = getErrorAlert(error, "Error creating address") + alert = .error(title: a.title, error: a.message) await MainActor.run { progressIndicator = 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 a334bb9a06..0f65783152 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 @@ -1570,11 +1570,13 @@ object ChatController { return null } - suspend fun apiAddShortLinkMyAddress(rh: Long?): UserContactLinkRec? { - val userId = kotlin.runCatching { currentUserId("apiAddShortLinkMyAddress") }.getOrElse { return null } - val r = sendCmd(rh, CC.ApiAddShortLinkMyAddress(userId)) + suspend fun apiAddMyAddressShortLink(rh: Long?): UserContactLinkRec? { + val userId = kotlin.runCatching { currentUserId("apiAddMyAddressShortLink") }.getOrElse { return null } + val r = sendCmd(rh, CC.ApiAddMyAddressShortLink(userId)) if (r is API.Result && r.res is CR.UserContactLink) return r.res.contactLink - Log.e(TAG, "apiAddShortLinkMyAddress bad response: ${r.responseType} ${r.details}") + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAddMyAddressShortLink", generalGetString(MR.strings.error_creating_address), r) + } return null } @@ -2024,10 +2026,12 @@ object ChatController { return null } - suspend fun apiAddShortLinkGroupLink(rh: Long?, groupId: Long): Pair? { - val r = sendCmd(rh, CC.APIAddShortLinkGroupLink(groupId)) + suspend fun apiAddGroupShortLink(rh: Long?, groupId: Long): Pair? { + val r = sendCmd(rh, CC.ApiAddGroupShortLink(groupId)) if (r is API.Result && r.res is CR.GroupLink) return r.res.connLinkContact to r.res.memberRole - Log.e(TAG, "apiAddShortLinkGroupLink bad response: ${r.responseType} ${r.details}") + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAddGroupShortLink", generalGetString(MR.strings.error_creating_link_for_group), r) + } return null } @@ -3383,7 +3387,7 @@ sealed class CC { class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC() class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() - class APIAddShortLinkGroupLink(val groupId: Long): CC() + class ApiAddGroupShortLink(val groupId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() class APITestProtoServer(val userId: Long, val server: String): CC() @@ -3438,7 +3442,7 @@ sealed class CC { class ApiCreateMyAddress(val userId: Long, val short: Boolean): CC() class ApiDeleteMyAddress(val userId: Long): CC() class ApiShowMyAddress(val userId: Long): CC() - class ApiAddShortLinkMyAddress(val userId: Long): CC() + class ApiAddMyAddressShortLink(val userId: Long): CC() class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC() class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC() class ApiGetCallInvitations: CC() @@ -3572,7 +3576,7 @@ sealed class CC { is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" - is APIAddShortLinkGroupLink -> "/_short link #$groupId" + is ApiAddGroupShortLink -> "/_short link #$groupId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" is APITestProtoServer -> "/_server test $userId $server" @@ -3627,7 +3631,7 @@ sealed class CC { is ApiCreateMyAddress -> "/_address $userId short=${onOff(short)}" is ApiDeleteMyAddress -> "/_delete_address $userId" is ApiShowMyAddress -> "/_show_address $userId" - is ApiAddShortLinkMyAddress -> "/_short_link_address $userId" + is ApiAddMyAddressShortLink -> "/_short_link_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}" is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId" @@ -3739,7 +3743,7 @@ sealed class CC { is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole" is APIDeleteGroupLink -> "apiDeleteGroupLink" is APIGetGroupLink -> "apiGetGroupLink" - is APIAddShortLinkGroupLink -> "apiAddShortLinkGroupLink" + is ApiAddGroupShortLink -> "apiAddGroupShortLink" is APICreateMemberContact -> "apiCreateMemberContact" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APITestProtoServer -> "testProtoServer" @@ -3794,7 +3798,7 @@ sealed class CC { is ApiCreateMyAddress -> "apiCreateMyAddress" is ApiDeleteMyAddress -> "apiDeleteMyAddress" is ApiShowMyAddress -> "apiShowMyAddress" - is ApiAddShortLinkMyAddress -> "apiAddShortLinkMyAddress" + is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink" is ApiSetProfileAddress -> "apiSetProfileAddress" is ApiAddressAutoAccept -> "apiAddressAutoAccept" is ApiAcceptContact -> "apiAcceptContact" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 7e3fd839e2..30c16db6a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -52,7 +52,7 @@ fun GroupLinkView( fun addShortLink() { creatingLink = true withBGApi { - val link = chatModel.controller.apiAddShortLinkGroupLink(rhId, groupInfo.groupId) + val link = chatModel.controller.apiAddGroupShortLink(rhId, groupInfo.groupId) if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 53237f48ac..d77c4bc7f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -85,7 +85,7 @@ fun UserAddressView( fun addShortLink() { withBGApi { progressIndicator = true - val userAddress = chatModel.controller.apiAddShortLinkMyAddress(user.value?.remoteHostId) + val userAddress = chatModel.controller.apiAddMyAddressShortLink(user.value?.remoteHostId) if (userAddress != null) { chatModel.userAddress.value = userAddress } diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 4909189d40..13510b4d2b 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -713,7 +713,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName case mRole_ of Nothing -> getGroupLinkRole cc user g >>= \case - Just (_, CCLink gLink _, mRole) -> do + Just (_, CCLink gLink _, _, mRole) -> do let anotherRole = case mRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ initialRole n mRole @@ -1045,7 +1045,7 @@ vr :: ChatController -> VersionRangeChat vr ChatController {config = ChatConfig {chatVRange}} = chatVRange {-# INLINE vr #-} -getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, CreatedLinkContact, GroupMemberRole)) +getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, CreatedLinkContact, GroupLinkId, GroupMemberRole)) getGroupLinkRole cc user gInfo = withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo diff --git a/cabal.project b/cabal.project index 48b75a86cd..c067ba2be0 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: d352d518c2b3a42bc7a298954dde799422e1457f + tag: 18e73b8aa7996562f0f92db3967da02d960cb805 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 68a6054ef0..e25c99ebd1 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d352d518c2b3a42bc7a298954dde799422e1457f" = "1rha84pfpaqx3mf218szkfra334vhijqf17hanxqmp1sicfbf1x3"; + "https://github.com/simplex-chat/simplexmq.git"."18e73b8aa7996562f0f92db3967da02d960cb805" = "0sglm2i5gq34fmcm06q13452yapx7k40rc9b4q6qlfzjavq3jgld"; "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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5e15c6fb7a..3b59f56021 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -373,7 +373,7 @@ data ChatCommand | APIGroupLinkMemberRole GroupId GroupMemberRole | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId - | APIAddShortLinkGroupLink GroupId + | APIAddGroupShortLink GroupId | APICreateMemberContact GroupId GroupMemberId | APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent} | GetUserProtoServers AProtocolType @@ -462,7 +462,7 @@ data ChatCommand | DeleteMyAddress | APIShowMyAddress UserId | ShowMyAddress - | APIAddShortLinkMyAddress UserId + | APIAddMyAddressShortLink UserId | APISetProfileAddress UserId Bool | SetProfileAddress Bool | APIAddressAutoAccept UserId (Maybe AutoAccept) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index a565d90696..744bb2ba26 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1812,11 +1812,11 @@ processChatCommand' vr = \case CRUserContactLink user <$> withFastStore (`getUserAddress` user) ShowMyAddress -> withUser' $ \User {userId} -> processChatCommand $ APIShowMyAddress userId - APIAddShortLinkMyAddress userId -> withUserId' userId $ \user -> do + APIAddMyAddressShortLink userId -> withUserId' userId $ \user -> do (ucl@UserContactLink {connLinkContact = CCLink connFullLink sLnk_}, conn) <- withFastStore $ \db -> (,) <$> getUserAddress db user <*> getUserAddressConnection db vr user when (isJust sLnk_) $ throwCmdError "address already has short link" - sLnk <- withAgent $ \a -> setContactShortLink a (aConnId conn) "" + sLnk <- shortenShortLink' =<< withAgent (\a -> setContactShortLink a (aConnId conn) "" Nothing) case entityId conn of Just uclId -> do withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk @@ -2401,7 +2401,7 @@ processChatCommand' vr = \case pure $ CRGroupLinkCreated user gInfo ccLink' mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withGroupLock "groupLinkMemberRole" groupId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId - (groupLinkId, groupLink, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo + (groupLinkId, groupLink, _, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo assertUserGroupRole gInfo GRAdmin when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' when (mRole' /= mRole) $ withFastStore' $ \db -> setGroupLinkMemberRole db user groupLinkId mRole' @@ -2412,16 +2412,17 @@ processChatCommand' vr = \case pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId - (_, groupLink, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo + (_, groupLink, _, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo groupLink mRole - APIAddShortLinkGroupLink groupId -> withUser $ \user -> do - (gInfo, (uclId, _gLink@(CCLink connFullLink sLnk_), mRole), conn) <- withFastStore $ \db -> do + APIAddGroupShortLink groupId -> withUser $ \user -> do + (gInfo, (uclId, _gLink@(CCLink connFullLink sLnk_), gLinkId, mRole), conn) <- withFastStore $ \db -> do gInfo <- getGroupInfo db vr user groupId gLink <- getGroupLink db user gInfo conn <- getGroupLinkConnection db vr user gInfo pure (gInfo, gLink, conn) when (isJust sLnk_) $ throwCmdError "group link already has short link" - sLnk <- withAgent $ \a -> setContactShortLink a (aConnId conn) "" + let crClientData = encodeJSON $ CRDataGroup gLinkId + sLnk <- shortenShortLink' =<< toShortGroupLink <$> withAgent (\a -> setContactShortLink a (aConnId conn) "" (Just crClientData)) withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk let groupLink' = CCLink connFullLink (Just sLnk) pure $ CRGroupLink user gInfo groupLink' mRole @@ -3295,13 +3296,14 @@ processChatCommand' vr = \case CSLContact _ ct srv linkKey -> CSLContact SLSServer ct srv linkKey restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) shortLinkUserData short = if short then Just "" else Nothing + shortenShortLink' :: ConnShortLink m -> CM (ConnShortLink m) + shortenShortLink' l = (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config) shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) - shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM (\l -> (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config)) sLnk + shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' sLnk createdGroupLink :: CreatedLinkContact -> CreatedLinkContact - createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toGroupLink <$> shortLink) - where - toGroupLink :: ShortLinkContact -> ShortLinkContact - toGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k + createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> shortLink) + toShortGroupLink :: ShortLinkContact -> ShortLinkContact + toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId @@ -4244,7 +4246,7 @@ chatCommandP = "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), - "/_short link #" *> (APIAddShortLinkGroupLink <$> A.decimal), + "/_short link #" *> (APIAddGroupShortLink <$> A.decimal), "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember) <*> shortP), "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), "/delete link #" *> (DeleteGroupLink <$> displayNameP), @@ -4300,7 +4302,7 @@ chatCommandP = ("/delete_address" <|> "/da") $> DeleteMyAddress, "/_show_address " *> (APIShowMyAddress <$> A.decimal), ("/show_address" <|> "/sa") $> ShowMyAddress, - "/_short_link_address " *> (APIAddShortLinkMyAddress <$> A.decimal), + "/_short_link_address " *> (APIAddMyAddressShortLink <$> A.decimal), "/_profile_address " *> (APISetProfileAddress <$> A.decimal <* A.space <*> onOffP), ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 6c66ea0e64..f3661699cb 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -256,12 +256,12 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) -getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, CreatedLinkContact, GroupMemberRole) +getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, CreatedLinkContact, GroupLinkId, GroupMemberRole) getGroupLink db User {userId} gInfo@GroupInfo {groupId} = ExceptT . firstRow groupLink (SEGroupLinkNotFound gInfo) $ - DB.query db "SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) + DB.query db "SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) where - groupLink (linkId, cReq, shortLink, mRole_) = (linkId, CCLink cReq shortLink, fromMaybe GRMember mRole_) + groupLink (linkId, cReq, shortLink, gLinkId, mRole_) = (linkId, CCLink cReq shortLink, gLinkId, fromMaybe GRMember mRole_) getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId) getGroupLinkId db User {userId} GroupInfo {groupId} = 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 36833ddd3d..ea91bced4d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5794,7 +5794,7 @@ Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_i Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 Plan: SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) From 96dcf16cc3b13301f06f734678f0a534f0146433 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 24 May 2025 16:49:45 +0100 Subject: [PATCH 285/289] core: update simplexmq (#5941) * core: update simplexmq * update types --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 6 +++--- src/Simplex/Chat/Remote.hs | 6 +++--- src/Simplex/Chat/Remote/RevHTTP.hs | 6 +++--- src/Simplex/Chat/Remote/Types.hs | 10 +++++----- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cabal.project b/cabal.project index c067ba2be0..c6abdfbbde 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: 18e73b8aa7996562f0f92db3967da02d960cb805 + tag: ffecd4a17af68677dedf05c95a80dc0f5c584236 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index e25c99ebd1..954f2f0341 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."18e73b8aa7996562f0f92db3967da02d960cb805" = "0sglm2i5gq34fmcm06q13452yapx7k40rc9b4q6qlfzjavq3jgld"; + "https://github.com/simplex-chat/simplexmq.git"."ffecd4a17af68677dedf05c95a80dc0f5c584236" = "09c4yjn1329844f7dxw5fklxxh6jmn8d5g72mw113bs99sp9mcf7"; "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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3b59f56021..016a7f3dd2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -87,7 +87,7 @@ import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Transport (TLS, simplexMQVersion) +import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, catchAllErrors', tryAllErrors, tryAllErrors', (<$$>)) import Simplex.RemoteControl.Client @@ -1415,7 +1415,7 @@ data RemoteCtrlSession { remoteCtrlId_ :: Maybe RemoteCtrlId, ctrlDeviceName :: Text, rcsClient :: RCCtrlClient, - tls :: TLS, + tls :: TLS 'TClient, sessionCode :: Text, rcsWaitSession :: Async (), rcsWaitConfirmation :: TMVar (Either RCErrorType (RCCtrlSession, RCCtrlPairing)) @@ -1423,7 +1423,7 @@ data RemoteCtrlSession | RCSessionConnected { remoteCtrlId :: RemoteCtrlId, rcsClient :: RCCtrlClient, - tls :: TLS, + tls :: TLS 'TClient, rcsSession :: RCCtrlSession, http2Server :: Async (), remoteOutputQ :: TBQueue (Either ChatError ChatEvent) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index f6c94badfe..92b499d8ef 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -58,7 +58,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) +import Simplex.Messaging.Transport (TLS, TransportPeer (..), closeConnection, tlsUniq) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util @@ -184,7 +184,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) - waitForHostSession :: Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> CM () + waitForHostSession :: Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS 'TServer, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> CM () waitForHostSession remoteHost_ rhKey sseq rcAddr_ rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId @@ -474,7 +474,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) where validateRemoteCtrl RCInvitation {idkey} RemoteCtrl {ctrlPairing = RCCtrlPairing {idPubKey}} = unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity - waitForCtrlSession :: Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> CM () + waitForCtrlSession :: Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS 'TClient, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> CM () waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs index 4df5bcac2a..20484c0b10 100644 --- a/src/Simplex/Chat/Remote/RevHTTP.hs +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -8,18 +8,18 @@ module Simplex.Chat.Remote.RevHTTP where -import Simplex.Messaging.Transport (TLS) +import Simplex.Messaging.Transport (TLS, TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.RemoteControl.Discovery -attachRevHTTP2Client :: IO () -> TLS -> IO (Either HTTP2ClientError HTTP2Client) +attachRevHTTP2Client :: IO () -> TLS 'TServer -> IO (Either HTTP2ClientError HTTP2Client) attachRevHTTP2Client disconnected = attachHTTP2Client config ANY_ADDR_V4 "0" disconnected defaultHTTP2BufferSize where config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} -attachHTTP2Server :: TLS -> (HTTP2Request -> IO ()) -> IO () +attachHTTP2Server :: TLS 'TClient -> (HTTP2Request -> IO ()) -> IO () attachHTTP2Server tls processRequest = runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId sessionALPN r sendResponse -> do reqBody <- getHTTP2Body r doNotPrefetchHead diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index a5396e7945..defbe7e72c 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -25,7 +25,7 @@ import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) -import Simplex.Messaging.Transport (TLS (..), TSbChainKeys (..)) +import Simplex.Messaging.Transport (TLS (..), TSbChainKeys (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.TMap as TM import Simplex.RemoteControl.Client @@ -102,11 +102,11 @@ data RHPendingSession = RHPendingSession data RemoteHostSession = RHSessionStarting | RHSessionConnecting {invitation :: Text, rhPendingSession :: RHPendingSession} - | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession} - | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} + | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS 'TServer, rhPendingSession :: RHPendingSession} + | RHSessionConfirmed {tls :: TLS 'TServer, rhPendingSession :: RHPendingSession} | RHSessionConnected { rchClient :: RCHostClient, - tls :: TLS, + tls :: TLS 'TServer, rhClient :: RemoteHostClient, pollAction :: Async (), storePath :: FilePath @@ -128,7 +128,7 @@ rhsSessionState = \case RHSessionConfirmed {tls} -> RHSConfirmed {sessionCode = tlsSessionCode tls} RHSessionConnected {tls} -> RHSConnected {sessionCode = tlsSessionCode tls} -tlsSessionCode :: TLS -> Text +tlsSessionCode :: TLS p -> Text tlsSessionCode = verificationCode . tlsUniq data RemoteProtocolError From ee2ea152dc95053bdbb966536da430555a993663 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 24 May 2025 21:13:10 +0100 Subject: [PATCH 286/289] core: refactor types for DB entity (#5945) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 5 ++- src/Simplex/Chat/Operators.hs | 63 +-------------------------- src/Simplex/Chat/Operators/Presets.hs | 1 + src/Simplex/Chat/Store/Profiles.hs | 5 ++- src/Simplex/Chat/Store/Remote.hs | 5 ++- src/Simplex/Chat/View.hs | 1 + tests/OperatorTests.hs | 1 + 11 files changed, 18 insertions(+), 70 deletions(-) diff --git a/cabal.project b/cabal.project index c6abdfbbde..012eb62618 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: ffecd4a17af68677dedf05c95a80dc0f5c584236 + tag: 56ea2fdd56af5f5a5da41642486aa086d7371823 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 954f2f0341..fa637db5bc 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ffecd4a17af68677dedf05c95a80dc0f5c584236" = "09c4yjn1329844f7dxw5fklxxh6jmn8d5g72mw113bs99sp9mcf7"; + "https://github.com/simplex-chat/simplexmq.git"."56ea2fdd56af5f5a5da41642486aa086d7371823" = "1ninimiccsk0ba4wls2i9rqxj6g057m2k30zw19jvzma4xbalzg8"; "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 553f2ec6cd..d35e76f344 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -256,6 +256,7 @@ library , constraints >=0.12 && <0.14 , containers ==0.6.* , crypton ==0.34.* + , crypton-x509 ==1.7.* , data-default ==0.7.* , directory ==1.3.* , email-validate ==2.3.* @@ -271,7 +272,6 @@ library , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* - , scientific ==0.3.7.* , simple-logger ==0.1.* , simplexmq >=6.3 , socks ==0.6.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6b554d29c4..e14275b75c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -44,6 +44,7 @@ import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 744bb2ba26..1653c33aaa 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -88,6 +88,7 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, m import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -197,7 +198,7 @@ startChatController mainApp enableSndFiles = do startExpireCIThread user setExpireCIFlag user True where - shouldExpireChats = + shouldExpireChats = fmap (fromRight False) $ runExceptT $ withStore' $ \db -> do ttl <- getChatItemTTL db user ttlCount <- getChatTTLCount db user @@ -3683,7 +3684,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' interval setChatItemsExpiration :: User -> Int64 -> Int -> CM' () -setChatItemsExpiration user newTTL ttlCount +setChatItemsExpiration user newTTL ttlCount | newTTL > 0 || ttlCount > 0 = do startExpireCIThread user whenM chatStarted $ setExpireCIFlag user True diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 8c4490a2c4..c9b5e020d9 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -37,7 +37,6 @@ import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isNothing, mapMaybe) -import Data.Scientific (floatingOrInteger) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) @@ -46,11 +45,11 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions -import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (User) import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) -import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) @@ -69,32 +68,6 @@ usageConditionsText = in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|] ) -data DBStored = DBStored | DBNew - -data SDBStored (s :: DBStored) where - SDBStored :: SDBStored 'DBStored - SDBNew :: SDBStored 'DBNew - -deriving instance Show (SDBStored s) - -class DBStoredI s where sdbStored :: SDBStored s - -instance DBStoredI 'DBStored where sdbStored = SDBStored - -instance DBStoredI 'DBNew where sdbStored = SDBNew - -data DBEntityId' (s :: DBStored) where - DBEntityId :: Int64 -> DBEntityId' 'DBStored - DBNewEntity :: DBEntityId' 'DBNew - -deriving instance Show (DBEntityId' s) - -deriving instance Eq (DBEntityId' s) - -type DBEntityId = DBEntityId' 'DBStored - -type DBNewEntity = DBEntityId' 'DBNew - data OperatorTag = OTSimplex | OTFlux deriving (Eq, Ord, Show) @@ -118,19 +91,6 @@ instance TextEncoding OperatorTag where OTSimplex -> "simplex" OTFlux -> "flux" --- this and other types only define instances of serialization for known DB IDs only, --- entities without IDs cannot be serialized to JSON -instance FromField DBEntityId -#if defined(dbPostgres) - where - fromField f dat = DBEntityId <$> fromField f dat -#else - where - fromField f = DBEntityId <$> fromField f -#endif - -instance ToField DBEntityId where toField (DBEntityId i) = toField i - data UsageConditions = UsageConditions { conditionsId :: Int64, conditionsCommit :: Text, @@ -486,25 +446,6 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] userServers p = map aUserServer' . concatMap (servers' p) -instance ToJSON (DBEntityId' s) where - toEncoding = \case - DBEntityId i -> toEncoding i - DBNewEntity -> JE.null_ - toJSON = \case - DBEntityId i -> toJSON i - DBNewEntity -> J.Null - -instance DBStoredI s => FromJSON (DBEntityId' s) where - parseJSON v = case (v, sdbStored @s) of - (J.Null, SDBNew) -> pure DBNewEntity - (J.Number n, SDBStored) -> case floatingOrInteger n of - Left (_ :: Double) -> fail "bad DBEntityId" - Right i -> pure $ DBEntityId (fromInteger i) - _ -> fail "bad DBEntityId" - omittedField = case sdbStored @s of - SDBStored -> Nothing - SDBNew -> Just DBNewEntity - $(JQ.deriveJSON defaultJSON ''UsageConditions) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index 06c2e19fab..18a0b7ebed 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -9,6 +9,7 @@ import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Simplex.Chat.Operators import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Protocol (ProtocolType (..), SMPServer) operatorSimpleXChat :: NewServerOperator diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 4986ed5140..38b2615686 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -111,6 +111,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode) +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) #if defined(dbPostgres) @@ -500,14 +501,14 @@ toGroupLinkInfo (groupId_, mRole_) = getGroupLinkInfo :: DB.Connection -> UserId -> GroupId -> IO (Maybe GroupLinkInfo) getGroupLinkInfo db userId groupId = - fmap join $ maybeFirstRow toGroupLinkInfo $ + fmap join $ maybeFirstRow toGroupLinkInfo $ DB.query db [sql| SELECT group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? - |] + |] (userId, groupId) getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 4921369b10..cc626ddcf0 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -10,6 +10,7 @@ import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeASCII, encodeUtf8) +import qualified Data.X509 as X import Data.Word (Word16) import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Shared @@ -66,7 +67,7 @@ remoteHostQuery = FROM remote_hosts |] -toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519, Maybe Text, Maybe Text, Maybe Word16) -> RemoteHost +toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject X.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519, Maybe Text, Maybe Text, Maybe Word16) -> RemoteHost toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey, ifaceName_, ifaceAddr_, bindPort_) = RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing, bindAddress_, bindPort_} where @@ -133,7 +134,7 @@ toRemoteCtrl :: ( RemoteCtrlId, Text, C.APrivateSignKey, - C.SignedObject C.Certificate, + C.SignedObject X.Certificate, C.KeyHash, C.PublicKeyEd25519, C.PrivateKeyX25519, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8a6e6037af..54e888ea86 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -60,6 +60,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index dbfde6a03d..656f0ae0e2 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -23,6 +23,7 @@ import Simplex.Chat.Operators.Presets import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Protocol import Test.Hspec From b848f735ce4e8e48c4fc0ddd4a47f2d3bae80ab8 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 25 May 2025 11:56:00 +0100 Subject: [PATCH 287/289] ui: smaller QR code for short links (#5946) * ui: smaller QR code for short links * more small * size * translations --- apps/ios/Shared/Views/NewChat/QRCode.swift | 35 ++++++++++++------- .../NetworkAndServers/NewServerView.swift | 2 +- .../ProtocolServerView.swift | 2 +- apps/ios/ru.lproj/Localizable.strings | 4 +-- .../simplex/common/platform/Images.android.kt | 6 ++-- .../chat/simplex/common/platform/Images.kt | 2 +- .../simplex/common/views/newchat/QRCode.kt | 18 ++++++---- .../networkAndServers/ProtocolServerView.kt | 2 +- .../simplex/common/platform/Images.desktop.kt | 6 ++-- 9 files changed, 46 insertions(+), 31 deletions(-) diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index 453149198b..c9054f30da 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -12,11 +12,12 @@ import SimpleXChat struct MutableQRCode: View { @Binding var uri: String + var small: Bool = false var withLogo: Bool = true var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var body: some View { - QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor) + QRCode(uri: uri, small: small, withLogo: withLogo, tintColor: tintColor) .id("simplex-qrcode-view-for-\(uri)") } } @@ -27,7 +28,7 @@ struct SimpleXCreatedLinkQRCode: View { var onShare: (() -> Void)? = nil var body: some View { - QRCode(uri: link.simplexChatUri(short: short), onShare: onShare) + QRCode(uri: link.simplexChatUri(short: short), small: short && link.connShortLink != nil, onShare: onShare) } } @@ -38,50 +39,57 @@ struct SimpleXLinkQRCode: View { var onShare: (() -> Void)? = nil var body: some View { - QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor, onShare: onShare) + QRCode(uri: simplexChatLink(uri), small: uri.count < 200, withLogo: withLogo, tintColor: tintColor, onShare: onShare) } } +private let smallQRRatio: CGFloat = 0.63 + struct QRCode: View { let uri: String + var small: Bool = false var withLogo: Bool = true var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var onShare: (() -> Void)? = nil @State private var image: UIImage? = nil @State private var makeScreenshotFunc: () -> Void = {} + @State private var width: CGFloat = .infinity var body: some View { ZStack { if let image = image { - qrCodeImage(image) - GeometryReader { geo in + qrCodeImage(image).frame(width: width, height: width) + GeometryReader { g in + let w = g.size.width * (small ? smallQRRatio : 1) + let l = w * (small ? 0.195 : 0.16) + let m = w * 0.005 ZStack { if withLogo { - let w = geo.size.width Image("icon-light") .resizable() .scaledToFit() - .frame(width: w * 0.16, height: w * 0.16) - .frame(width: w * 0.165, height: w * 0.165) + .frame(width: l, height: l) + .frame(width: l + m, height: l + m) .background(.white) .clipShape(Circle()) } } .onAppear { + width = w makeScreenshotFunc = { let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale) - showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)]) + showShareSheet(items: [makeScreenshot(g.frame(in: .local).origin, size)]) onShare?() } } - .frame(width: geo.size.width, height: geo.size.height) + .frame(width: g.size.width, height: g.size.height) } } else { - Color.clear.aspectRatio(1, contentMode: .fit) + Color.clear.aspectRatio(small ? 1 / smallQRRatio : 1, contentMode: .fit) } } .onTapGesture(perform: makeScreenshotFunc) - .task { image = await generateImage(uri, tintColor: tintColor) } + .task { image = await generateImage(uri, tintColor: tintColor, errorLevel: small ? "M" : "L") } .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -94,10 +102,11 @@ private func qrCodeImage(_ image: UIImage) -> some View { .textSelection(.enabled) } -private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? { +private func generateImage(_ uri: String, tintColor: UIColor, errorLevel: String) async -> UIImage? { let context = CIContext() let filter = CIFilter.qrCodeGenerator() filter.message = Data(uri.utf8) + filter.correctionLevel = errorLevel if let outputImage = filter.outputImage, let cgImage = context.createCGImage(outputImage, from: outputImage.extent) { return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index 17a0ffdd1c..c8cb2349e7 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -65,7 +65,7 @@ struct NewServerView: View { useServerSection(valid) if valid { Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) { - MutableQRCode(uri: $serverToEdit.server) + MutableQRCode(uri: $serverToEdit.server, small: true) .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 13d01874ed..97bfd360cb 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -110,7 +110,7 @@ struct ProtocolServerView: View { useServerSection(valid) if valid { Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) { - MutableQRCode(uri: $serverToEdit.server) + MutableQRCode(uri: $serverToEdit.server, small: true) .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) } } diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 759a4c79f4..a6025879eb 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -3797,7 +3797,7 @@ alert button */ "Or securely share this file link" = "Или передайте эту ссылку"; /* No comment provided by engineer. */ -"Or show this code" = "Или покажите этот код"; +"Or show this code" = "Или покажите код"; /* No comment provided by engineer. */ "Or to share privately" = "Или поделиться конфиденциально"; @@ -4844,7 +4844,7 @@ chat item action */ "Share SimpleX address on social media." = "Поделитесь SimpleX адресом в социальных сетях."; /* No comment provided by engineer. */ -"Share this 1-time invite link" = "Поделиться одноразовой ссылкой-приглашением"; +"Share this 1-time invite link" = "Поделитесь одноразовой ссылкой"; /* No comment provided by engineer. */ "Share to SimpleX" = "Поделиться в SimpleX"; diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index fc323f6ffd..4f47fda130 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -101,13 +101,13 @@ actual fun GrayU8.toImageBitmap(): ImageBitmap = ConvertBitmap.grayToBitmap(this actual fun ImageBitmap.hasAlpha(): Boolean = hasAlpha -actual fun ImageBitmap.addLogo(): ImageBitmap = asAndroidBitmap().applyCanvas { - val radius = (width * 0.16f) / 2 +actual fun ImageBitmap.addLogo(size: Float): ImageBitmap = asAndroidBitmap().applyCanvas { + val radius = (width * size) / 2 val paint = android.graphics.Paint() paint.color = android.graphics.Color.WHITE drawCircle(width / 2f, height / 2f, radius, paint) val logo = androidAppContext.resources.getDrawable(R.drawable.icon_foreground_android_common, null).toBitmap() - val logoSize = (width * 0.24).toInt() + val logoSize = (width * size * 1.5).toInt() translate((width - logoSize) / 2f, (height - logoSize) / 2f) drawBitmap(logo, null, android.graphics.Rect(0, 0, logoSize, logoSize), null) }.asImageBitmap() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index fca69d5398..19e40ab0a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -16,7 +16,7 @@ expect fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOut expect fun GrayU8.toImageBitmap(): ImageBitmap expect fun ImageBitmap.hasAlpha(): Boolean -expect fun ImageBitmap.addLogo(): ImageBitmap +expect fun ImageBitmap.addLogo(size: Float): ImageBitmap expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap expect fun isImage(uri: URI): Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index bacb5ab802..0ed32f845b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -31,6 +31,7 @@ fun SimpleXCreatedLinkQRCode( ) { QRCode( connLink.simplexChatUri(short), + small = short && connLink.connShortLink != null, modifier, padding, tintColor, @@ -50,6 +51,7 @@ fun SimpleXLinkQRCode( ) { QRCode( simplexChatLink(connReq), + small = connReq.count() < 200, modifier, padding, tintColor, @@ -61,6 +63,7 @@ fun SimpleXLinkQRCode( @Composable fun QRCode( connReq: String, + small: Boolean = false, modifier: Modifier = Modifier, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING * 2f, vertical = DEFAULT_PADDING_HALF), tintColor: Color = Color(0xff062d56), @@ -68,9 +71,11 @@ fun QRCode( onShare: (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() + val logoSize = if (small) 0.21f else 0.16f + val errorLevel = if (small) QrCode.ErrorLevel.M else QrCode.ErrorLevel.L val qr = remember(connReq, tintColor, withLogo) { - qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) - .let { if (withLogo) it.addLogo() else it } + qrCodeBitmap(connReq, 1024, errorLevel).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) + .let { if (withLogo) it.addLogo(logoSize) else it } } Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Image( @@ -79,12 +84,13 @@ fun QRCode( Modifier .padding(padding) .widthIn(max = 400.dp) + .fillMaxWidth(if (small) 0.67f else 1f) .aspectRatio(1f) .then(modifier) .clickable { scope.launch { - val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) - .let { if (withLogo) it.addLogo() else it } + val image = qrCodeBitmap(connReq, 1024, errorLevel).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) + .let { if (withLogo) it.addLogo(logoSize) else it } val file = saveTempImageUncompressed(image, true) if (file != null) { shareFile("", CryptoFile.plain(file.absolutePath)) @@ -96,8 +102,8 @@ fun QRCode( } } -fun qrCodeBitmap(content: String, size: Int = 1024): ImageBitmap { - val qrCode = QrCodeEncoder().addAutomatic(content).setError(QrCode.ErrorLevel.L).fixate() +fun qrCodeBitmap(content: String, size: Int = 1024, errorLevel: QrCode.ErrorLevel): ImageBitmap { + val qrCode = QrCodeEncoder().addAutomatic(content).setError(errorLevel).fixate() /** See [QrCodeGeneratorImage.initialize] and [FiducialImageEngine.configure] for size calculation */ val numModules = QrCode.totalModules(qrCode.version) // Hide border on light themes to make it fit to the same place as camera in QRCodeScanner. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index bebc96a28c..8626fd3143 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -189,7 +189,7 @@ fun CustomServer( if (valid.value) { SectionDividerSpaced() SectionView(stringResource(MR.strings.smp_servers_add_to_another_device).uppercase()) { - QRCode(serverAddress.value) + QRCode(serverAddress.value, small = true) } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 53f3301507..d0ba082adf 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -133,9 +133,9 @@ actual fun ImageBitmap.hasAlpha(): Boolean { return false } -actual fun ImageBitmap.addLogo(): ImageBitmap { - val radius = (width * 0.16f).toInt() - val logoSize = (width * 0.24).toInt() +actual fun ImageBitmap.addLogo(size: Float): ImageBitmap { + val radius = (width * size).toInt() + val logoSize = (width * size * 1.5).toInt() val logo: BufferedImage = MR.images.icon_foreground_common.image val original = toAwtImage() val withLogo = BufferedImage(width, height, original.type) From cbaab06975152f432225f26e6bcecfcfc4317cc6 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 26 May 2025 08:10:13 +0000 Subject: [PATCH 288/289] scripts/prepare-vlc: switch to using simplex vlc builds (#5939) --- scripts/desktop/prepare-vlc-linux.sh | 4 +++- scripts/desktop/prepare-vlc-mac.sh | 6 ++++-- scripts/desktop/prepare-vlc-windows.sh | 5 ++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/desktop/prepare-vlc-linux.sh b/scripts/desktop/prepare-vlc-linux.sh index dae1c9255d..a798fd6fc9 100755 --- a/scripts/desktop/prepare-vlc-linux.sh +++ b/scripts/desktop/prepare-vlc-linux.sh @@ -10,11 +10,13 @@ vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linu mkdir $vlc_dir || exit 0 +vlc_tag='v3.0.21-1' +vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-linux-x86_64.appimage" cd /tmp mkdir tmp 2>/dev/null || true cd tmp -curl --tlsv1.2 https://github.com/cmatomic/VLCplayer-AppImage/releases/download/3.0.11.1/VLC_media_player-3.0.11.1-x86_64.AppImage -L -o appimage +curl --tlsv1.2 "${vlc_url}" -L -o appimage chmod +x appimage ./appimage --appimage-extract cp -r squashfs-root/usr/lib/* $vlc_dir diff --git a/scripts/desktop/prepare-vlc-mac.sh b/scripts/desktop/prepare-vlc-mac.sh index 288b5a1a7d..4db2983f67 100755 --- a/scripts/desktop/prepare-vlc-mac.sh +++ b/scripts/desktop/prepare-vlc-mac.sh @@ -9,7 +9,9 @@ if [ "$ARCH" == "arm64" ]; then else vlc_arch=intel64 fi -vlc_version=3.0.19 + +vlc_tag='v3.0.21-1' +vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-macos-${ARCH}.zip" function readlink() { echo "$(cd "$(dirname "$1")"; pwd -P)" @@ -23,7 +25,7 @@ mkdir -p $vlc_dir/vlc || exit 0 cd /tmp mkdir tmp 2>/dev/null || true cd tmp -curl --tlsv1.2 https://github.com/simplex-chat/vlc/releases/download/v$vlc_version/vlc-macos-$ARCH.zip -L -o vlc +curl --tlsv1.2 "${vlc_url}" -L -o vlc unzip -oqq vlc install_name_tool -add_rpath "@loader_path/VLC.app/Contents/MacOS/lib" vlc-cache-gen cd VLC.app/Contents/MacOS/lib diff --git a/scripts/desktop/prepare-vlc-windows.sh b/scripts/desktop/prepare-vlc-windows.sh index 7d2a71a952..4e65528ca0 100644 --- a/scripts/desktop/prepare-vlc-windows.sh +++ b/scripts/desktop/prepare-vlc-windows.sh @@ -10,10 +10,13 @@ vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/wind rm -rf $vlc_dir mkdir -p $vlc_dir/vlc || exit 0 +vlc_tag='v3.0.21-1' +vlc_url="https://github.com/simplex-chat/vlc/releases/download/${vlc_tag}/vlc-win-x86_64.zip" + cd /tmp mkdir tmp 2>/dev/null || true cd tmp -curl --tlsv1.2 https://irltoolkit.mm.fcix.net/videolan-ftp/vlc/3.0.18/win64/vlc-3.0.18-win64.zip -L -o vlc +curl --tlsv1.2 "${vlc_url}" -L -o vlc $WINDIR\\System32\\tar.exe -xf vlc cd vlc-* # Setting the same date as the date that will be on the file after extraction from JAR to make VLC cache checker happy From 686145ba3681cf08092e61dbeac900b7a3f5f99b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 26 May 2025 16:57:18 +0100 Subject: [PATCH 289/289] ui: smaller QR code for verify code view, change iOS layout (#5948) * ui: smaller QR code for verify code view, change iOS layout * ios: fix layout for editing group profile --- .../Views/Chat/Group/GroupChatInfoView.swift | 3 - .../Views/Chat/Group/GroupProfileView.swift | 100 ++++++++------- .../Shared/Views/Chat/VerifyCodeView.swift | 119 ++++++++---------- .../Shared/Views/Database/DatabaseView.swift | 16 +-- .../Views/NewChat/NewChatMenuButton.swift | 2 +- .../Views/UserSettings/UserProfile.swift | 93 +++++++------- apps/ios/ru.lproj/Localizable.strings | 10 +- .../common/views/chat/VerifyCodeView.kt | 2 +- .../simplex/common/views/newchat/QRCode.kt | 2 +- 9 files changed, 167 insertions(+), 180 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 4218e94224..55d7b626fa 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -633,9 +633,6 @@ struct GroupChatInfoView: View { groupInfo: $groupInfo, groupProfile: groupInfo.groupProfile ) - .navigationBarTitle("Group profile") - .modifier(ThemedBackground()) - .navigationBarTitleDisplayMode(.large) } label: { Label("Edit group profile", systemImage: "pencil") } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 1617edd11f..4de3608735 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -26,6 +26,7 @@ struct GroupProfileView: View { @Environment(\.dismiss) var dismiss: DismissAction @Binding var groupInfo: GroupInfo @State var groupProfile: GroupProfile + @State private var currentProfileHash: Int? @State private var showChooseSource = false @State private var showImagePicker = false @State private var showTakePhoto = false @@ -34,60 +35,40 @@ struct GroupProfileView: View { @FocusState private var focusDisplayName var body: some View { - return VStack(alignment: .leading) { - Text("Group profile is stored on members' devices, not on the servers.") - .padding(.vertical) + List { + EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource) + .if(!focusDisplayName) { $0.padding(.top) } - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - profileImageView(groupProfile.image) - if groupProfile.image != nil { - Button { - groupProfile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) - } - } - } - - editImageButton { showChooseSource = true } - } - .frame(maxWidth: .infinity, alignment: .center) - - VStack(alignment: .leading) { - ZStack(alignment: .topLeading) { - if !validNewProfileName() { + Section { + HStack { + TextField("Group display name", text: $groupProfile.displayName) + .focused($focusDisplayName) + if !validNewProfileName { Button { alert = .invalidName(validName: mkValidName(groupProfile.displayName)) } label: { Image(systemName: "exclamationmark.circle").foregroundColor(.red) } - } else { - Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - profileNameTextEdit("Group display name", $groupProfile.displayName) - .focused($focusDisplayName) } - .padding(.bottom) let fullName = groupInfo.groupProfile.fullName if fullName != "" && fullName != groupProfile.displayName { - profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) - .padding(.bottom) - } - HStack(spacing: 20) { - Button("Cancel") { dismiss() } - Button("Save group profile") { saveProfile() } - .disabled(!canUpdateProfile()) + TextField("Group full name (optional)", text: $groupProfile.fullName) } + } footer: { + Text("Group profile is stored on members' devices, not on the servers.") } - .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) + Section { + Button("Reset") { + groupProfile = groupInfo.groupProfile + currentProfileHash = groupProfile.hashValue + } + .disabled(currentProfileHash == groupProfile.hashValue) + Button("Save group profile", action: saveProfile) + .disabled(!canUpdateProfile) + } } - .padding() - .frame(maxHeight: .infinity, alignment: .top) .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true @@ -95,6 +76,11 @@ struct GroupProfileView: View { Button("Choose from library") { showImagePicker = true } + if UIPasteboard.general.hasImages { + Button("Paste image") { + chosenImage = UIPasteboard.general.image + } + } } .fullScreenCover(isPresented: $showTakePhoto) { ZStack { @@ -120,8 +106,20 @@ struct GroupProfileView: View { } } .onAppear { + currentProfileHash = groupProfile.hashValue DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - focusDisplayName = true + withAnimation { focusDisplayName = true } + } + } + .onDisappear { + if canUpdateProfile { + showAlert( + title: NSLocalizedString("Save group profile?", comment: "alert title"), + message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"), + buttonAction: saveProfile, + cancelButton: true + ) } } .alert(item: $alert) { a in @@ -135,30 +133,30 @@ struct GroupProfileView: View { return createInvalidNameAlert(name, $groupProfile.displayName) } } - .contentShape(Rectangle()) - .onTapGesture { hideKeyboard() } + .navigationBarTitle("Group profile") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large) } - private func canUpdateProfile() -> Bool { - groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName() + private var canUpdateProfile: Bool { + currentProfileHash != groupProfile.hashValue && + groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && + validNewProfileName } - private func validNewProfileName() -> Bool { + private var validNewProfileName: Bool { groupProfile.displayName == groupInfo.groupProfile.displayName || validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces)) } - func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View { - TextField(label, text: name) - .padding(.leading, 32) - } - func saveProfile() { Task { do { groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces) + groupProfile.fullName = groupProfile.fullName.trimmingCharacters(in: .whitespaces) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) await MainActor.run { + currentProfileHash = groupProfile.hashValue groupInfo = gInfo chatModel.updateGroup(gInfo) dismiss() diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift index 7b01fe0300..373311073a 100644 --- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift +++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift @@ -24,85 +24,70 @@ struct VerifyCodeView: View { } private func verifyCodeView(_ code: String) -> some View { - ScrollView { - let splitCode = splitToParts(code, length: 24) - VStack(alignment: .leading) { - Group { + let splitCode = splitToParts(code, length: 24) + return List { + Section { + QRCode(uri: code, small: true) + + Text(splitCode) + .multilineTextAlignment(.leading) + .font(.body.monospaced()) + .lineLimit(20) + .frame(maxWidth: .infinity, alignment: .center) + } header: { + if connectionVerified { HStack { - if connectionVerified { - Image(systemName: "checkmark.shield") - .foregroundColor(theme.colors.secondary) - Text("\(displayName) is verified") - } else { - Text("\(displayName) is not verified") - } + Image(systemName: "checkmark.shield").foregroundColor(theme.colors.secondary) + Text("\(displayName) is verified").textCase(.none) } - .frame(height: 24) - - QRCode(uri: code) - .padding(.horizontal) - - Text(splitCode) - .multilineTextAlignment(.leading) - .font(.body.monospaced()) - .lineLimit(20) - .padding(.bottom, 8) + } else { + Text("\(displayName) is not verified").textCase(.none) } - .frame(maxWidth: .infinity, alignment: .center) - + } footer: { Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.") - .padding(.bottom) + } - Group { - if connectionVerified { - Button { - verifyCode(nil) - } label: { - Label("Clear verification", systemImage: "shield") - } - .padding() - } else { - HStack { - NavigationLink { - ScanCodeView(connectionVerified: $connectionVerified, verify: verify) - .navigationBarTitleDisplayMode(.large) - .navigationTitle("Scan code") - .modifier(ThemedBackground()) - } label: { - Label("Scan code", systemImage: "qrcode") - } - .padding() - Button { - verifyCode(code) { verified in - if !verified { showCodeError = true } - } - } label: { - Label("Mark verified", systemImage: "checkmark.shield") - } - .padding() - .alert(isPresented: $showCodeError) { - Alert(title: Text("Incorrect security code!")) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .center) - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + Section { + if connectionVerified { Button { - showShareSheet(items: [splitCode]) + verifyCode(nil) } label: { - Image(systemName: "square.and.arrow.up") + Label("Clear verification", systemImage: "shield") + } + } else { + NavigationLink { + ScanCodeView(connectionVerified: $connectionVerified, verify: verify) + .navigationBarTitleDisplayMode(.large) + .navigationTitle("Scan code") + .modifier(ThemedBackground()) + } label: { + Label("Scan code", systemImage: "qrcode") + } + Button { + verifyCode(code) { verified in + if !verified { showCodeError = true } + } + } label: { + Label("Mark verified", systemImage: "checkmark.shield") + } + .alert(isPresented: $showCodeError) { + Alert(title: Text("Incorrect security code!")) } } } - .onChange(of: connectionVerified) { _ in - if connectionVerified { dismiss() } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showShareSheet(items: [splitCode]) + } label: { + Image(systemName: "square.and.arrow.up") + } } } + .onChange(of: connectionVerified) { _ in + if connectionVerified { dismiss() } + } } private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) { diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 59eee1338b..a7e61b3105 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -21,7 +21,7 @@ enum DatabaseAlert: Identifiable { case deleteLegacyDatabase case deleteFilesAndMedia case setChatItemTTL(ttl: ChatItemTTL) - case error(title: LocalizedStringKey, error: String = "") + case error(title: String, error: String = "") var id: String { switch self { @@ -456,7 +456,7 @@ struct DatabaseView: View { } } catch let error { await MainActor.run { - alert = .error(title: "Error exporting chat database", error: responseError(error)) + alert = .error(title: NSLocalizedString("Error exporting chat database", comment: "alert title"), error: responseError(error)) progressIndicator = false } } @@ -492,10 +492,10 @@ struct DatabaseView: View { return migration } } catch let error { - await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert) + await operationEnded(.error(title: NSLocalizedString("Error importing chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert) } } catch let error { - await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert) + await operationEnded(.error(title: NSLocalizedString("Error deleting chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert) } } else { showAlert("Error accessing database file") @@ -513,7 +513,7 @@ struct DatabaseView: View { await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert) return true } catch let error { - await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert) + await DatabaseView.operationEnded(.error(title: NSLocalizedString("Error deleting database", comment: "alert title"), error: responseError(error)), $progressIndicator, $alert) return false } } @@ -522,7 +522,7 @@ struct DatabaseView: View { if removeLegacyDatabaseAndFiles() { legacyDatabase = false } else { - alert = .error(title: "Error deleting old database") + alert = .error(title: NSLocalizedString("Error deleting old database", comment: "alert title")) } } @@ -546,7 +546,7 @@ struct DatabaseView: View { let (title, message) = chatDeletedAlertText() showAlert(title, message: message, actions: { [okAlertActionWaiting] }) } else if case let .error(title, error) = dbAlert { - showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] }) + showAlert(title, message: error, actions: { [okAlertActionWaiting] }) } else { alert.wrappedValue = dbAlert cont.resume() @@ -567,7 +567,7 @@ struct DatabaseView: View { } } catch { await MainActor.run { - alert = .error(title: "Error changing setting", error: responseError(error)) + alert = .error(title: NSLocalizedString("Error changing setting", comment: "alert title"), error: responseError(error)) chatItemTTL = currentChatItemTTL afterSetCiTTL() } diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index e5263813fa..98f43f49b2 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -125,7 +125,7 @@ struct NewChatSheet: View { } NavigationLink { AddGroupView() - .navigationTitle("Create secret group") + .navigationTitle("Create group") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 9aa42930bf..aa2c04ccaa 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -25,28 +25,8 @@ struct UserProfile: View { var body: some View { List { - Group { - if profile.image != nil { - ZStack(alignment: .bottomTrailing) { - ZStack(alignment: .topTrailing) { - profileImageView(profile.image) - .onTapGesture { showChooseSource = true } - overlayButton("multiply", edge: .top) { profile.image = nil } - } - overlayButton("camera", edge: .bottom) { showChooseSource = true } - } - } else { - ZStack(alignment: .center) { - profileImageView(profile.image) - editImageButton { showChooseSource = true } - } - } - } - .frame(maxWidth: .infinity, alignment: .center) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - .padding(.top) - .contentShape(Rectangle()) + EditProfileImage(profileImage: $profile.image, showChooseSource: $showChooseSource) + .padding(.top) Section { HStack { @@ -133,25 +113,6 @@ struct UserProfile: View { .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } } - private func overlayButton( - _ systemName: String, - edge: Edge.Set, - action: @escaping () -> Void - ) -> some View { - Image(systemName: systemName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 12) - .foregroundColor(theme.colors.primary) - .padding(6) - .frame(width: 36, height: 36, alignment: .center) - .background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5)) - .clipShape(Circle()) - .contentShape(Circle()) - .padding([.trailing, edge], -12) - .onTapGesture(perform: action) - } - private func showFullName(_ user: User) -> Bool { user.profile.fullName != "" && user.profile.fullName != user.profile.displayName } @@ -189,8 +150,54 @@ struct UserProfile: View { } } -func profileImageView(_ imageStr: String?) -> some View { - ProfileImage(imageStr: imageStr, size: 192) +struct EditProfileImage: View { + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner + @Binding var profileImage: String? + @Binding var showChooseSource: Bool + + var body: some View { + Group { + if profileImage != nil { + ZStack(alignment: .bottomTrailing) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profileImage, size: 160) + .onTapGesture { showChooseSource = true } + overlayButton("multiply", edge: .top) { profileImage = nil } + } + overlayButton("camera", edge: .bottom) { showChooseSource = true } + } + } else { + ZStack(alignment: .center) { + ProfileImage(imageStr: profileImage, size: 160) + editImageButton { showChooseSource = true } + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .contentShape(Rectangle()) + } + + private func overlayButton( + _ systemName: String, + edge: Edge.Set, + action: @escaping () -> Void + ) -> some View { + Image(systemName: systemName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 12) + .foregroundColor(theme.colors.primary) + .padding(6) + .frame(width: 36, height: 36, alignment: .center) + .background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5)) + .clipShape(Circle()) + .contentShape(Circle()) + .padding([.trailing, edge], -12) + .onTapGesture(perform: action) + } } func editImageButton(action: @escaping () -> Void) -> some View { diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index a6025879eb..70cd739531 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1596,13 +1596,13 @@ set passcode view */ /* delete after time pref value */ -"default (%@)" = "по умолчанию (%@)"; +"default (%@)" = "базовый (%@)"; /* No comment provided by engineer. */ -"default (no)" = "по умолчанию (нет)"; +"default (no)" = "базовый (нет)"; /* No comment provided by engineer. */ -"default (yes)" = "по умолчанию (да)"; +"default (yes)" = "базовый (да)"; /* alert action swipe action */ @@ -1705,7 +1705,7 @@ swipe action */ "Delete messages" = "Удалить сообщения"; /* No comment provided by engineer. */ -"Delete messages after" = "Удалять сообщения через"; +"Delete messages after" = "Удалять сообщения"; /* No comment provided by engineer. */ "Delete old database" = "Удалить предыдущую версию данных"; @@ -4633,7 +4633,7 @@ chat item action */ "Send questions and ideas" = "Отправьте вопросы и идеи"; /* No comment provided by engineer. */ -"Send receipts" = "Отправлять отчёты о доставке"; +"Send receipts" = "Отчёты о доставке"; /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Отправьте из галереи или из дополнительных клавиатур."; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index e670fae5ef..91f7af2b95 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -68,7 +68,7 @@ private fun VerifyCodeLayout( } } - QRCode(connectionCode, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF)) + QRCode(connectionCode, small = true, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF)) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.weight(2f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 0ed32f845b..74c9a55ecf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -84,7 +84,7 @@ fun QRCode( Modifier .padding(padding) .widthIn(max = 400.dp) - .fillMaxWidth(if (small) 0.67f else 1f) + .fillMaxWidth(if (small) 0.63f else 1f) .aspectRatio(1f) .then(modifier) .clickable {